> ## Documentation Index
> Fetch the complete documentation index at: https://docs.intelligence-management-platform.com/llms.txt
> Use this file to discover all available pages before exploring further.

# User credential tools

> Collect an API key or login via a secure in-chat form using ExuluUserCredentialsConfig: declare typed fields, validate on submission, and receive credentials in execute — no OAuth app required.

When a tool needs a static secret from the user — an API key, a personal access token, or a username/password pair — declare an `authentication` config with `authType: "user_credentials"` on the tool. IMP renders a secure credential form directly in the chat UI the first time the tool is called, stores the values encrypted at rest, and injects them into every subsequent `execute` call. Your code never receives an unentered form; it only runs when credentials are present.

## Prerequisites

* Completed [Custom tools](/developers/tutorials/custom-tools) — you know how to build an `ExuluTool`.
* Read [ExuluTool — configuration](/developers/core/exulu-tool/configuration) — the user credentials section explains `ExuluUserCredentialsConfig` and `CredentialField`.

## What IMP handles for you

When `authentication` is declared with `authType: "user_credentials"`:

1. **Credential form prompt** — On the first call (and whenever stored credentials have been revoked or invalidated), IMP skips `execute` and returns a structured `credentialRequest` payload. The chat interface renders this as a typed form card — password fields are masked, `help` text is shown below each input.
2. **Secure submission** — When the user submits the form, the frontend posts `{ nonce, values }` to `POST /credentials/submit`. The endpoint requires a valid session and cross-checks the session user against the nonce (15-minute TTL, AES-encrypted) to prevent replay attacks across users.
3. **Encrypted storage** — Values are AES-encrypted (keyed off `NEXTAUTH_SECRET`) and stored in the `user_credentials` table under `(provider, userId)`. Plaintext never persists on disk.
4. **Injection** — On every subsequent tool call, IMP decrypts the stored row and injects `credentials` into `inputs` before calling `execute`. Values transit the server only — they never reach the model.
5. **History scrubbing** — The `credentialRequest` payload (which contains the nonce) is replaced in the model-visible conversation history with a scrub placeholder. A system guardrail also instructs the model to never ask the user to type credentials into the chat.

Your code only needs to declare the fields, optionally validate them on submission, and use `inputs.credentials` in `execute`.

## What you will build

An ERP integration tool that authenticates with an API key. The key is collected once through a secure in-chat form and reused for every subsequent call.

<Steps>
  <Step title="Declare the tool with user credentials authentication">
    ```typescript src/tools/erp-orders.ts theme={null}
    import { ExuluTool, CredentialInvalidError } from "@exulu/backend";
    import { z } from "zod";

    export const erpOrdersTool = new ExuluTool({
      id: "erp_orders",
      name: "ERP orders",
      description: "Fetches open orders from the ERP system for the signed-in user. Use this tool when the user asks about pending orders, order status, or shipment timelines.",
      type: "function",
      category: "erp",

      inputSchema: z.object({
        status: z
          .enum(["open", "shipped", "all"])
          .default("open")
          .describe("Filter orders by status."),
        limit: z
          .number()
          .int()
          .min(1)
          .max(100)
          .default(20)
          .describe("Maximum number of orders to return."),
      }),

      config: [
        {
          name: "erp_base_url",
          description: "Base URL of the ERP API, e.g. https://erp.example.com/api/v2",
          type: "string",
          default: "https://erp.example.com/api/v2",
        },
      ],

      needsApproval: false,

      authentication: {
        authType: "user_credentials",
        provider:  "example_erp",          // Shared key — multiple tools can share this
        fields: [
          {
            name:        "apiKey",
            label:       "ERP API key",
            type:        "password",
            placeholder: "ek-…",
            help:        "Find your personal API key under ERP → Settings → API access.",
          },
          {
            name:        "username",
            label:       "ERP username",
            type:        "text",
            placeholder: "alice@example.com",
          },
        ],
        validate: async (values) => {
          // Called server-side when the user submits the form.
          // Throw to reject the submission — the error message is shown in the form.
          const response = await fetch("https://erp.example.com/api/v2/auth/verify", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ apiKey: values.apiKey, username: values.username }),
          });
          if (!response.ok) {
            throw new Error("API key or username is invalid. Check ERP → Settings → API access.");
          }
        },
      },

      execute: async ({ status, limit, credentials, toolVariablesConfig }) => {
        // credentials is injected by the framework when stored credentials exist.
        // If no credentials are stored, IMP never reaches execute — it returns the form instead.
        // toolVariablesConfig contains admin-supplied config values keyed by their name
        // as declared in the tool's config schema (e.g. toolVariablesConfig?.erp_base_url).
        // Hard-coded here for simplicity; swap for toolVariablesConfig?.erp_base_url when
        // you add a config schema entry for it.
        const apiBase = "https://erp.example.com/api/v2";

        const url = new URL(`${apiBase}/orders`);
        url.searchParams.set("status", status);
        url.searchParams.set("limit", String(limit));

        const response = await fetch(url.toString(), {
          headers: {
            "X-Api-Key":  credentials.apiKey,
            "X-Username": credentials.username,
            Accept:       "application/json",
          },
        });

        if (response.status === 401) {
          // Stale key — tell IMP to delete the stored row and re-prompt in this same turn.
          throw new CredentialInvalidError("example_erp", "API key rejected by upstream");
        }

        if (!response.ok) {
          return { result: JSON.stringify({ error: `ERP API returned ${response.status}` }) };
        }

        const data = await response.json();
        return { result: JSON.stringify(data.orders ?? []) };
      },
    });
    ```

    The `credentials` object injected into `inputs` is typed as `Record<string, string>` — the same shape the user submitted through the form, one key per `fields[].name`:

    ```typescript theme={null}
    // What execute receives after the user fills in the form above
    {
      credentials: {
        apiKey:   "ek-abc123…",
        username: "alice@example.com",
      }
    }
    ```
  </Step>

  <Step title="Understand the credential form flow">
    When the agent calls `erp_orders` for a user who has not yet entered credentials:

    1. IMP checks the credential store for a `(example_erp, userId)` row. None exists.
    2. IMP skips `execute` and returns a structured short-circuit result:

    ```json theme={null}
    {
      "credentialRequest": {
        "provider":   "example_erp",
        "fields":     [
          { "name": "apiKey",   "label": "ERP API key",  "type": "password", "placeholder": "ek-…", "help": "Find your personal API key under ERP → Settings → API access." },
          { "name": "username", "label": "ERP username", "type": "text",     "placeholder": "alice@example.com" }
        ],
        "submitUrl":  "https://your-imp-backend.example.com/credentials/submit",
        "nonce":      "<AES-encrypted claims, 15-minute TTL>"
      },
      "result": null
    }
    ```

    3. The chat interface renders a credential form card. Password fields are masked; help text appears below each input.
    4. The user enters their API key and username and clicks **Save**. The frontend posts `{ nonce, values }` to `POST /credentials/submit` using the current session cookie.
    5. The submit endpoint verifies the session, decrypts the nonce (rejects if expired or tampered), cross-checks the session user against the nonce's `userId`, runs the optional `validate` hook, then writes the AES-encrypted values to the store. Returns `{ ok: true }`.
    6. The agent is notified that the form was submitted and calls `erp_orders` again.
    7. IMP finds the stored row, decrypts it, and injects `credentials` into `execute`.

    You do not need to handle this fallback in your code — IMP manages it entirely.
  </Step>

  <Step title="Share credentials across tools">
    Multiple tools that access the same provider (for example, an `erp_invoices` tool alongside `erp_orders`) should share the same `provider` key. The user fills in the form once; all tools in the group reuse the same stored row.

    ```typescript src/tools/erp-invoices.ts theme={null}
    export const erpInvoicesTool = new ExuluTool({
      id: "erp_invoices",
      // …
      authentication: {
        authType: "user_credentials",
        provider:  "example_erp",          // Same key as erpOrdersTool
        fields: [
          { name: "apiKey",   label: "ERP API key",  type: "password" },
          { name: "username", label: "ERP username", type: "text" },
        ],
      },
      execute: async ({ credentials }) => {
        // Same stored credentials — no second form shown
        const response = await fetch("https://erp.example.com/api/v2/invoices", {
          headers: { "X-Api-Key": credentials.apiKey, "X-Username": credentials.username },
        });
        const data = await response.json();
        return { result: JSON.stringify(data.invoices ?? []) };
      },
    });
    ```

    <Warning>
      When tools share a `provider`, they share one stored credential set per user. The `fields` arrays on all tools in the group must be identical (same names, same count). A mismatch causes a field-set validation error at the submit endpoint.
    </Warning>
  </Step>

  <Step title="Self-healing with CredentialInvalidError">
    If the upstream API rejects the stored key (for example, the user rotated their API key in the ERP console), throw `CredentialInvalidError` inside `execute`. IMP catches it, deletes the stale stored row, and returns a fresh credential form in the same turn — no extra code required.

    ```typescript theme={null}
    import { CredentialInvalidError } from "@exulu/backend";

    execute: async ({ credentials }) => {
      const response = await fetch("https://erp.example.com/api/v2/orders", {
        headers: { "X-Api-Key": credentials.apiKey },
      });

      if (response.status === 401) {
        // Stale row deleted; user sees the form again immediately.
        throw new CredentialInvalidError("example_erp", "API key rejected by upstream");
      }

      // …
    }
    ```

    `CredentialInvalidError` takes two arguments: the `provider` string (must match `authentication.provider`) and an optional human-readable `reason`. The `reason` is included in the error message but is not shown directly to the user.

    Only throw `CredentialInvalidError` for the tool's own provider. Any other error is re-thrown and surfaces as a normal tool error.
  </Step>

  <Step title="Register on the app">
    ```typescript src/exulu.ts theme={null}
    import { erpOrdersTool }   from "./tools/erp-orders";
    import { erpInvoicesTool } from "./tools/erp-invoices";

    export const ready = app.create({
      // …
      tools: [erpOrdersTool, erpInvoicesTool],
    });
    ```

    Enable the tools in the agent workbench. No additional server configuration is required — `NEXTAUTH_SECRET` (already set for your IMP installation) is used as the encryption key. Make sure `BACKEND` is set to the backend's public base URL so IMP can build the `submitUrl` in the credential form.
  </Step>
</Steps>

## Security posture

| Concern                   | How IMP handles it                                                                                                                                                                                                                                      |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **At-rest encryption**    | Credential values are AES-encrypted with `NEXTAUTH_SECRET` before storage. The plaintext never touches disk.                                                                                                                                            |
| **Nonce replay**          | Each credential form carries a 15-minute AES-encrypted nonce that binds `(provider, userId, expiresAt)`. The submit endpoint cross-checks the nonce's `userId` against the session user, so a leaked nonce cannot overwrite another user's credentials. |
| **Server-side injection** | Values are decrypted and injected into `execute` server-side. They never appear in the tool call the model sees or in the chat history.                                                                                                                 |
| **History scrubbing**     | `credentialRequest` payloads from earlier turns are replaced with a placeholder before the conversation history is forwarded to the model — the nonce and field definitions never reach the language model.                                             |
| **System guardrail**      | When any active tool has `authType: "user_credentials"`, IMP appends a system-prompt block instructing the model to never ask the user to type credentials into the chat and to ignore pasted values.                                                   |

## User management

Users can view and revoke their saved credentials in **Settings → Connections**. The interface lists every `(provider, authType)` pair the user has stored, with a **Revoke** button for each. After revocation, the next tool call that needs those credentials shows the credential form again.

The same operations are available via the API (session-authenticated):

```bash theme={null}
# List saved credential providers (metadata only — values are never returned)
GET /credentials

# Revoke credentials for one provider (idempotent)
DELETE /credentials/:provider
```

## What you built

* An ERP orders tool with `ExuluUserCredentialsConfig` that collects an API key and username through a secure in-chat form.
* A `validate` hook that tests the credentials against the upstream API before storing them.
* A `CredentialInvalidError` throw that triggers automatic re-prompting when the stored key is rejected.
* A shared `provider` key so multiple ERP tools reuse the same credential set.

## Next steps

<CardGroup cols={2}>
  <Card title="ExuluTool — configuration" icon="settings" href="/developers/core/exulu-tool/configuration">
    Full ExuluUserCredentialsConfig reference — every field, CredentialField type, and the injected credentials shape.
  </Card>

  <Card title="OAuth tools" icon="lock" href="/developers/tutorials/oauth-tools">
    Use the authorization-code + PKCE flow when the provider supports OAuth instead of static keys.
  </Card>

  <Card title="Custom tools" icon="wrench" href="/developers/tutorials/custom-tools">
    Build tools without authentication using Zod schemas and admin config params.
  </Card>

  <Card title="Tool approvals" icon="check-circle" href="/user-guide/chat/tool-approvals">
    How users see and approve tool calls in the chat interface.
  </Card>
</CardGroup>
