> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runtype.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runtype.com/_mcp/server.

# Runtime tools

> Define tools inline without saving them to your account

Define runtime tools directly in a dispatch request without saving them to your account. Use them for dynamic configurations, testing, multi-tenant applications, and one-off tasks.

## When to use runtime tools

Use runtime tools for the following cases:

| Use case            | Description                               |
| ------------------- | ----------------------------------------- |
| **Testing**         | Try tool configurations before saving.    |
| **Dynamic configs** | Vary tool URLs or parameters per request. |
| **User-provided**   | Accept tool definitions from end users.   |
| **One-off tasks**   | Use a tool for one execution.             |

## Basic example

The following example adds an external runtime tool to a flow prompt:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { FlowBuilder, RuntypeClient } from '@runtypelabs/sdk'

const client = new RuntypeClient({
  apiKey: process.env.RUNTYPE_API_KEY,
})

const result = await new FlowBuilder()
  .createFlow({ name: 'Weather Agent' })
  .prompt({
    name: 'Agent',
    model: 'claude-sonnet-5',
    userPrompt: 'What is the weather in Tokyo?',
    tools: {
      runtimeTools: [
        {
          name: 'get_weather',
          description: 'Get current weather for a city',
          toolType: 'external',
          parametersSchema: {
            type: 'object',
            properties: {
              city: { type: 'string', description: 'City name' },
            },
            required: ['city'],
          },
          config: {
            url: 'https://api.example.com/v1/current?city={{city}}',
            method: 'GET',
            headers: {
              Authorization: 'Bearer {{secret:WEATHER_API_KEY}}',
            },
          },
        },
      ],
    },
  })
  .run(client, { streamResponse: true })
```

Set `RUNTYPE_API_KEY` to your Runtype API key. Replace `WEATHER_API_KEY` with the
name of the managed secret that stores the weather API key.

The Python SDK equivalent is:

**`Python SDK`**

```python title="Python SDK"
import os

from runtype import RuntypeClient

client = RuntypeClient(api_key=os.environ["RUNTYPE_API_KEY"])

runtime_tool = {
    "name": "get_weather",
    "description": "Get current weather for a city",
    "toolType": "external",
    "parametersSchema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name"}
        },
        "required": ["city"]
    },
    "config": {
        "url": "https://api.example.com/v1/current?city={{city}}",
        "method": "GET",
        "headers": {
            "authorization": "Bearer {{secret:WEATHER_API_KEY}}"
        }
    }
}

for event in client.dispatch({
    "flow": {
        "steps": [{
            "type": "prompt",
            "config": {
                "model": "claude-sonnet-5",
                "userPrompt": "What is the weather in Tokyo?",
                "tools": {
                    "runtimeTools": [runtime_tool]
                }
            }
        }]
    }
}):
    print(event)
```

Set `RUNTYPE_API_KEY` to your Runtype API key. Replace `WEATHER_API_KEY` with the
name of the managed secret that stores the weather API key.

The cURL equivalent is:

**`cURL`**

```bash title="cURL"
curl https://api.runtype.com/v1/dispatch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "flow": {
      "steps": [{
        "type": "prompt",
        "config": {
          "model": "claude-sonnet-5",
          "userPrompt": "What is the weather in Tokyo?",
          "tools": {
            "runtimeTools": [{
              "name": "get_weather",
              "description": "Get current weather for a city",
              "toolType": "external",
              "parametersSchema": {
                "type": "object",
                "properties": {
                  "city": {"type": "string"}
                },
                "required": ["city"]
              },
              "config": {
                "url": "https://api.example.com/v1/current?city={{city}}",
                "method": "GET"
              }
            }]
          }
        }
      }]
    }
  }'
```

Replace `YOUR_API_KEY` with your Runtype API key.

### Add runtime tools to an agent dispatch

To give a saved agent extra tools for one request, put them in `agent.tools.runtimeTools` on `POST /v1/dispatch`. The agent's saved tools stay available:

**`cURL`**

```bash title="cURL"
curl https://api.runtype.com/v1/dispatch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": {
      "agentId": "YOUR_AGENT_ID",
      "tools": {
        "runtimeTools": [{
          "name": "get_weather",
          "description": "Get current weather for a city",
          "toolType": "external",
          "parametersSchema": {
            "type": "object",
            "properties": {
              "city": {"type": "string"}
            },
            "required": ["city"]
          },
          "config": {
            "url": "https://api.example.com/v1/current?city={{city}}",
            "method": "GET"
          }
        }]
      }
    },
    "messages": [{"role": "user", "content": "What is the weather in Tokyo?"}]
  }'
```

Replace `YOUR_API_KEY` with your Runtype API key and `YOUR_AGENT_ID` with the ID of the saved agent.

`POST /v1/agents/{id}/execute` also accepts runtime tools in `tools.runtimeTools`. That route accepts the `external`, `custom`, `flow`, `subagent`, `local`, and `search` tool types. It rejects a flow tool with an inline `config.flow`, so reference a saved flow with `config.flowId` instead.

Tool search is on by default for multi-turn agents (`loopConfig.maxTurns` of 2 or more). When such an agent has 20 or more tools, Runtype holds the agent's runtime tools back from the model and adds a `tool_search` tool. The model searches, and the tools that it finds load on the next turn. Saved tools, built-in tools, and MCP server tools are always sent. To send every runtime tool on every turn, set `agent.tools.toolSearch` to `{ "enabled": false }`. To keep specific runtime tools loaded, list their names in `agent.tools.toolSearch.alwaysLoaded`. For the full behavior, see [Tool search](/user-guide/agents/agent-tools#tool-search).

## Tool types

Runtime tools support the following types.

### External tools

Call an HTTP API with an external tool:

**`External tool`**

```typescript title="External tool"
{
  name: 'fetch_user',
  description: 'Fetch user details by ID',
  toolType: 'external',
  parametersSchema: {
    type: 'object',
    properties: {
      userId: { type: 'string' }
    },
    required: ['userId']
  },
  config: {
    url: 'https://api.example.com/users/{{userId}}',
    method: 'GET',
    headers: {
      'Authorization': 'Bearer {{secret:API_TOKEN}}'
    }
  }
}
```

Replace `API_TOKEN` with the name of the managed secret that stores the API token.

An external tool's `config` accepts the following fields:

| Field     | Required | Description                                                                                          |
| --------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `url`     | Yes      | The request URL. Supports `{{param}}` placeholders and must be a valid URL after substitution.       |
| `method`  | No       | `GET`, `POST`, `PUT`, or `DELETE`. The default is `POST`.                                            |
| `headers` | No       | Request headers. Values support `{{param}}` and `{{secret:NAME}}`.                                   |
| `body`    | No       | A JSON template for the request body. Values support `{{param}}` and `{{secret:NAME}}` placeholders. |

For a `POST` example with a `body`, see [Receive binary results from other tools](#receive-binary-results-from-other-tools).

#### Receive binary results from other tools

When a tool returns binary media, such as a browser screenshot or a generated image, Runtype stores the bytes and adds a `runtype-asset://` handle to the tool result. The model passes the handle to the next tool as a plain string instead of re-typing the base64 content.

To receive those bytes in an external tool, declare `contentEncoding: 'base64'` on the parameter. Runtype resolves a handle in that parameter to the stored bytes, as base64, before the request is dispatched:

**`External tool that accepts a handle`**

```typescript title="External tool that accepts a handle"
{
  name: 'upload_image',
  description: 'Upload an image to the asset library',
  toolType: 'external',
  parametersSchema: {
    type: 'object',
    properties: {
      filename: { type: 'string' },
      image: {
        type: 'string',
        contentEncoding: 'base64',
        description: 'Image bytes, or a runtype-asset:// handle from another tool'
      }
    },
    required: ['filename', 'image']
  },
  config: {
    url: 'https://api.example.com/images',
    method: 'POST',
    headers: {
      'Authorization': 'Bearer {{secret:API_TOKEN}}'
    },
    body: {
      filename: '{{filename}}',
      data: '{{image}}'
    }
  }
}
```

A parameter without `contentEncoding` receives the handle string unchanged. Handles are scoped to your organization and expire after 7 days. A handle that is missing, expired, or from another organization fails that tool call with an error. The same rule applies to MCP server tools that declare the keyword in their input schema, and to the built-in `store_asset.content`, `publish_page.content`, and `send_email.attachments[].content` parameters.

### Custom tools

Run code in a sandbox with a custom tool:

**`Custom tool`**

```typescript title="Custom tool"
{
  name: 'calculate_tax',
  description: 'Calculate sales tax',
  toolType: 'custom',
  parametersSchema: {
    type: 'object',
    properties: {
      amount: { type: 'number' },
      rate: { type: 'number' }
    },
    required: ['amount', 'rate']
  },
  config: {
    code: `
      const tax = amount * (rate / 100);
      return {
        subtotal: amount,
        tax: tax.toFixed(2),
        total: (amount + tax).toFixed(2)
      };
    `,
    timeout: 5000
  }
}
```

A custom tool's `config` requires `code`. The value that the code returns is the tool result. The optional `timeout` is in milliseconds, with a maximum of `30000` (30 seconds) for runtime custom tools. Saved custom tools have a different range, described in [Creating custom tools](/user-guide/tools/creating-custom-tools).

#### Network access and managed secrets

Custom tool code has no network access unless `config.networkAccess` turns it on. Set it to `{ allowedHostnames: [...] }` to allow specific hosts, or to `'on'` to allow any public host. Omitting it, or setting `'off'`, blocks every request.

To send a managed secret, declare it in `config.secrets` by its bare name. Your code never receives the value: it reads a placeholder token from `secrets.NAME`, and the egress proxy replaces the placeholder with the real value only on requests to the declared host and path. `networkAccess` must allow that host.

**`Custom tool that calls an API with a managed secret`**

```typescript title="Custom tool that calls an API with a managed secret"
{
  name: 'github_user',
  description: 'Look up the authenticated GitHub user',
  toolType: 'custom',
  parametersSchema: { type: 'object', properties: {} },
  config: {
    code: `
      const res = await fetch('https://api.github.com/user', {
        headers: { Authorization: \`Bearer \${secrets.GITHUB_TOKEN}\` }
      });
      return await res.json();
    `,
    networkAccess: { allowedHostnames: ['api.github.com'] },
    secrets: [
      { key: 'GITHUB_TOKEN', hostnamePattern: 'api.github.com', injectionMode: 'bearer_header' }
    ]
  }
}
```

An API key that dispatches a runtime tool with `secrets` needs the `SECRETS:READ` permission. `{{secret:NAME}}` is never allowed in custom code. These settings work with the `cloudflare-worker` (default) and `runtype-sandbox` providers. For every field, see [Call an API from custom code](/user-guide/tools/creating-custom-tools#call-an-api-from-custom-code).

### Flow tools

Run another Runtype flow as a tool. The tool returns the value that the flow's final executed step wrote to its output variable.

**`Flow tool`**

```typescript title="Flow tool"
{
  name: 'analyze_sentiment',
  description: 'Run sentiment analysis on text',
  toolType: 'flow',
  parametersSchema: {
    type: 'object',
    properties: {
      text: { type: 'string' }
    },
    required: ['text']
  },
  config: {
    flowId: 'YOUR_FLOW_ID',
    parameterMapping: { text: 'input_text' },
    outputMapping: 'sentiment'
  }
}
```

Replace `YOUR_FLOW_ID` with the ID of the flow that you want to run.

A flow tool's `config` accepts the following fields:

* `flowId`: the ID of a saved flow. You can reference a saved flow tool with `toolId` instead.
* `flow`: an inline flow definition with a non-empty `steps` array. Only an agent dispatch through `POST /v1/dispatch` accepts it. A flow dispatch and `POST /v1/agents/{id}/execute` reject it, so reference a saved flow with `flowId` there.
* `parameterMapping`: renames tool parameters before the flow runs. Each key is a tool parameter name, and each value is the flow input name that receives it. Unmapped parameters pass through with their own names.
* `outputMapping`: a dot path into the flow's output value.
* `outputVariable`: a flow variable to return instead of the final step's output.

### Subagent tools

Delegate a self-contained task to a focused child agent. The child runs in its own context window and can't see the parent conversation. It returns only its final answer. Runtype intersects the child's `allowedTools` with the parent's resolved tools.

**`Subagent tool`**

```typescript title="Subagent tool"
{
  name: 'research_topic',
  description: 'Research a topic and return a summary',
  toolType: 'subagent',
  parametersSchema: {
    type: 'object',
    properties: {
      task: { type: 'string', description: 'What to research' }
    },
    required: ['task']
  },
  config: {
    // Set exactly one of `agentId` or `agent`.
    agentId: 'YOUR_AGENT_ID',
    allowedTools: ['builtin:exa', 'mcp:linear:*'],
    maxTurns: 5,
    timeoutMs: 300000,
    outputFormat: 'text'
  }
}
```

Replace `YOUR_AGENT_ID` with the ID of the saved agent. To define the child inline, replace `agentId` with an `agent` object and keep only one of these fields.

A subagent tool's `config` follows these rules:

* Set exactly one of `agentId` or `agent`.
* An inline `agent` requires `allowedTools`. Set it to `[]` to grant the child no tools.
* `maxTurns` must be a positive integer.
* `outputFormat` is `text`, `json`, or `last_message`.

To let an agent decide what to delegate at runtime, add `subagentConfig` to its
tools configuration. Runtype synthesizes a `spawn_subagent` tool. The model can
use it to choose the task, tools from `toolPool`, and an optional system prompt:

**`Dynamic subagent configuration`**

```typescript title="Dynamic subagent configuration"
tools: {
  toolIds: ['builtin:exa', 'mcp:linear:create_issue'],
  subagentConfig: {
    toolPool: ['builtin:exa', 'mcp:linear:*'], // subset of parent's tools
    maxSpawnsPerRun: 5,   // default 5
    maxTurnsLimit: 10,    // hard cap, default 10
    allowNesting: false   // default false
  }
}
```

`subagentConfig` also accepts `defaultMaxTurns`, `defaultModel`, and `defaultTimeoutMs`. `maxSpawnsPerRun` accepts 1 to 50. `maxTurnsLimit` and `defaultMaxTurns` accept 1 to 100. `defaultTimeoutMs` accepts 1,000 to 600,000 milliseconds.

Attached subagents use the parent's approval policy when the parent explicitly
sets `tools.approval.require`, including `false` or `[]` to require no approvals.
Otherwise, the child's own policy applies. This rule is the same for in-process
and durable execution. Detached subagents use their own approval policy.

> **Note**
>
> The parent stream shows one tool bubble for each subagent call. The child's internal turns and
> tool calls don't appear in the parent stream. If a child tool requires approval, the parent
> pauses with an `approval_start` event. The event includes a `subagent` field that identifies the
> parent subagent tool and the child agent's name. The `toolName` and `parameters` fields identify
> the inner tool that needs approval. Each subagent call counts as one tool call against the
> parent's `maxToolCalls`, and the child's cost counts toward the parent's total.

### Local tools

Run a local tool in your client instead of on Runtype. Set `toolType` to `local`.
The dispatch pauses and emits `await`, then your client runs the handler and
resumes the run. A local tool doesn't require a `config` object:

**`Local tool`**

```typescript title="Local tool"
{
  name: 'ask_user',
  description: 'Ask the end user a question',
  toolType: 'local',
  parametersSchema: {
    type: 'object',
    properties: {
      question: { type: 'string' }
    },
    required: ['question']
  }
}
```

A local tool and a [client tool](#client-side-tools-webmcp) both run in the caller and resume the same way. Declare a local tool in `runtimeTools` when it belongs to a flow step or agent tool configuration. Send client tools in the top-level `clientTools` field to add caller-run tools per request without changing the flow or agent.

## Pass secrets

Store sensitive values in **Settings > Secrets**. Then use a managed reference in an external tool's URL, headers, or body:

**`Managed secret reference`**

```typescript title="Managed secret reference"
headers: {
  Authorization: 'Bearer {{secret:API_TOKEN}}'
}
```

Replace `API_TOKEN` with the name of the managed secret that stores the API token.

Secret references follow these rules:

* Only `external` tools can reference a secret. A `{{secret:NAME}}` reference in any other tool type returns HTTP `400`.
* The API key that sends the request needs the `SECRETS:READ` or `SECRETS:*` permission. Without it, the request returns HTTP `403`.

Runtype doesn't support a top-level dispatch `secrets` map. A non-empty map returns HTTP `400` with `RUNTIME_AGENT_TRANSIENT_SECRETS_UNSUPPORTED` on an agent dispatch, or `RUNTIME_FLOW_TRANSIENT_SECRETS_UNSUPPORTED` on a flow dispatch. An empty map is accepted and does nothing. Use `{{secret:NAME}}` references for credentials instead. Runtype encrypts managed values at rest and doesn't log them or return them in API responses. For more information, see [Managing secrets](/user-guide/settings/managing-secrets).

## Variable substitution

Tool templates resolve against the tool call's arguments and the managed secret store. The following table lists the template forms:

| Variable                            | Source                                              |
| ----------------------------------- | --------------------------------------------------- |
| `{{paramName}}`                     | Tool call argument                                  |
| `{{secret:NAME}}`                   | Managed secret store                                |
| `{{hiddenName}}`                    | Execution variable listed in `hiddenParameterNames` |
| `{{_tenant.id}}`, `{{_endUser.id}}` | The execution's tenant and end user                 |
| `{{_identity.token}}`               | Runtype-signed identity assertion, minted per call  |

Execution variables such as `{{_record.field}}`, `{{_flow.id}}`, and `{{_user.id}}` do not resolve in a tool's URL, headers, or body. Runtype sends an unresolved template as literal text. To pass an execution variable, list its name in `hiddenParameterNames`, as described in [Hide parameters from the model](#hide-parameters-from-the-model).

The following tool configuration fills the URL from an argument, the authorization header from a secret, and a tenant header from a hidden parameter:

**`Template variables`**

```typescript title="Template variables"
{
  hiddenParameterNames: ['_tenant'],
  config: {
    url: 'https://api.example.com/orders/{{id}}',
    headers: {
      'Authorization': 'Bearer {{secret:API_TOKEN}}',
      'X-Tenant-Id': '{{_tenant.id}}'
    }
  }
}
```

Replace `API_TOKEN` with the name of the managed secret that stores the API token. For the fields each tool type templates and how unresolved values behave, see [Tool template variables](/developer-guides/guides/tool-template-variables).

## Combine with saved tools

Combine runtime tools with saved tools in one tools configuration:

**`Combined tools`**

```typescript title="Combined tools"
tools: {
  // Saved tools by ID
  toolIds: [
    'YOUR_TOOL_ID',
    'mcp:notion:create_page'
  ],
  // Runtime tools
  runtimeTools: [
    {
      name: 'dynamic_tool',
      description: 'Run a dynamic tool',
      toolType: 'external',
      parametersSchema: { type: 'object', properties: {} },
      config: { url: 'https://api.example.com', method: 'GET' }
    }
  ],
  maxToolCalls: 10
}
```

Replace `YOUR_TOOL_ID` with the ID of the saved tool.

## SDK helper functions

Use the TypeScript SDK helper function to create an external tool:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { createExternalTool } from '@runtypelabs/sdk'

const weatherTool = createExternalTool({
  name: 'get_weather',
  description: 'Get weather for a city',
  parametersSchema: {
    type: 'object',
    properties: {
      city: { type: 'string' },
    },
  },
  url: 'https://api.example.com/current?city={{city}}',
  method: 'GET',
  headers: {
    Authorization: 'Bearer {{secret:WEATHER_API_KEY}}',
  },
})

// Use in flow
tools: {
  runtimeTools: [weatherTool]
}
```

Replace `WEATHER_API_KEY` with the name of the managed secret that stores the
weather API key.

## Client-side tools (WebMCP)

Runtime tools run on Runtype servers. Runtype calls the external URL, runs the
sandboxed code, or invokes the nested flow. **Client tools** run in your client,
such as a browser page or an SDK process. Define them per request in the top-level
`clientTools` field. The field works with `POST /v1/dispatch` and
`POST /v1/agents/:id/execute`, with the same admission rules. When the model calls
a client tool, Runtype pauses the run and streams a unified `await` event. Run the
tool locally, then resume the run with the result. For the pause event details, see
[Pause and resume on an agent dispatch](#pause-and-resume-on-an-agent-dispatch).

Each entry has a `name`, `description`, `parametersSchema`, and an `origin`:

* `origin: 'sdk'`: a tool that your SDK process executes. Runtype accepts it automatically.
* `origin: 'webmcp'`: a tool that a browser page registers through WebMCP
  (`document.modelContext`). On the API-key paths (`/v1/dispatch` and
  `/v1/agents/:id/execute`), Runtype accepts WebMCP tools by default. Include a tool
  in `clientTools` to opt in.

### Reference a Runtype MCP catalog tool

When the tool is one Runtype's own MCP catalog already defines, send a reference
instead of a full manifest:

```json
{ "name": "create_flow", "catalog": "runtype-mcp" }
```

Runtype resolves `description` and `parametersSchema` from its own copy of the
catalog, so you don't send (or keep in sync) either one. A name the catalog does
not define returns a 400 rather than being dropped. The resolved entry is admitted
as `origin: 'webmcp'` and goes through the same allowlist, naming, and duplicate
rules as any other entry.

References and full manifests mix freely in one array:

```json
{
  "clientTools": [
    { "name": "create_flow", "catalog": "runtype-mcp" },
    {
      "name": "highlight_row",
      "description": "Highlight a row in the on-page table.",
      "parametersSchema": { "type": "object", "properties": { "id": { "type": "string" } } },
      "origin": "webmcp"
    }
  ]
}
```

The client tool size limits in [Limits](#limits) apply to manifests you send. A
reference carries no description or schema of its own, so it isn't measured
against them.

Set `untrustedContentHint: true` when a tool's **output** contains untrusted
third-party data. Runtype wraps that result in a nonce-delimited envelope with the
`UNTRUSTED_TOOL_OUTPUT` marker before the model reads it. WebMCP output receives
this treatment without the hint. Set the hint for SDK tools whose results can
contain user-supplied or externally fetched content.

Client tools merge into the model's tool set with the precedence
`saved < runtimeTools < clientTools`. A later turn can override a saved tool with
the same name without editing the flow.

The API-key `/v1/dispatch` examples use the lower-level path. Use the Persona
widget for browser-embedded chat. Use direct dispatch when you build a non-Persona
chat UI, run local tools from an SDK or native process, or proxy browser tool calls
through a server that stores the Runtype API key. Don't expose a Runtype API key in
a browser page that you don't control.

The following TypeScript example runs a local tool through the SDK:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { RuntypeClient } from '@runtypelabs/sdk'

const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })

// scope: 'turn' snapshots your local tools into the dispatch envelope's
// clientTools[] and drives the dispatch + resume loop for you, executing
// each tool call locally.
await client.runWithLocalTools(
  {
    flow: { id: 'YOUR_FLOW_ID' },
    messages: [{ role: 'user', content: 'What time is it?' }],
  },
  {
    get_time: {
      description: 'Return the current ISO time',
      parametersSchema: { type: 'object', properties: {} },
      execute: async () => new Date().toISOString(),
    },
  },
  { scope: 'turn' }
)
```

Replace `YOUR_FLOW_ID` with the ID of the flow that you want to run. Set
`RUNTYPE_API_KEY` to your Runtype API key.

The following request registers a browser tool for an API-key dispatch:

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/dispatch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": { "agentId": "YOUR_AGENT_ID" },
    "messages": [{ "role": "user", "content": "Search the catalog" }],
    "clientTools": [
      {
        "name": "search",
        "description": "Search the catalog",
        "parametersSchema": { "type": "object", "properties": {} },
        "origin": "webmcp"
      }
    ]
  }'
```

Replace `YOUR_API_KEY` with your Runtype API key and `YOUR_AGENT_ID` with the ID of
the saved agent.

### Restrict which client tools are admitted

Runtype accepts every submitted client tool by default. To narrow the accepted
`origin: 'webmcp'` tools, pass `clientToolsPolicy` with an `allowlist` of glob
patterns. Use an exact name or a name with one trailing `*`. The allowlist doesn't
gate SDK-origin tools.

Configure the policy as follows:

**`Client tool policy`**

```json title="Client tool policy"
{
  "agent": { "agentId": "YOUR_AGENT_ID" },
  "clientTools": [
    {
      "name": "search_products",
      "description": "Search products",
      "parametersSchema": { "type": "object" },
      "origin": "webmcp"
    },
    {
      "name": "delete_account",
      "description": "Delete the account",
      "parametersSchema": { "type": "object" },
      "origin": "webmcp"
    }
  ],
  "clientToolsPolicy": { "allowlist": ["search_*"] }
}
```

The policy accepts `search_products` and drops `delete_account`. Omit
`clientToolsPolicy` to admit every WebMCP tool. Replace `YOUR_AGENT_ID` with the ID
of the saved agent.

`clientToolsPolicy` applies to API-key callers on both `POST /v1/dispatch` and
`POST /v1/agents/:id/execute`. It applies across the request and matches bare tool
names. It doesn't provide an origin-scoped surface policy. Persona and other
client-token chat surfaces use `behavior.webmcp.allowlist` instead.

### Persona chat surfaces and WebMCP

Persona chat widgets use the public client-token path (`/v1/client/chat`), so the
`chat` surface's `behavior.webmcp` policy controls WebMCP admission. Enable page-tool
discovery with `webmcp: { enabled: true }` in the Persona configuration.

> **Warning**
>
> Client Chat can pause for a browser-side client tool and resume that same execution through `POST
>   /v1/client/resume`. This isn't a human tool-approval channel. If the selected Agent, Flow, nested
> Flow, saved tool, or subagent can reach a human approval gate, `POST /v1/client/chat` rejects the
> request before execution with HTTP `501` and code `APPROVAL_MODE_UNSUPPORTED`. Use an
> authenticated dispatch or dashboard execution host when a person must approve a server-side tool.
> The one exception is a root Runtype Agent gate that sets `tools.approval.approver` to `end-user`.
> See [Let the chat visitor approve a tool](#let-the-chat-visitor-approve-a-tool).

### Let the chat visitor approve a tool

By default, only the Agent's owner can approve a gated tool. Ask one question before you change that: does this gate ask for the visitor's consent, or enforce your policy? If the gate confirms what the visitor wants (for example, placing a pickup order the visitor pays for), the visitor is the right person to answer it. If it enforces your policy or budget (refunds, discounts, credits, payouts, or spend on your account), keep it an owner gate, because a visitor must not approve their own exception. Let the visitor approve a consent gate in the widget:

**`Agent config`**

```json title="Agent config"
{
  "tools": {
    "approval": {
      "require": ["place_pickup_order"],
      "approver": "end-user"
    }
  }
}
```

The run pauses with an `approval_start` event that carries the tool name and its exact parameters. Your chat client shows them with **Approve** and **Deny** and sends the decision to `POST /v1/client/approve` with `{ sessionId, executionId, approvalId, decision }`; the run then continues in the same conversation. Persona 4.24.0 and earlier do not call this route yet, so enable an `end-user` gate only with a client that does. Runtype enforces the following rules:

* Only the browser session that started the turn can answer, and only once.
* The approval covers the reviewed parameters only. If the arguments change, the visitor is asked again.
* Visitors can't create **Always allow** grants.
* A gate on a nested Flow, subagent, or Skill still returns `APPROVAL_MODE_UNSUPPORTED`. Claude Managed Agents keep refusing all gates on Client Chat.
* Your owner approval routes (`POST /v1/dispatch/approve`) still answer any pause.

Enable WebMCP in the Persona configuration as follows:

**`Persona widget config`**

```typescript title="Persona widget config"
initAgentWidget({
  target: '#chat',
  config: {
    clientToken: 'YOUR_CLIENT_TOKEN',
    webmcp: { enabled: true },
  },
})
```

Replace `YOUR_CLIENT_TOKEN` with the client token for the chat surface.

Set the WebMCP policy on the chat surface as follows:

**`Chat surface policy`**

```json title="Chat surface policy"
{
  "type": "chat",
  "behavior": {
    "webmcp": {
      "enabled": true,
      "allowlist": [
        {
          "origin": "https://store.example.com",
          "tools": ["search_*", "get_cart"]
        },
        { "origin": "*", "tools": ["read_page"] }
      ]
    }
  }
}
```

On a Persona surface, the following rules apply:

* The embedding page registers tools with `document.modelContext.registerTool(toolDefinition)`.
* Persona snapshots those tools per turn and forwards them as
  `clientTools[]` with `origin: 'webmcp'`.
* Runtype applies a server-owned `webmcp:` prefix before the model sees the
  tool.
* `behavior.webmcp.enabled` must be true, and any `allowlist` rules must match
  the validated request origin and the bare tool name.
* The client token's `allowedOrigins` remains the enforced CORS boundary for
  which browser origins may call the surface.
* Persona also has SDK-owned client tools that require no page registration:
  expose `ask_user_question` with `features.askUserQuestion.expose: true` or
  `suggest_replies` with `suggestions.followUps.expose: true` (`features.suggestReplies.expose` is a deprecated alias that still works).

The dashboard WebMCP tab shows page tools and origins observed on the chat
surface over the trailing 7 days. Use the tab to promote a discovered tool to an
origin-scoped allowlist rule.

### Pause and resume on an agent dispatch

When you dispatch with an `agent` or `flow` payload, the execution uses these
pause events:

* A client-tool pause emits `await`.
* A tool-approval pause emits `approval_start`.
* A question for the **person** emits `await` with an `awaitReason` and an
  `elicitation` payload. An MCP server uses `mcp_elicitation`. An external A2A
  agent uses `a2a_input_required` or `a2a_auth_required`.

Use the fields in the `await` event to run the tool and resume the execution:

**`await`**

```json title="await"
{
  "type": "await",
  "executionId": "YOUR_EXECUTION_ID",
  "seq": 12,
  "toolId": "YOUR_TOOL_ID",
  "toolCallId": "YOUR_TOOL_CALL_ID",
  "toolName": "search_products",
  "parameters": { "query": "waterproof trail shoe" },
  "origin": "webmcp",
  "pageOrigin": "https://store.example.com",
  "awaitedAt": "2026-06-16T00:00:00Z"
}
```

Replace `YOUR_EXECUTION_ID`, `YOUR_TOOL_ID`, and `YOUR_TOOL_CALL_ID` with the
values from the event.

A tool-approval pause uses `approval_start`. When a child agent calls the gated
tool, the event also carries a `subagent` object. The `toolName` and `parameters`
fields describe the inner tool that needs approval. The `subagent.toolName` field
names the parent subagent tool:

**`approval_start (subagent)`**

```json title="approval_start (subagent)"
{
  "type": "approval_start",
  "executionId": "YOUR_EXECUTION_ID",
  "seq": 12,
  "approvalId": "YOUR_APPROVAL_ID",
  "toolCallId": "YOUR_TOOL_CALL_ID",
  "toolName": "send_email",
  "toolType": "custom",
  "parameters": { "to": "dana@example.com" },
  "timeout": 300000,
  "startedAt": "2026-06-17T00:00:00Z",
  "iteration": 1,
  "subagent": { "toolName": "delegate_email", "agentName": "Email Subagent" }
}
```

Replace the placeholders with the IDs from the event. Use `dana@example.com` only
as an example recipient.

Values injected for protected parameters appear as `[REDACTED]` in approval
payloads. Display the placeholder as protected context; approving or denying
the call doesn't require resubmitting those values.

Approve an approval pause through the approval endpoint. For a client-tool pause,
run the tool locally, then post its result to `/v1/dispatch/resume`:

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/dispatch/resume \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "executionId": "YOUR_EXECUTION_ID",
    "toolOutputs": {
      "YOUR_TOOL_CALL_ID": { "result": "YOUR_TOOL_RESULT" }
    },
    "streamResponse": true
}'
```

Replace `YOUR_API_KEY`, `YOUR_EXECUTION_ID`, `YOUR_TOOL_CALL_ID`, and
`YOUR_TOOL_RESULT` with values from your request and the tool result.

Use these fields when you resume a client-tool pause:

* `executionId`: the value from the `await` event. It identifies the paused turn.
* `toolOutputs`: an object keyed by the per-call `toolCallId` from the event. You
  can also key a single call by tool name.

The resumed stream continues with events such as `text_delta` and ends with
`execution_complete` or `execution_error`, or pauses with another `await`. That
`await` can identify an unanswered call from the current batch or a new call.

A resume of a saved-agent run can return `{ "executionId": "...", "status": "running" }`
or a `detached` `await` instead of the continued stream. Follow the run with
`GET /v1/executions/{executionId}/events?after=`, as described in
[Detached streams](#detached-streams).

#### Partial batches and resume conflicts

When an agent pauses with multiple client-tool calls, you can submit results for
only the calls that have finished. Runtype retains accepted answers and re-emits
the outstanding calls without requesting another model response. It resumes the
model only after every call in that batch has an answer. Match each result to its
exact `toolCallId`, even when several calls use the same tool name; don't resend
already accepted answers or treat unknown call IDs as completed work.

Serialize resume submissions for a flow execution. Concurrent resumes of the same
flow execution return HTTP `409`. Check the response body's `code`:

* `CONTINUATION_IN_PROGRESS`: another request owns this continuation. Wait for
  that request's response or stream. If it fails before advancing the checkpoint,
  you can retry the still-pending calls; don't blindly retry while it is running.
* `RESUME_STATE_CHANGED`: the submitted pause was replaced or already consumed.
  Don't replay its tool results. Use the latest `await` event or JSON paused
  response from the winning continuation and answer only its outstanding calls.
* HTTP `404` means no resumable checkpoint remains, including a completed or
  expired execution. Replaying old results can't restart it.

These checks also apply when `streamResponse` is `true`: a rejected admission is
an HTTP error, not a successful resumed stream. Resume isn't a general
idempotency key for side effects; keep the original tool result while recovering
rather than rerunning the local action automatically.

For client-token authentication, use the
[browser resume endpoint](#resume-from-a-browser-client-token-path) instead of
sending a secret API key from a page.

#### Elicitation pauses: when the question is for the human

Not every `await` event represents a tool that you run. An MCP server can request
input during a `tools/call`. An [external A2A agent](/user-guide/flows/agent-and-flow-templates#a2a-agent) can return
`input-required` or `auth-required`. In both cases, the peer asks the **person**,
not your process. These cases use the same `await` event. Distinguish them with
`awaitReason`:

**`await (external A2A agent asked a question)`**

```json title="await (external A2A agent asked a question)"
{
  "type": "await",
  "executionId": "YOUR_EXECUTION_ID",
  "seq": 8,
  "toolId": "YOUR_TASK_ID",
  "toolName": "external_agent",
  "awaitReason": "a2a_input_required",
  "elicitation": {
    "mode": "form",
    "message": "Which travel dates do you want to book?",
    "serverName": "Travel Agent"
  },
  "externalAgent": {
    "contextId": "YOUR_CONTEXT_ID",
    "taskId": "YOUR_TASK_ID"
  },
  "awaitedAt": "2026-08-11T00:00:00Z"
}
```

Replace `YOUR_EXECUTION_ID`, `YOUR_TASK_ID`, and `YOUR_CONTEXT_ID` with the values
from the event.

Use these fields to handle the elicitation:

* `elicitation.message` is **peer-controlled, display-only text**. Show it to the
  person, but don't branch your logic on its content. Treat `awaitReason` as UX
  context, not a control signal.
* `elicitation.mode` is `form` (answer with text) or `url` (send the human to
  `elicitation.url` to authenticate or consent, then continue). A2A pauses use
  `form`. MCP servers can use `url`.
* `externalAgent` carries the peer's conversation handles (`contextId` /
  `taskId`) so you can correlate the paused run with the external agent's
  conversation. You don't need to send these handles in the resume request. The
  resume uses the saved conversation checkpoint.

##### Resume an MCP-server elicitation

An MCP-server elicitation (`awaitReason: "mcp_elicitation"`) resumes through
`POST /v1/dispatch/resume`, not through a re-dispatch. Key `toolOutputs` by the
`toolCallId` from the `await` event, the same key a client-tool resume uses, and
send the person's answer as an `mcpElicitation` entry:

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/dispatch/resume \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "executionId": "YOUR_EXECUTION_ID",
    "toolOutputs": {
      "YOUR_TOOL_CALL_ID": {
        "kind": "mcpElicitation",
        "response": {
          "action": "accept",
          "content": { "YOUR_FIELD": "YOUR_VALUE" }
        },
        "pauseCount": 1
      }
    },
    "streamResponse": true
}'
```

Replace `YOUR_API_KEY`, `YOUR_EXECUTION_ID`, and `YOUR_TOOL_CALL_ID` with your API
key and the identifiers from the `await` event. Replace `YOUR_FIELD` and
`YOUR_VALUE` with a property from `elicitation.requestedSchema` and the person's
answer.

The entry differs from a client-tool result in three ways:

* `kind` must be the literal string `mcpElicitation`. The value marks the entry
  as a person's answer. A plain `{ "result": ... }` entry is consumed as the
  tool's output instead, which sends the wrong value to the model.
* `response.action` is `accept`, `decline`, or `cancel`. Send `content` only with
  `accept`, and match its properties to `elicitation.requestedSchema` from the
  `await` event. A `decline` or `cancel` response carries no `content`.
* `pauseCount` echoes `elicitation.pauseCount` from the `await` event. Runtype
  retains the checkpoint's cumulative count; a smaller submitted count can't
  reset the per-call cap.

Form acceptance requires an object that validates against the saved request
schema. Runtype doesn't coerce values or insert defaults, and rejects
unsupported schema constraints rather than ignoring them. Invalid answers do
not advance the paused invocation. For URL acceptance, send `action: "accept"`
without `content`: this acknowledges the out-of-band interaction, and the MCP
server must confirm its outcome when the call is re-issued. It doesn't grant
tool approval or certify successful authentication.

Runtype re-issues the paused MCP call with the answer as input to that call. The
answer doesn't become the tool's result, so the model sees whatever the
re-issued call returns. The server can elicit again on the re-issued call, which
pauses the run once more with a new `await` event. Answer each pause the same
way, keyed by the `toolCallId` of the event you received.

One tool call can pause for input at most three times without making progress.
After that, Runtype declines the elicitation and fails the tool call with a
message that asks the person to complete the requested action out of band and
try again.

##### Resume an A2A elicitation

An external agent elicitation (`awaitReason: "a2a_input_required"` or
`"a2a_auth_required"`) resumes by **re-dispatching the agent with the person's
answer as the message** and `resumeFrom` naming the paused execution. Don't send
`toolOutputs` because no local tool runs:

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/dispatch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": { "agentId": "YOUR_AGENT_ID" },
    "messages": [{ "role": "user", "content": "June 3 to June 10." }],
    "resumeFrom": { "executionId": "YOUR_EXECUTION_ID", "iteration": 0 },
    "streamResponse": true
}'
```

Replace `YOUR_API_KEY`, `YOUR_AGENT_ID`, and `YOUR_EXECUTION_ID` with your API key,
the saved agent ID, and the paused execution ID.

The continuation sends your answer to the external agent on the same
conversation and streams the rest of the run. Checkpoints expire after 7 days,
and a peer that pauses without assigning a `contextId` can't continue the saved
conversation. The `await` event still lets your UI display the question. A
re-dispatch starts a fresh conversation.

### Pause and resume without streaming

You can handle a pause without consuming the SSE stream. Set
`streamResponse: false` when you dispatch through `POST /v1/agents/:id/execute`
or `POST /v1/dispatch` with an `agent` payload. A paused run returns a JSON
envelope with `status: "paused"` and a `pausedReason` object:

**`Non-streaming paused response (approval gate)`**

```json title="Non-streaming paused response (approval gate)"
{
  "success": true,
  "result": "",
  "status": "paused",
  "stopReason": "paused",
  "agentExecutionId": "YOUR_AGENT_EXECUTION_ID",
  "pausedReason": {
    "type": "local_action",
    "executionId": "YOUR_EXECUTION_ID",
    "toolId": "YOUR_TOOL_ID",
    "toolName": "complete_order",
    "parameters": { "orderId": "YOUR_ORDER_ID" },
    "awaitReason": "approval_required",
    "approvalId": "YOUR_APPROVAL_ID"
  }
}
```

Replace the `YOUR_*` values with identifiers from the paused execution.

The API sets `success` to `true` for a resumable run. Branch on
`status === "paused"` or `stopReason === "paused"`, not on `success`.
Protected values in `pausedReason.parameters` also appear as `[REDACTED]`.

Resolve a paused run according to the `pausedReason` fields:

* **Approval gate** (`awaitReason: "approval_required"`, an `approvalId` is
  present): submit the decision to `POST /v1/agents/:id/approve` with the
  `executionId` and `approvalId` from `pausedReason`:

  **`cURL`**

  ```bash title="cURL"
  curl -X POST https://api.runtype.com/v1/agents/YOUR_AGENT_ID/approve \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "executionId": "YOUR_EXECUTION_ID",
      "approvalId": "YOUR_APPROVAL_ID",
      "decision": "approved",
      "streamResponse": false
    }'
  ```

  Replace `YOUR_AGENT_ID`, `YOUR_API_KEY`, `YOUR_EXECUTION_ID`, and
  `YOUR_APPROVAL_ID` with the values for the paused run.

* **Local/client tool call** (no `awaitReason` / `approvalId`): run the tool
  yourself. Then resume with the result through `POST /v1/dispatch/resume`, using
  the `executionId` from `pausedReason` and the tool call:

  **`cURL`**

  ```bash title="cURL"
  curl -X POST https://api.runtype.com/v1/dispatch/resume \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "executionId": "YOUR_EXECUTION_ID",
      "toolOutputs": { "YOUR_TOOL_CALL_ID": { "result": "YOUR_TOOL_RESULT" } },
      "streamResponse": false
    }'
  ```

  Replace `YOUR_API_KEY`, `YOUR_EXECUTION_ID`, `YOUR_TOOL_CALL_ID`, and
  `YOUR_TOOL_RESULT` with values from the paused run and the local tool.

* **A question for the human** (`awaitReason: "a2a_input_required"`,
  `"a2a_auth_required"`, or `"mcp_elicitation"`): `pausedReason.elicitation`
  carries the peer's question. For an external agent, the envelope also carries
  `externalAgent` with the conversation handles at the **top level**, alongside
  `status`, not inside `pausedReason`:

  **`Non-streaming paused response (external agent asked a question)`**

  ```json title="Non-streaming paused response (external agent asked a question)"
  {
    "success": true,
    "status": "paused",
    "stopReason": "paused",
    "pausedReason": {
      "type": "local_action",
      "executionId": "YOUR_EXECUTION_ID",
      "toolId": "YOUR_TASK_ID",
      "toolName": "external_agent",
      "awaitReason": "a2a_input_required",
      "elicitation": {
        "mode": "form",
        "message": "Which travel dates do you want to book?",
        "serverName": "Travel Agent"
      }
    },
    "externalAgent": {
      "contextId": "YOUR_CONTEXT_ID",
      "taskId": "YOUR_TASK_ID"
    }
  }
  ```

  Replace the `YOUR_*` values with identifiers from the paused response.

  Resume an external agent (`a2a_input_required` or `a2a_auth_required`) by
  re-dispatching with the person's answer and `resumeFrom`, as described in
  [Resume an A2A elicitation](#resume-an-a2a-elicitation). Resume an MCP server
  (`mcp_elicitation`) with an `mcpElicitation` entry on
  `POST /v1/dispatch/resume` instead, as described in
  [Resume an MCP-server elicitation](#resume-an-mcp-server-elicitation).

Set `streamResponse: false` on the approval or resume request to receive the
continuation as JSON instead of an SSE stream.

### Resume from a browser (client-token path)

With an API key, complete a paused tool call by posting its result to
`/v1/dispatch/resume`. That route requires a secret API key with `DISPATCH:*`
scope. A **browser** page, including the embedded Persona widget, can't use it.

Browsers authenticate with a **client token** through the `/v1/client/*` routes.
To complete a paused local-tool turn, post the result to `/v1/client/resume`:

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/client/resume \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "YOUR_SESSION_ID",
    "executionId": "YOUR_EXECUTION_ID",
    "toolOutputs": {
      "YOUR_TOOL_CALL_ID": { "result": "YOUR_TOOL_RESULT" }
    },
    "streamResponse": true
}'
```

Replace `YOUR_SESSION_ID`, `YOUR_EXECUTION_ID`, `YOUR_TOOL_CALL_ID`, and
`YOUR_TOOL_RESULT` with values from the client session, paused event, and local
tool result.

Use these fields to resume the browser session:

* `sessionId`: the session from `/v1/client/init`. It authenticates the request
  with an active session, an active client token, and a matching `Origin`.
* `executionId`: the value from the `await` event. It scopes the resume to the
  session's user. A client token can resume only that user's executions.
* `toolOutputs`: an object keyed by the per-call `toolCallId` from the `await`
  event. You can also key a single call by tool name.
* `assistantMessageId`: an optional ID, up to 128 characters, for the assistant
  message your page renders from this resumed leg. Sending an ID you already
  minted lets a later re-send deduplicate by ID instead of text.

A resume doesn't count as a new execution. The turn was counted when it started.

For agent batches and flow conflict recovery, follow
[Partial batches and resume conflicts](#partial-batches-and-resume-conflicts).
The same per-call identity rules apply to this client-token endpoint.

What the resumed leg produces is persisted to the conversation. Runtype assembles
the leg's assistant text from the stream and appends it as an assistant message,
and it resolves the paused tool calls the conversation already holds from the
`toolOutputs` you submit. The completion is stored whether or not you send
`assistantMessageId`; without that field, it is stored under a server-minted ID.

Don't resend the `clientTools` definitions on resume unless the page tool set
changes. Runtype carries the definitions from the originating `/v1/client/chat`
dispatch forward unchanged.

#### Client tool results in later turns

A client tool call that the browser answers through `/v1/client/resume` stays in
the conversation. On every later `/v1/client/chat` turn, Runtype replays that call
and its result to the model, so the agent can answer a follow-up question from the
earlier result without calling the tool again.

* Only calls the browser answered are replayed. A call that paused and was never
  answered is dropped, and results from server-executed tools aren't replayed.
* A call replays only while the page still offers that tool in `clientTools`. If
  a later page no longer registers the tool, earlier calls to it are left out.
* WebMCP results, and results from tools with `untrustedContentHint`, keep their
  untrusted-content markers on replay. Each replayed result is identical on every
  turn, so the conversation prefix stays in the prompt cache.
* Runtype doesn't refresh a replayed result. If the underlying data can change,
  the agent must call the tool again to get current data.

#### Refresh page tools mid-run

If a paused page tool navigates, the destination page registers its own WebMCP
tools on `document.modelContext`. The dispatch-time snapshot doesn't include
those tools. To make them callable on the next model turn, send the page's current
registry with the resume. Use the same send-once protocol as `/v1/client/chat`:

* **Send the full registry**: Include `clientTools` with the page's complete
  registry and include `clientToolsFingerprint` for the set.
* **Send only the fingerprint**: If the registry is unchanged since the last full
  send, send only `clientToolsFingerprint`. If it doesn't match the stored
  registry, the request returns `409 { "error": "client_tools_resend_required" }`.
  Retry with the full `clientTools` array and the fingerprint.

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/client/resume \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "YOUR_SESSION_ID",
    "executionId": "YOUR_EXECUTION_ID",
    "toolOutputs": {
      "YOUR_TOOL_CALL_ID": { "url": "https://store.example.com/checkout" }
    },
    "clientTools": [
      {
        "name": "submit_checkout",
        "description": "Submit the checkout form on this page",
        "parametersSchema": { "type": "object", "properties": {} },
        "origin": "webmcp"
      }
    ],
    "clientToolsFingerprint": "YOUR_CLIENT_TOOLS_FINGERPRINT",
    "streamResponse": true
}'
```

Replace `YOUR_SESSION_ID`, `YOUR_EXECUTION_ID`, `YOUR_TOOL_CALL_ID`, and
`YOUR_CLIENT_TOOLS_FINGERPRINT` with the values for the active client session,
paused event, tool call, and current registry.

The refreshed set **replaces** the run's persisted tool set for the rest of the
run. It doesn't merge with the saved set, so a dispatch-time tool that you omit
is no longer callable. Runtype revalidates and regates the set against the
surface's `behavior.webmcp` policy for the request `Origin`, as it does during
dispatch.

> **Note**
>
> The Persona chat widget sends the `/v1/client/resume` request in client-token mode. You implement
> only the local tools. This endpoint is documented for custom client-token integrations.

### Detached streams

A pause isn't the only reason an agent stream ends early. The socket an agent
turn streams over carries a **watch lease**, not the turn's lifetime. When the
lease expires, Runtype emits an `await` event with `awaitReason: "detached"` and
closes the stream. The run is still going server-side. Treat it as "still
working", never as an error and never as a finished turn:

**`Detached frame`**

```json title="Detached frame"
{
  "type": "await",
  "awaitReason": "detached",
  "executionId": "YOUR_EXECUTION_ID"
}
```

To keep receiving events, reopen the run through its events route with the last
SSE `id:` you saw as the cursor. The server replays strictly after that cursor,
so you get no duplicates:

**`cURL`**

```bash title="cURL"
curl -N https://api.runtype.com/v1/agents/YOUR_AGENT_ID/executions/YOUR_EXECUTION_ID/events?after=YOUR_LAST_EVENT_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Replace `YOUR_AGENT_ID`, `YOUR_EXECUTION_ID`, and `YOUR_LAST_EVENT_ID` with the
agent, the `executionId` from the detached frame, and the last SSE `id:` value
your client received.

The TypeScript SDK does this for you. `client.agents.executeStream()` follows a
detached turn automatically and keeps yielding frames until a real terminal
arrives, so `executeWithCallbacks()` inherits the behavior. The local-tool loop
gets it on both legs: its first stream comes from `executeStream()`, and each
resume after a local tool is followed the same way, so a lease that expires
mid-continuation never ends the session early. The detach frame is still
forwarded, so a UI that renders `await` states keeps rendering it:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { RuntypeClient, withDetachedReconnect } from '@runtypelabs/sdk'

const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })

// Reconnect is on by default.
const stream = await client.agents.executeStream('YOUR_AGENT_ID', {
  messages: [{ role: 'user', content: 'Research this and write it up' }],
})

// Opt out to own the reconnect yourself, then drive it with withDetachedReconnect.
const raw = await client.agents.executeStream(
  'YOUR_AGENT_ID',
  { messages: [{ role: 'user', content: 'Research this and write it up' }] },
  { autoReconnect: false }
)
```

> **Note**
>
> This applies to agent streams from `client.agents.executeStream()` and `POST
>   /v1/agents/:id/execute`. Flow dispatch streams don't detach. Raw HTTP callers and the Python SDK
> reattach manually with the `?after=` request in this section.

For a run that you started through `POST /v1/dispatch` with a saved agent, or any
run whose agent ID you don't have, reconnect by execution ID with
`GET /v1/executions/{executionId}/events?after=`. It replays and tails events the
same way. An execution of an inline agent definition returns HTTP `404` on this
route.

### Control stream lifetime

An agent turn keeps running after its stream closes. The exception is a short
turn that carries no `conversationId`, no tools, skills, or sandbox, no client
tools, no approval gate, and no `Prefer: respond-async` header. Runtype runs that turn in the request, so it answers
sooner but can't detach or reconnect with `?after=`. To control the stream, set
`durability` in the `POST /v1/agents/{id}/execute` request body:

**`Request body`**

```json title="Request body"
{
  "messages": [{ "role": "user", "content": "Research this and write it up" }],
  "durability": {
    "watchLeaseMs": 1800000
  }
}
```

`watchLeaseMs` sets how long the stream stays attached before the turn detaches,
from 10 seconds to 30 minutes. The default is 10 minutes. A longer lease keeps
one stream open for longer and doesn't change how long the run can take.

Set `durability.forced` to `"in_process"` to run any turn in the request, with
the same trade-off. Setting `forced` to `"durable"` doesn't guarantee a turn that
can detach. A request value outranks the agent's saved `durability.forced`
setting.

### Tool replay after an interrupted turn

An agent turn can be interrupted while a tool call is in progress. When the turn
resumes, Runtype knows that the call might have started but not whether it
finished.

**Runtype doesn't silently re-run an interrupted call.** If the model calls that
tool again on the resumed turn, it receives an error that says the effects are
unknown and to verify before retrying, and Runtype doesn't run the tool. The
model decides what to do next: check whether the write landed, then retry or
move on.

Re-running a read-only tool has no side effects. To allow Runtype to run an
interrupted call again, set `idempotent: true` on a runtime tool in a
`POST /v1/dispatch` request or a saved agent. Every tool type accepts this field.
The `tools.runtimeTools` override on `POST /v1/agents/{id}/execute` drops it:

**`Read-shaped tool, safe to re-dispatch`**

```typescript title="Read-shaped tool, safe to re-dispatch"
{
  name: 'fetch_user',
  description: 'Fetch user details by ID',
  toolType: 'external',
  idempotent: true,
  parametersSchema: {
    type: 'object',
    properties: { userId: { type: 'string' } },
    required: ['userId']
  },
  config: {
    url: 'https://api.example.com/users/{{userId}}',
    method: 'GET'
  }
}
```

Leave it unset for anything that writes, sends, charges, or creates. Built-in
catalog tools carry the flag already, derived from what the tool does: reads and
searches are re-dispatched, and writes, sends and metered job-creating calls are
not. MCP tools follow the server's own `idempotentHint` / `readOnlyHint`
annotations, and a server that sends neither is treated as not safe to re-run.

### Require a committed intent before a tool runs

Before an Agent calls a server-side tool, Runtype records an intent: a durable note that the call is about to start. By default the intent is best-effort. If Runtype can't record it, the tool still runs.

Set `tools.intent` to `mandatory` on an Agent so that a tool that isn't `idempotent` runs only after its intent is recorded:

**`Agent that requires a committed intent`**

```json title="Agent that requires a committed intent"
{
  "tools": {
    "intent": "mandatory",
    "toolIds": ["builtin:runtype_record_upsert"]
  }
}
```

`tools.intent` accepts `mandatory` or `best-effort`. The default is `best-effort`.

With `mandatory`, if Runtype can't record the intent, it doesn't call the tool. The tool call fails, and the model receives an error that starts with `TOOL_INTENT_NOT_COMMITTED`:

```text
TOOL_INTENT_NOT_COMMITTED: the tool call was not started because its intent could not be recorded durably. It had no effect; it is safe to call it again.
```

The call had no effect, so the model can call the tool again.

The setting has these limits:

* It applies to Agents only. A flow prompt step doesn't record tool intents, so the flow validator warns with `TOOL_INTENT_POLICY_AGENT_ONLY` when a prompt step sets it.
* It applies only where the run keeps a tool journal, which durable Agent runs do. A run without a tool journal calls the tool as it would without the setting.
* Tools marked `idempotent` keep the best-effort intent, because calling them again is safe.
* A dispatch request can set `tools.intent` to `mandatory` for a saved Agent. A request can't lower a saved Agent's `mandatory` to `best-effort`.

Each intent also records whether the tool was `idempotent` when the call started. When a run resumes after an interruption, Runtype calls an interrupted tool again only if it was idempotent then and is still idempotent now. Marking a tool `idempotent` later doesn't make an interrupted call run again. Removing `idempotent` stops an interrupted call from running again.

## Tool approval grants

When you enable [tool approval](/user-guide/agents/creating-and-configuring-agents#safety), a gated tool call pauses the run for a person to approve or deny. If the approval prompt offers **Always allow**, the person can select it instead of **Allow once**. The selection creates a grant, so future dispatches skip the approval prompt for that tool.

A grant stores an **Always allow** decision for the owner, agent, and approving end user. An account-level grant applies to every end user and appears as **All users** in the dashboard.

For an agent with a [Tenancy Strategy](/developer-guides/guides/end-user-identity), the end-user key is the durable projected end-user ID (`eu_YOUR_END_USER_ID`). Without a Tenancy Strategy, the key is the raw `endUser.id` that you pass at dispatch. The lookup matches both forms for compatibility. If you remove an agent's Tenancy Strategy, grants recorded with the strategy no longer match, so the person approves the tool again. Scoped memory and records use the same re-scoping behavior.

> **Warning**
>
> A grant skips only the approval prompt, not authorization. Tool resolution, ownership scoping, and
> secret access are unchanged. Runtype doesn't remember skill-load approvals, so loading a skill
> prompts for approval.

### Create a grant

Select **Always allow** in an approval prompt to create a grant.

With the raw API, pass `remember: true` when you resolve the approval. The authenticated dashboard
execution UI sets this field when the person selects **Always allow**. The public Client Chat path
doesn't host human approval prompts.

**`cURL`**

```bash title="cURL"
curl -X POST https://api.runtype.com/v1/agents/YOUR_AGENT_ID/approve \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "executionId": "YOUR_EXECUTION_ID",
    "approvalId": "YOUR_APPROVAL_ID",
    "decision": "approved",
    "remember": true
}'
```

Replace `YOUR_AGENT_ID`, `YOUR_API_KEY`, `YOUR_EXECUTION_ID`, and
`YOUR_APPROVAL_ID` with the values for the paused run.

To offer the choice, enable `tools.approval.choices.alwaysAllow` in the saved
agent configuration or set it per dispatch. See [Dispatch-time approval overrides](#dispatch-time-approval-overrides).

### List grants

List active grants for the authenticated owner. Pass `agentId` to filter the results
to one agent.

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { RuntypeClient } from '@runtypelabs/sdk'

const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })

const grants = await client.toolApprovalGrants.list('YOUR_AGENT_ID')
```

Replace `YOUR_AGENT_ID` with the ID of the agent to filter. Set
`RUNTYPE_API_KEY` to your Runtype API key.

The following request lists grants for one agent:

**`cURL`**

```bash title="cURL"
curl https://api.runtype.com/v1/tool-approval-grants?agentId=YOUR_AGENT_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Replace `YOUR_AGENT_ID` with the agent ID and `YOUR_API_KEY` with your Runtype API
key.

The response is `{ "data": [] }`. Each grant has these fields:

| Field        | Description                                                                                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`         | Grant ID, used to revoke                                                                                                                                          |
| `agentId`    | The agent the grant applies to                                                                                                                                    |
| `endUserRef` | The approving end user, represented by `eu_YOUR_END_USER_ID` for agents with a Tenancy Strategy or by the raw `endUser.id`, or `null` for an account-level grant. |
| `toolType`   | Tool namespace (`builtin`, `mcp`, `custom`)                                                                                                                       |
| `toolName`   | The tool the grant remembers                                                                                                                                      |
| `decision`   | The remembered decision (`allow`)                                                                                                                                 |
| `createdAt`  | When the grant was recorded                                                                                                                                       |
| `expiresAt`  | Expiry time, or `null` if the grant doesn't expire                                                                                                                |

### Revoke a grant

Revoke a grant to prompt for approval again on future dispatches. The API returns
`{ "revoked": true }`.

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
await client.toolApprovalGrants.revoke('YOUR_GRANT_ID')
```

Replace `YOUR_GRANT_ID` with the ID of the grant to revoke.

**`cURL`**

```bash title="cURL"
curl -X DELETE https://api.runtype.com/v1/tool-approval-grants/YOUR_GRANT_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Replace `YOUR_GRANT_ID` with the ID of the grant and `YOUR_API_KEY` with your
Runtype API key.

You can also view and revoke grants from the dashboard in the agent editor **Safety** section, under **Remembered approvals**.

## Dispatch-time approval overrides

When you dispatch an agent through `POST /v1/dispatch`, `agent.tools.approval`
overrides the saved approval configuration for that run. It accepts the following
fields:

* `require`: tools that need approval. Set it to `true` for all tools, or to an
  array of tool names and patterns such as `["send_email", "mcp:*"]`.
* `requestReason`: whether the prompt asks the model for a reason for the call.
* `choices`: choices that the prompt offers. Set `choices.alwaysAllow: true` to
  show **Always allow** for this dispatch.

Configure an approval override as follows:

**`Approval override`**

```json title="Approval override"
{
  "agent": {
    "agentId": "YOUR_AGENT_ID",
    "tools": {
      "approval": {
        "require": ["send_email"],
        "choices": { "alwaysAllow": true }
      }
    }
  },
  "messages": [{ "role": "user", "content": "Email the summary to the team" }]
}
```

Replace `YOUR_AGENT_ID` with the ID of the saved agent.

## Hide parameters from the model

Set `hiddenParameterNames` on a runtime tool to fill parameters from execution variables instead of from the model. Runtype removes each listed parameter from the model-facing schema and sets it to the execution variable with the same name. The resolved value replaces any value the model sent.

The following tool hides `_tenant` and sends its ID in a header:

**`Hidden parameter`**

```typescript title="Hidden parameter"
{
  name: 'list_orders',
  description: 'List recent orders for the current account',
  toolType: 'external',
  parametersSchema: {
    type: 'object',
    properties: { status: { type: 'string' } },
    required: ['status']
  },
  hiddenParameterNames: ['_tenant'],
  config: {
    url: 'https://api.example.com/v1/orders?status={{status}}',
    method: 'GET',
    headers: {
      Authorization: 'Bearer {{secret:ORDERS_API_KEY}}',
      'X-Tenant-Id': '{{_tenant.id}}'
    }
  }
}
```

Replace `ORDERS_API_KEY` with the name of the managed secret that authenticates Runtype to your API. Runtype merges hidden values into the tool arguments before it fills the URL, headers, and body.

The name decides how Runtype resolves a hidden parameter:

* **No leading `_`.** Runtype reads the execution variable, then the same field in `_record.metadata`, then the same field inside another object-valued variable without a leading `_`. If nothing matches, the parameter stays visible and the model supplies it.
* **Leading `_`.** Runtype reads only the top-level execution variable. If it isn't set, Runtype doesn't call the tool and returns an error to the model as the tool result.
* **Leading `_internal`.** Runtype never resolves these names and rejects them when you save an agent or send them on an agent execute request.

Use `_tenant` and `_endUser` for identity. Runtype sets them from the execution's end-user identity, as described in [End-user identity and credentials](/developer-guides/guides/end-user-identity). Other names with a leading `_` don't get that protection, so also authenticate Runtype to your backend with a `{{secret:NAME}}` header.

To let your API verify the identity instead of trusting a plain ID, reference `{{_identity.token}}` in the URL, a header, or the body. Runtype signs a short-lived assertion for each call. For the claims and how to verify them, see [Send a verifiable identity assertion to your API](/developer-guides/guides/end-user-identity#send-a-verifiable-identity-assertion-to-your-api).

To grade such an agent in an eval suite, give each case an `endUser` (or set the suite's `identityDefaults`). For details, see [Grade an agent that runs as an end user](/user-guide/evals/manage-evals-as-code#grade-an-agent-that-runs-as-an-end-user).

In `tool_start` and `tool_input_complete` events, Runtype replaces resolved hidden values with `[REDACTED]` and lists the names in `hiddenParameterNames`. The field is accepted on runtime tools in `/v1/dispatch`, on saved agents, and on the `tools.runtimeTools` override of an agent execute request. For the dashboard view of this feature, see [Runtime tools](/user-guide/tools/runtime-tools#hide-parameters-from-the-model).

## Ask for input before dispatch

Saved and inline `external` and `custom` tools can declare
`config.elicit: { message, requestedSchema, merge: 'parameters' }`. The final
`parametersSchema` and `requestedSchema` must be closed objects. Requested fields
belong to the person: their primitive types, constraints, and requiredness must
match the final schema. Supported field types are string, number, integer, and
boolean, with titles, descriptions, enums, string length bounds, and numeric
bounds. Runtype rejects references, unsupported keywords, cross-field
constraints, reserved names, and overlap with hidden parameters.

The model sees only the remaining parameters. Runtype validates those arguments,
collects the person's fields, validates the merged object, then requests any
ordinary approval for the final arguments. An approval resume reuses the
validated input. If you change the tool definition or the model arguments, the
invocation must restart. A cancel or decline skips the tool call and returns a
cancellation result for the original call.

Tool elicitation works on AG-UI surfaces for top-level agent calls authenticated
with a credential other than a client token, such as a Surface key. It doesn't work in subagent calls
or other nested executions, in Claude Managed Agents, or in Persona. Server tool
arguments stay private unless you list the tool in the AG-UI surface's
`behavior.discloseToolArguments.tools`, for example `['schedule_meeting']`. That
setting exposes the model's arguments in interrupt metadata and tool-call events.
Runtype still removes hidden values before it stores or discloses them.

The pause emits an `await` event with `awaitReason: 'tool_elicitation'` and a
form `elicitation` payload. Resume follows the normal authorization and routing
rules for the surface. MCP elicitation keeps its own `awaitReason` and resume
behavior.

## Limits

The following limits apply to runtime and client tools:

| Limit                                 | Value                                 |
| ------------------------------------- | ------------------------------------- |
| Serialized `runtimeTools` per request | 1 MB across all steps                 |
| Runtime custom tool timeout           | 30 seconds                            |
| MCP servers per step                  | 5                                     |
| Client tools payload                  | 256 KB                                |
| Client tool name                      | 64 characters, starting with a letter |
| Client tool description               | 2 KB                                  |
| Each string in a client tool's schema | 2 KB                                  |
| Tools for a local in-browser model    | 50                                    |

Runtype doesn't limit the number of runtime tools or client tools in a request.
It limits their size instead. Runtype checks the `runtimeTools` size when you
dispatch, run an eval, validate a Flow, and create, update, or execute an Agent.
AG-UI frontend tools follow the client tool limits. A frontend tool set that is
too large, or that contains an invalid tool, returns a `400` error.

A local in-browser model accepts at most 50 tools. This limit comes from the
local inference protocol.

A model provider can still reject a long tool list. For example, OpenAI accepts
at most 128 tools. Runtype reports that rejection with the error code
`TOOL_COUNT_REFUSED_BY_PROVIDER`. To run a large tool set, turn on
[tool search](/user-guide/agents/agent-tools#tool-search) for a multi-turn Agent.
Tool search sends the model a small set of tools and a `tool_search` tool, and
loads other runtime tools as the model finds them.

For MCP tool call timeouts, see [MCP servers](/developer-guides/integrations/mcp-servers#limits).

## Troubleshooting

Runtype validates runtime tools before the run starts. An invalid tool returns an
HTTP error with an `error` message, and nothing runs. The following table lists
common causes:

| Error                                           | Status | Fix                                                                                                                                  |
| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `Runtime tool missing required fields`          | 400    | Set `name`, `description`, `toolType`, and `parametersSchema`. Set `config` for every type except `local`.                           |
| `parametersSchema must be a JSON Schema object` | 400    | Set `type: "object"` at the top level of `parametersSchema`.                                                                         |
| `collides with the reserved tool name`          | 400    | Rename the tool. Names that normalize to `runtype_set_state` are reserved, and the `runtype_` prefix is reserved for platform tools. |
| `cannot reference {{secret:KEY}}`               | 400    | Move the secret reference to an `external` tool.                                                                                     |
| `does not have SECRETS:READ permission`         | 403    | Grant `SECRETS:READ` or `SECRETS:*` to the API key.                                                                                  |
| `missing URL` or `has invalid URL format`       | 400    | Set `config.url` to a URL that is valid after placeholder substitution.                                                              |
| `timeout exceeds 30s limit`                     | 400    | Set a custom tool `timeout` of `30000` or less.                                                                                      |

Before Runtype checks for a reserved name, it lowercases the tool name and
replaces each non-alphanumeric character with `_`. A name such as
`Runtype-Set-State` matches the reserved `runtype_set_state` name.

## Best practices

Apply these practices when you configure runtime tools:

#### Save frequently used tools

If you use the same runtime tool repeatedly, save it to your account through the API or dashboard to keep the configuration in one place.

#### Test before production

Use runtime tools to test configurations, then save working tools for production use.

#### Validate schemas

Check that `parametersSchema` is valid JSON Schema with `type: "object"` at the top level. Runtype rejects an invalid tool before the run starts.

## Next steps

Continue with one of these resources:

#### [Working with tools](/developer-guides/guides/working-with-tools)

Complete tools overview

#### [MCP servers](/developer-guides/integrations/mcp-servers)

Runtime MCP server configuration

#### [End-user identity and credentials](/developer-guides/guides/end-user-identity)

Pass end-user identity to tools

#### [Managing secrets](/user-guide/settings/managing-secrets)

Store credentials for tool references

#### [Agent approvals](/user-guide/agents/creating-and-configuring-agents#safety)

Require approval for tool calls

#### [Embedding the chat widget](/user-guide/products-surfaces/embedding-the-chat-widget-script-tag)

Add Persona and WebMCP page tools

#### [Tools API](/api-reference/runtype-api/tools/list-tools)

Tools endpoint reference

#### [Building flows](/user-guide/flows/creating-and-editing-flows)

Create flows with tools

#### [Tool approvals](/user-guide/tools/tool-approvals)

Configure approval gates and grants

#### [Run flows and agents with dispatch](/developer-guides/guides/dispatch)

Send the dispatch requests that carry runtime tools