Skip to main content

Tool Context

tool_context is a flat Record<string, string> a caller attaches to a generation. Every entry is forwarded as an HTTP request header on each tool call the generation makes, so a server-side tool can authorize against the caller's identity instead of trusting data embedded in the prompt.

This page is the canonical contract. tool_context is not general templating: a value is never interpolated into a URL, and never into arguments the model chooses. The two places a value is read by name are both declared by the tool — its own headers, and its preset_parameters — via a {{context:<key>}} token. See Placing a value in a real header and Pinning a parameter to the run's value below, and Expressions & Templating.

Where it is accepted

SurfaceField
POST /api/v1/agents/{agent_id}/generatetool_context in the body
POST /api/v1/sessions / PATCH /api/v1/sessions/{session_id}tool_context — persisted on the session, applied to every generation in it
POST /api/v1/sessions/{session_id}/messages and .../generatetool_context — per-request, this generation only
POST /api/v1/conversations/{conversation_id}/generatetool_context in the body
POST /api/v1/orchestration-runstool_context — persisted on the run, applied to the generation of every agent node it executes, to the tool call of every tool and poll node, and inherited by loop/sub_orchestration child runs — narrowable per node with context_keys (see Run Tool Context)
POST /api/v1/taskstool_context — persisted on the task, applied to the dispatches of the entry state's on_enter (all three kinds: agent, tool, orchestration)
POST /api/v1/tasks/{task_id}/transitionstool_contextreplaces the task's stored bag; omitting it keeps it (see Dispatch tool context)
POST /api/v1/evals/{eval_id}/runstool_context — persisted on the run and applied to every item's generation, so an agent is scored as it runs in production. Write-only, and cleared when the run settles (see Run tool context)
POST /api/v1/tools/{tool_id}/calltool_context — this call only (below)
Formation templatestool_context on a Session resource

Which tools receive the headers

Tool typeHeaders forwardedNotes
httpYesInjected as request headers on the outbound call
mcpYesInjected on the MCP tools/call fetch
builtinYesPropagated into nested agent generations
clientNoExecutes on the caller's side; nothing is sent. A {{context:}} preset is still resolved before the arguments are handed over
pipelineYesEach step inherits the context the pipeline was called with

Context headers are applied after any headers configured on the tool's execute.headers / mcp.headers, so a context header wins over a tool-defined header with the same name.

A generation's bag also reaches the tool calls it makes before the model runs: a tool_output message content block asks the server to call a tool and inline its result into the prompt, and that call carries the same identity-pinned bag the model's own tool calls do.

By default a tool receives every key in the bag. A tool can narrow that to an allowlist with context_keys — the containment half of carrying a credential in tool_context, and the answer to "mind what egresses" below:

{ "name": "list-orders", "type": "http", "context_keys": ["tenant"] }

The auto-populated identity keys are always forwarded regardless of the allowlist, and a key consumed by a {{context:<key>}} token in the tool's own headers or preset_parameters is substituted regardless of it too — the allowlist governs what is handed to a tool that never asked for it, while a token is the tool naming the key it consumes.

When a generation pauses with status: "requires_action", the tool_context from the original request is preserved and reapplied on resume. An orchestration run gets the same guarantee from its own row rather than from the paused generation: it survives an awaiting_input pause, a sleeping wait, a background worker drive and a crash redrive, none of which carry a request a bag could travel in. A task stores its bag the same way, which is what carries it across an approval gate, a retry and an automated hop — its pauses are measured in days, not seconds.

Where a session's and a run's bag is readable, a task's is write-only: a task is long-lived and read by every principal on the board, so its stored bag is never returned by a read and is cleared when the task closes.

Placing a value in a real header

tool_context by itself can only ever produce headers under the deployment's context prefix (X-Soat-Context- by default). That a prefix is always applied, and that the caller cannot choose it, is a security invariant rather than an inconvenience: if a caller-supplied key could name any header, it could overwrite the tool's own configured credential or the server-pinned identity headers, and context headers are spread last in the header build.

When a target needs the value in a header of its own — almost always Authorization — the tool definition says so, with a {{context:<key>}} token in its headers:

mcp:
headers:
Authorization: 'Bearer {{context:ocaToken}}'
execute:
url: https://api.example.com/v1/orders
headers:
Authorization: 'Bearer {{context:ocaToken}}'
X-Tenant: '{{context:tenant}}'

The authority is split on purpose: the tool knows the header shape its endpoint expects, and the caller only supplies the value. Nothing about tool_context itself changes — the same call still also sends the prefixed …-ocaToken context header, so a target may read either.

RuleBehavior
Where the token is allowedexecute.headers, mcp.headers and preset_parameters. Anywhere else — execute.url, mcp.url, execute.auth, a body — is rejected at write time with 400 INVALID_TEMPLATE_TOKEN. A context value is caller-supplied, so it must not be able to steer the outbound URL.
Key grammarIdentical to a tool_context key's — one grammar, so the two cannot disagree about which keys exist. An invalid key is rejected at write time, not at first call.
Missing key at call timeThe tool call fails with 400 MISSING_TOOL_CONTEXT_KEY, naming the key and header. An Authorization: Bearer with no value would reach the endpoint and come back as an opaque upstream 401, several steps from the actual mistake.
Empty-string valueA value, not a missing key — the header is sent empty, because that is what the caller asked for.
With {{secret:...}}Both kinds may appear in the same header value and are substituted in a single pass, so a substituted value is never re-scanned as template source: a tool_context value containing {{secret:sec_...}} stays literal text, and so does a secret whose plaintext contains {{context:...}}.
Calling pathsEvery dispatch surface carries a bag. POST /api/v1/tools/{tool_id}/call takes one in its body (below); orchestration tool and poll nodes carry the run's, and so does every step of a pipeline tool. A tool declaring {{context:...}} that is called without the key it needs fails with MISSING_TOOL_CONTEXT_KEY.

The token is resolved at the point of use. GET /tools echoes back the token, never the resolved value — the same as {{secret:...}}.

Pinning a parameter to the run's value

A header carries the credential; it cannot carry the scope the credential must be confined to. A token that reaches dozens of ad accounts is still one token, and which account this run may act on is a decision the caller makes per run — not one the model should make, and not one that can be frozen into the tool when it is created.

preset_parameters accepts the same {{context:<key>}} token, which closes that gap:

{
"type": "mcp",
"mcp": { "headers": { "Authorization": "Bearer {{context:ocaToken}}" } },
"context_keys": ["ocaToken", "ocaAdAccountId"],
"preset_parameters": { "adAccountId": "{{context:ocaAdAccountId}}" }
}

A preset is a pin: it wins over whatever the model (or a direct caller) supplies for the same key, and the key is hidden from the schema the model sees. Resolving the token from tool_context therefore makes the pin a genuine per-run boundary — one tool serving many tenants, instead of one tool per tenant.

RuleBehavior
Where it resolves fromThis call's tool_context, the same bag the headers read. The allowlist does not gate it.
Missing key at call timeThe tool call fails with 400 MISSING_TOOL_CONTEXT_KEY, naming the parameter and the key. The literal {{context:...}} text reaching the target as a resource id comes back as an opaque not found, several steps from the mistake.
TypeContext values are strings; a resolved value is retyped to what the target's schema declares for that parameter (integer, number, boolean), so the same account can be adAccountId: "act_…" on one action and metaAdAccountId: 123 on another. A value the declared type cannot accept is left as the string it is, for the target to reject.
NestingTokens are resolved at any depth — inside a nested object or an array element, not only at the top level.
{{secret:...}}Not resolved in a preset, and not an error: the token stays literal. A preset value travels into the request body, into guardrail evaluation and into the activity record, so secrets stay in headers.
GuardrailsA guard sees the resolved value in args.*, and an approval item records it — the arguments the call will actually carry.

Calling a context-dependent tool directly

POST /api/v1/tools/{tool_id}/call takes a tool_context of its own, so a tool that authorizes through the bag can be exercised without an agent in between — which is how you smoke-test one:

soat call-tool \
--tool-id tool_01 \
--input '{"order_id":"ord_42"}' \
--tool-context '{"ocaToken":"tok_abc123","tenant":"acme"}'

The bag behaves as it does anywhere else: it resolves {{context:...}} in execute.headers, mcp.headers and preset_parameters, it is forwarded as X-Soat-Context-* headers, and the tool's context_keys narrows it.

One difference, and it is the reason this route was the last one to get a bag. There is no session here, so there is no server-derived identity to stamp — and therefore nothing that would overwrite a caller-supplied session_id. The auto-populated keys are dropped from this bag rather than forwarded, in any casing, so a tool endpoint can still read X-Soat-Context-session_id as a value SOAT derived rather than one a caller chose.

Key → header name

The header name is the context prefix — X-Soat-Context- unless your deployment configures another one — followed by your key, verbatim. No character is re-cased and no separator is collapsed:

tool_context keyForwarded header
userIdX-Soat-Context-userId
tenantIdX-Soat-Context-tenantId
tenantExternalIdX-Soat-Context-tenantExternalId
tenant_external_idX-Soat-Context-tenant_external_id
tenant-external-idX-Soat-Context-tenant-external-id
tenant.external.idX-Soat-Context-tenant.external.id
TenantIdX-Soat-Context-TenantId
envX-Soat-Context-env

The table is a formality — the rule is string concatenation. tenant_external_id and tenantExternalId are two different keys and produce two different headers.

Keys are never case-converted. Unlike every other field in the REST API, tool_context keys are not rewritten between snake_case and camelCase: a key is an HTTP header name, not a SOAT field name, so it is stored, echoed in responses, and forwarded exactly as you wrote it — on REST, in formation templates, and over MCP alike. Reading a session's tool_context and sending it back unchanged is lossless.

Read the header case-insensitively

HTTP field names are case-insensitive (RFC 9110 §5.1), and HTTP/2 and HTTP/3 transmit them lowercased (RFC 9113 §8.2.1). Your endpoint will usually see x-soat-context-userid no matter how you spelled the key, so the casing in the table above is presentational.

Look the header up through your framework's normal accessor, which already handles this:

// Node / Express / Koa — incoming header names are lowercased for you
const userId = req.headers['x-soat-context-userid'];

Do not match X-Soat-Context-userId as an exact string, and make any gateway, WAF, or log-routing rule that references these headers case-insensitive.

Auto-populated keys (sessions)

When a generation runs through a session, the server injects these keys automatically:

Injected keyForwarded headerValue
session_idX-Soat-Context-session_idPublic ID of the session; always present
actor_idX-Soat-Context-actor_idPublic ID of the session's actor; omitted if not set
actor_external_idX-Soat-Context-actor_external_idexternal_id of the session's actor; omitted if not set

You do not need to set these yourself — a session-backed generation already carries them.

Precedence

For every other key, later wins — a per-request tool_context overrides the session's stored tool_context:

session tool_context < per-request tool_context

The three auto-populated keys (session_id, actor_id, actor_external_id) are the exception: they are always taken from the session and its actor, and a caller-supplied value for one of them — in either the stored or the per-request tool_context — is ignored. A tool endpoint can rely on these three headers reflecting the real session/actor even if the caller tries to set them.

Validation

A key becomes an HTTP header name, so it must be a valid one. A request whose tool_context violates either rule below is rejected with 400 INVALID_TOOL_CONTEXT_KEY at write time (create-session, update-session) or before the provider call (generation endpoints):

  1. Character set — a key may contain only letters, digits and !#$%&'*+-.^_`|~. A key with a space, colon, parenthesis, newline or non-ASCII character is rejected. (meta.keys lists the offending keys.)
  2. No collisions — two keys must not map to the same header field. Because header names are case-insensitive, userId and UserId produce two different header strings that HTTP folds into one, and one value would be silently dropped. (meta.header names the colliding header; meta.keys lists both keys.)

A tool's context_keys entries are held to rule 1 as well, and rejected with the same code at create-tool / update-tool: an entry names a key, so an entry no key could ever match is a typo, not an allowlist.

There is no length or total-header-bytes limit enforced by SOAT; the receiving server's own header limits apply.

Configuring the header prefix

The prefix is deployment configuration, not part of the request. A self-hosted deployment sets TOOL_CONTEXT_HEADER_PREFIX to replace X-Soat-Context- on every context header it emits — the case for it is white-labeling: a platform fronting SOAT under its own name should not send that name to the third-party tool endpoints its agents call.

TOOL_CONTEXT_HEADER_PREFIX=X-Acme-Context-
tool_context keyForwarded header
userIdX-Acme-Context-userId
tenant_external_idX-Acme-Context-tenant_external_id

Everything else is unchanged: the prefix is prepended verbatim (include the trailing - yourself), the key is still never re-cased, the auto-populated keys still take the same names after the prefix, and the validation rules above are evaluated against the configured prefix.

Two constraints:

  • The value must be a valid HTTP header-name prefix — letters, digits and !#$%&'*+-.^_`|~. An invalid prefix fails the tool call with an error naming the variable, rather than surfacing as an opaque fetch failure mid-generation.
  • The prefix cannot be removed. An empty or unset value keeps the default. Without a prefix, a caller-supplied tool_context key could name any header at all — Authorization included — and override a credential the tool definition configured.

Changing the prefix on a running deployment breaks every tool endpoint already reading the old header names, third-party endpoints included. Set it before wiring up tools, or migrate both sides together.

Security

The forwarded headers are the point: a tool endpoint can trust them in a way it cannot trust the prompt. Two things to keep in mind.

Verify the caller. Any client that can reach your tool endpoint can set an X-Soat-Context-* header by hand. The headers are trustworthy only if the endpoint also authenticates the request as coming from SOAT (a shared secret in execute.headers, mTLS, or network-level restriction). Treat them as attested by SOAT, not as unforgeable.

Mind what egresses. Unless a tool sets context_keys, every value is transmitted to every http, mcp and builtin tool the agent calls, including endpoints you do not control. Set context_keys on the tools that need a given key — particularly when a key holds a credential. This applies to the auto-populated actor_external_id: if an Actor's external_id holds a phone number or an email address, that PII reaches every third-party tool endpoint in the agent's tool set. When the tool set includes third-party endpoints, prefer an opaque internal identifier as external_id and correlate to the real contact detail on your own side.

Example

soat create-session \
--agent-id agent_01 \
--actor-id actor_01 \
--tool-context '{"tenantId":"acme","plan":"pro"}'

Every http tool call in that session then receives:

X-Soat-Context-session_id: sess_01
X-Soat-Context-actor_id: actor_01
X-Soat-Context-actor_external_id: +5511999999999
X-Soat-Context-tenantId: acme
X-Soat-Context-plan: pro