Runtime tools

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 caseDescription
TestingTry tool configurations before saving.
Dynamic configsVary tool URLs or parameters per request.
User-providedAccept tool definitions from end users.
One-off tasksUse a tool for one execution.

Basic example

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

TypeScript SDK
1import { FlowBuilder, RuntypeClient } from '@runtypelabs/sdk'
2
3const client = new RuntypeClient({
4 apiKey: process.env.RUNTYPE_API_KEY,
5})
6
7const result = await new FlowBuilder()
8 .createFlow({ name: 'Weather Agent' })
9 .prompt({
10 name: 'Agent',
11 model: 'gpt-5.4',
12 userPrompt: 'What is the weather in Tokyo?',
13 tools: {
14 runtimeTools: [
15 {
16 name: 'get_weather',
17 description: 'Get current weather for a city',
18 toolType: 'external',
19 parametersSchema: {
20 type: 'object',
21 properties: {
22 city: { type: 'string', description: 'City name' },
23 },
24 required: ['city'],
25 },
26 config: {
27 url: 'https://api.example.com/v1/current?city={{city}}',
28 method: 'GET',
29 headers: {
30 Authorization: 'Bearer {{secret:WEATHER_API_KEY}}',
31 },
32 },
33 },
34 ],
35 },
36 })
37 .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
1import os
2
3from runtype import RuntypeClient
4
5client = RuntypeClient(api_key=os.environ["RUNTYPE_API_KEY"])
6
7runtime_tool = {
8 "name": "get_weather",
9 "description": "Get current weather for a city",
10 "toolType": "external",
11 "parametersSchema": {
12 "type": "object",
13 "properties": {
14 "city": {"type": "string", "description": "City name"}
15 },
16 "required": ["city"]
17 },
18 "config": {
19 "url": "https://api.example.com/v1/current?city={{city}}",
20 "method": "GET",
21 "headers": {
22 "authorization": "Bearer {{secret:WEATHER_API_KEY}}"
23 }
24 }
25}
26
27for event in client.dispatch({
28 "flow": {
29 "steps": [{
30 "type": "prompt",
31 "config": {
32 "model": "gpt-5.4",
33 "userPrompt": "What is the weather in Tokyo?",
34 "tools": {
35 "runtimeTools": [runtime_tool]
36 }
37 }
38 }]
39 }
40}):
41 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
$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": "gpt-5.4",
> "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.

Tool types

Runtime tools support the following types:

External tools

Call an HTTP API with an external tool:

External tool
1{
2 name: 'fetch_user',
3 description: 'Fetch user details by ID',
4 toolType: 'external',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 userId: { type: 'string' }
9 },
10 required: ['userId']
11 },
12 config: {
13 url: 'https://api.example.com/users/{{userId}}',
14 method: 'GET',
15 headers: {
16 'Authorization': 'Bearer {{secret:API_TOKEN}}'
17 }
18 }
19}

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

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
1{
2 name: 'upload_image',
3 description: 'Upload an image to the asset library',
4 toolType: 'external',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 filename: { type: 'string' },
9 image: {
10 type: 'string',
11 contentEncoding: 'base64',
12 description: 'Image bytes, or a runtype-asset:// handle from another tool'
13 }
14 },
15 required: ['filename', 'image']
16 },
17 config: {
18 url: 'https://api.example.com/images',
19 method: 'POST',
20 headers: {
21 'Authorization': 'Bearer {{secret:API_TOKEN}}'
22 },
23 body: {
24 filename: '{{filename}}',
25 data: '{{image}}'
26 }
27 }
28}

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
1{
2 name: 'calculate_tax',
3 description: 'Calculate sales tax',
4 toolType: 'custom',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 amount: { type: 'number' },
9 rate: { type: 'number' }
10 },
11 required: ['amount', 'rate']
12 },
13 config: {
14 code: `
15 const tax = amount * (rate / 100);
16 return {
17 subtotal: amount,
18 tax: tax.toFixed(2),
19 total: (amount + tax).toFixed(2)
20 };
21 `,
22 timeout: 5000
23 }
24}

Flow tools

Run another Runtype flow as a tool:

Flow tool
1{
2 name: 'analyze_sentiment',
3 description: 'Run sentiment analysis on text',
4 toolType: 'flow',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 text: { type: 'string' }
9 },
10 required: ['text']
11 },
12 config: {
13 flowId: 'YOUR_FLOW_ID',
14 parameterMapping: { 'input_text': 'text' },
15 outputMapping: 'sentiment'
16 }
17}

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

Subagent tools

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

Subagent tool
1{
2 name: 'research_topic',
3 description: 'Research a topic and return a summary',
4 toolType: 'subagent',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 task: { type: 'string', description: 'What to research' }
9 },
10 required: ['task']
11 },
12 config: {
13 // Set exactly one of `agentId` or `agent`.
14 agentId: 'YOUR_AGENT_ID',
15 allowedTools: ['builtin:exa', 'mcp:linear:*'],
16 maxTurns: 5,
17 timeoutMs: 300000,
18 outputFormat: 'text'
19 }
20}

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.

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
1tools: {
2 toolIds: ['builtin:exa', 'mcp:linear:create_issue'],
3 subagentConfig: {
4 toolPool: ['builtin:exa', 'mcp:linear:*'], // subset of parent's tools
5 maxSpawnsPerRun: 5, // default 5
6 maxTurnsLimit: 10, // hard cap, default 10
7 allowNesting: false // default false
8 }
9}

The parent stream shows one tool bubble for each subagent call. The child’s internal turns and tool calls do not 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 does not require a config object:

Local tool
1{
2 name: 'ask_user',
3 description: 'Ask the end user a question',
4 toolType: 'local',
5 parametersSchema: {
6 type: 'object',
7 properties: {
8 question: { type: 'string' }
9 },
10 required: ['question']
11 }
12}

Pass secrets

Store sensitive values in Settings > Secrets. Then use a managed reference in supported HTTP tool fields:

Managed secret reference
1headers: {
2 Authorization: 'Bearer {{secret:API_TOKEN}}'
3}

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

The top-level dispatch secrets map is retired on BOTH dispatch targets: a non-empty map is refused with 400 RUNTIME_AGENT_TRANSIENT_SECRETS_UNSUPPORTED on an agent dispatch and 400 RUNTIME_FLOW_TRANSIENT_SECRETS_UNSUPPORTED on a flow dispatch. An empty map is accepted and does nothing. Use {{secret:NAME}} for credentials in hosted FLOW execution. Managed values are encrypted at rest and are not logged or returned in API responses.

Variable substitution

Use the following template variables in tool configurations:

VariableSource
{{paramName}}Tool call parameter
{{secret:NAME}}Managed secret store
{{_record.field}}Current record data
{{_flow.id}}Flow metadata

For example, configure a request with record, flow, and user values:

Template variables
1config: {
2 url: 'https://api.example.com/{{_record.type}}/{{id}}',
3 headers: {
4 'Authorization': 'Bearer {{secret:API_TOKEN}}',
5 'X-User-Id': '{{_user.id}}'
6 }
7}

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

Combine with saved tools

Combine runtime tools with saved tools in one tools configuration:

Combined tools
1tools: {
2 // Saved tools by ID
3 toolIds: [
4 'YOUR_TOOL_ID',
5 'mcp:notion:create_page'
6 ],
7 // Runtime tools
8 runtimeTools: [
9 {
10 name: 'dynamic_tool',
11 description: 'Run a dynamic tool',
12 toolType: 'external',
13 parametersSchema: { type: 'object', properties: {} },
14 config: { url: 'https://api.example.com', method: 'GET' }
15 }
16 ],
17 maxToolCalls: 10
18}

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
1import { createExternalTool } from '@runtypelabs/sdk'
2
3const weatherTool = createExternalTool({
4 name: 'get_weather',
5 description: 'Get weather for a city',
6 parametersSchema: {
7 type: 'object',
8 properties: {
9 city: { type: 'string' },
10 },
11 },
12 url: 'https://api.example.com/current?city={{city}}',
13 method: 'GET',
14 headers: {
15 Authorization: 'Bearer {{secret:WEATHER_API_KEY}}',
16 },
17})
18
19// Use in flow
20tools: {
21 runtimeTools: [weatherTool]
22}

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.

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:

1{ "name": "create_flow", "catalog": "runtype-mcp" }

Runtype resolves description and parametersSchema from its own copy of the catalog, so you do not 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:

1{
2 "clientTools": [
3 { "name": "create_flow", "catalog": "runtype-mcp" },
4 {
5 "name": "highlight_row",
6 "description": "Highlight a row in the on-page table.",
7 "parametersSchema": { "type": "object", "properties": { "id": { "type": "string" } } },
8 "origin": "webmcp"
9 }
10 ]
11}

The size limits below apply to manifests you send. A reference carries no description or schema of its own, so it is not 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. Tool descriptions are limited to 2 KB.

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. Do not expose a Runtype API key in a browser page that you do not control.

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

TypeScript SDK
1import { RuntypeClient } from '@runtypelabs/sdk'
2
3const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })
4
5// scope: 'turn' snapshots your local tools into the dispatch envelope's
6// clientTools[] and drives the dispatch + resume loop for you, executing
7// each tool call locally.
8await client.runWithLocalTools(
9 {
10 flow: { id: 'YOUR_FLOW_ID' },
11 messages: [{ role: 'user', content: 'What time is it?' }],
12 },
13 {
14 get_time: {
15 description: 'Return the current ISO time',
16 parametersSchema: { type: 'object', properties: {} },
17 execute: async () => new Date().toISOString(),
18 },
19 },
20 { scope: 'turn' }
21)

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
$curl -X POST https://api.runtype.com/v1/dispatch \
> -H "Authorization: Bearer YOUR_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "agent": { "id": "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 does not gate SDK-origin tools.

Configure the policy as follows:

Client tool policy
1{
2 "agent": { "id": "YOUR_AGENT_ID" },
3 "clientTools": [
4 {
5 "name": "search_products",
6 "description": "Search products",
7 "parametersSchema": { "type": "object" },
8 "origin": "webmcp"
9 },
10 {
11 "name": "delete_account",
12 "description": "Delete the account",
13 "parametersSchema": { "type": "object" },
14 "origin": "webmcp"
15 }
16 ],
17 "clientToolsPolicy": { "allowlist": ["search_*"] }
18}

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 does not 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.

Client Chat can pause for a browser-side client tool and resume that same execution through POST /v1/client/resume. This is not 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.

Enable WebMCP in the Persona configuration as follows:

Persona widget config
1initAgentWidget({
2 target: '#chat',
3 config: {
4 clientToken: 'YOUR_CLIENT_TOKEN',
5 webmcp: { enabled: true },
6 },
7})

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
1{
2 "type": "chat",
3 "behavior": {
4 "webmcp": {
5 "enabled": true,
6 "allowlist": [
7 {
8 "origin": "https://store.example.com",
9 "tools": ["search_*", "get_cart"]
10 },
11 { "origin": "*", "tools": ["read_page"] }
12 ]
13 }
14 }
15}

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 features.suggestReplies.expose: true.

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
1{
2 "type": "await",
3 "executionId": "YOUR_EXECUTION_ID",
4 "seq": 12,
5 "toolId": "YOUR_TOOL_ID",
6 "toolCallId": "YOUR_TOOL_CALL_ID",
7 "toolName": "search_products",
8 "parameters": { "query": "waterproof trail shoe" },
9 "origin": "webmcp",
10 "pageOrigin": "https://store.example.com",
11 "awaitedAt": "2026-06-16T00:00:00Z"
12}

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)
1{
2 "type": "approval_start",
3 "executionId": "YOUR_EXECUTION_ID",
4 "seq": 12,
5 "approvalId": "YOUR_APPROVAL_ID",
6 "toolCallId": "YOUR_TOOL_CALL_ID",
7 "toolName": "send_email",
8 "toolType": "custom",
9 "parameters": { "to": "dana@example.com" },
10 "timeout": 300000,
11 "startedAt": "2026-06-17T00:00:00Z",
12 "iteration": 1,
13 "subagent": { "toolName": "delegate_email", "agentName": "Email Subagent" }
14}

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 does not 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
$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 use the tool name for a single call for compatibility with legacy clients.
  • The resumed stream continues with events such as text_delta and ends with execution_complete or execution_error, or pauses with another await. The latter can identify an unanswered call from the current batch or a new call.

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; do not resend already accepted answers or treat unknown call IDs as completed work.

Serialize resume submissions for a flow execution. Concurrent flow resumes now receive HTTP 409 rather than both executing. 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; do not blindly retry while it is running.
  • RESUME_STATE_CHANGED: the submitted pause was replaced or already consumed. Do not 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 cannot restart it.

These checks also apply when streamResponse is true: a rejected admission is an HTTP error, not a successful resumed stream. Resume is not 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 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 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)
1{
2 "type": "await",
3 "executionId": "YOUR_EXECUTION_ID",
4 "seq": 8,
5 "toolId": "YOUR_TASK_ID",
6 "toolName": "external_agent",
7 "awaitReason": "a2a_input_required",
8 "elicitation": {
9 "mode": "form",
10 "message": "Which travel dates do you want to book?",
11 "serverName": "Travel Agent"
12 },
13 "externalAgent": {
14 "contextId": "YOUR_CONTEXT_ID",
15 "taskId": "YOUR_TASK_ID"
16 },
17 "awaitedAt": "2026-08-11T00:00:00Z"
18}

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 do not 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 do not 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
$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 cannot reset the per-call cap.

Form acceptance requires an object that validates against the saved request schema. Runtype does not 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 does not grant tool approval or certify successful authentication.

Runtype re-issues the paused MCP call with the answer as input to that call. The answer does not 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. Do not send toolOutputs because no local tool runs:

cURL
$curl -X POST https://api.runtype.com/v1/dispatch \
> -H "Authorization: Bearer YOUR_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "agent": { "id": "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 cannot 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)
1{
2 "success": true,
3 "result": "",
4 "status": "paused",
5 "stopReason": "paused",
6 "agentExecutionId": "YOUR_AGENT_EXECUTION_ID",
7 "pausedReason": {
8 "type": "local_action",
9 "executionId": "YOUR_EXECUTION_ID",
10 "toolId": "YOUR_TOOL_ID",
11 "toolName": "complete_order",
12 "parameters": { "orderId": "YOUR_ORDER_ID" },
13 "awaitReason": "approval_required",
14 "approvalId": "YOUR_APPROVAL_ID"
15 }
16}

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
    $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
    $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)
    1{
    2 "success": true,
    3 "status": "paused",
    4 "stopReason": "paused",
    5 "pausedReason": {
    6 "type": "local_action",
    7 "executionId": "YOUR_EXECUTION_ID",
    8 "toolId": "YOUR_TASK_ID",
    9 "toolName": "external_agent",
    10 "awaitReason": "a2a_input_required",
    11 "elicitation": {
    12 "mode": "form",
    13 "message": "Which travel dates do you want to book?",
    14 "serverName": "Travel Agent"
    15 }
    16 },
    17 "externalAgent": {
    18 "contextId": "YOUR_CONTEXT_ID",
    19 "taskId": "YOUR_TASK_ID"
    20 }
    21}

    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 MCP server (mcp_elicitation) with an mcpElicitation entry on POST /v1/dispatch/resume instead, as described in 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, cannot 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
$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 use the tool name for a single call for compatibility with legacy clients.
  • 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.
  • The resume request does not consume additional execution quota. The turn was counted when it started.

For agent batches and flow conflict recovery, follow 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.

Do not 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.

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 does not 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 does not match the stored registry, the request returns 409 { "error": "client_tools_resend_required" }. Retry with the full clientTools array and the fingerprint.
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 does not 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.

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 is not 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
1{
2 "type": "await",
3 "awaitReason": "detached",
4 "executionId": "YOUR_EXECUTION_ID"
5}

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
$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
1import { RuntypeClient, withDetachedReconnect } from '@runtypelabs/sdk'
2
3const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })
4
5// Reconnect is on by default.
6const stream = await client.agents.executeStream('YOUR_AGENT_ID', {
7 messages: [{ role: 'user', content: 'Research this and write it up' }],
8})
9
10// Opt out to own the reconnect yourself, then drive it with withDetachedReconnect.
11const raw = await client.agents.executeStream(
12 'YOUR_AGENT_ID',
13 { messages: [{ role: 'user', content: 'Research this and write it up' }] },
14 { autoReconnect: false }
15)

This applies to agent streams from client.agents.executeStream() and POST /v1/agents/:id/execute. Flow dispatch streams do not detach. Raw HTTP callers and the Python SDK reattach manually with the ?after= request above.

Durable turns and the in-process fast lane

An agent turn is durable by default. A durable turn keeps running after the socket closes, which is what makes the detach, ?after= reconnect, and cancel behavior above possible. Setting it up costs about a second before the first token.

A turn that is provably short skips that setup and runs in-process instead. A turn qualifies only when every one of these is true: it carries no conversationId, the agent attaches no tools, skills, or sandbox, no approval gate can be reached, the request sends no client tools and no Prefer: respond-async, and the agent’s durability budget is no longer than the five-minute in-process limit. Such a turn is one model call, so it answers faster. In exchange it does not detach, cannot be reconnected with ?after=, and a wedged run is cancelled cooperatively rather than immediately. Anything that might run long or pause stays durable.

Pin either lane for one request with durability. A request pin outranks the agent’s saved durability.forced setting:

Request body
1{
2 "messages": [{ "role": "user", "content": "Research this and write it up" }],
3 "durability": {
4 "forced": "durable",
5 "watchLeaseMs": 1800000
6 }
7}

forced is "durable" or "in_process". watchLeaseMs sets how long the attached stream stays open before the turn detaches, from 10 seconds to 30 minutes; the default is 10 minutes. Raising it keeps one socket open for longer and does not change how long the run itself may take. Durability is not the same choice as Prefer: respond-async: a durable turn still streams to your request unless you ask for an async response.

POST /v1/dispatch accepts the saved-agent durability config on its agent input, so a saved configuration round-trips into an inline dispatch agent unchanged. The request-pin semantics above are live on POST /v1/dispatch for forced and watchLeaseMs when the request names a saved agent by agentId and carries nothing the durable session cannot honor: no resumeFrom and no clientToolOutputs, no Claude Managed agent and no local inference, no client tools, no inline secrets, inputs, credentialProxies, or environment override, no stepTimeoutMs or flowTimeoutMs, storeResults and cache not disabled, and no approval gate the run could reach. An inline agent definition and every one of those shapes keep the in-process path, where the pin still selects the fast lane or the standard one.

For a run you started through POST /v1/dispatch with a saved agent, or any run whose agent you do not have at hand, GET /v1/executions/{executionId}/events?after= reconnects by execution id alone with the same replay-and-tail contract. An execution that names no agent (an inline definition) answers 404, because it addresses no durable session.

Tool approval grants

When you enable tool approval, 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 durable 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, 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.

A grant skips only the approval prompt, not authorization. Tool resolution, ownership scoping, and secret access are unchanged. Runtype does not 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 does not host human approval prompts.

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.

List grants

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

TypeScript SDK
1import { RuntypeClient } from '@runtypelabs/sdk'
2
3const client = new RuntypeClient({ apiKey: process.env.RUNTYPE_API_KEY })
4
5const 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
$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:

FieldDescription
idGrant ID, used to revoke
agentIdThe agent the grant applies to
endUserRefThe 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.
toolTypeTool namespace (builtin, mcp, custom)
toolNameThe tool the grant remembers
decisionThe remembered decision (allow)
createdAtWhen the grant was recorded
expiresAtExpiry time, or null if the grant does not expire

Revoke a grant

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

TypeScript SDK
1await client.toolApprovalGrants.revoke('YOUR_GRANT_ID')

Replace YOUR_GRANT_ID with the ID of the grant to revoke.

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, agentInput.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
1{
2 "agent": { "id": "YOUR_AGENT_ID" },
3 "messages": [{ "role": "user", "content": "Email the summary to the team" }],
4 "agentInput": {
5 "tools": {
6 "approval": {
7 "require": ["send_email"],
8 "choices": { "alwaysAllow": true }
9 }
10 }
11 }
12}

Replace YOUR_AGENT_ID with the ID of the saved agent.

Limits

The following limits apply to runtime and client tools:

LimitValue
Total runtime tools per request50 across all steps
Client tools per dispatch50
Client tools payload64 KB
MCP servers per step5
Custom tool timeout30 seconds

API format

The API uses camelCase for field names. The following request shows the format:

Runtime tools request
1{
2 "tools": {
3 "runtimeTools": [
4 {
5 "name": "get_weather",
6 "description": "Get weather for a city",
7 "toolType": "external",
8 "parametersSchema": {
9 "type": "object",
10 "properties": {}
11 },
12 "config": {
13 "url": "https://api.example.com/weather",
14 "method": "GET"
15 }
16 }
17 ]
18 }
19}

Best practices

Apply these practices when you configure runtime 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.

Use lowercase header names. For example, use authorization instead of Authorization to avoid issues with automatic case conversion.

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

Validate that parametersSchema is valid JSON Schema. Invalid schemas cause tool calls to fail.

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 human: 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. References, unsupported keywords, cross-field constraints, reserved names and hidden parameter overlap are rejected.

The model sees only the remaining parameters. The runtime validates those arguments, collects the human-owned fields, validates the merged object, then requests any ordinary approval for the final arguments. Approval resume reuses the validated input. Changing the tool definition or model arguments requires restarting the invocation. Cancel or decline skips dispatch and returns a cancellation result for the original tool call.

This requires a host that can persist and resume input. The portable runtime host opts in with enableToolElicitation: true; both agent and flow lanes support it. Authenticated root AG-UI agents use the existing form interrupt and resume endpoint. Nested product execution, managed agents and dashboard Persona clients without form-answer support remain disabled. Server tool arguments stay private unless the builder opts the tool in through the AG-UI surface’s behavior.discloseToolArguments.tools, for example ['schedule_meeting']. That rule exposes model-owned arguments in interrupt metadata and tool-call events. Hidden values are still removed before persistence and disclosure.

Unified streaming emits the existing await event with awaitReason: 'tool_elicitation' and the existing form elicitation payload. There is no new event type. Resume authorization and routing use the server’s checkpoint, not the displayed reason. MCP elicitation keeps its existing reason and resume behavior.

Next steps

Continue with one of these resources: