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
| Surface | Field |
|---|---|
POST /api/v1/agents/{agent_id}/generate | tool_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 .../generate | tool_context — per-request, this generation only |
POST /api/v1/conversations/{conversation_id}/generate | tool_context in the body |
POST /api/v1/orchestration-runs | tool_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/tasks | tool_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}/transitions | tool_context — replaces the task's stored bag; omitting it keeps it (see Dispatch tool context) |
POST /api/v1/evals/{eval_id}/runs | tool_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}/call | tool_context — this call only (below) |
| Formation templates | tool_context on a Session resource |
Which tools receive the headers
| Tool type | Headers forwarded | Notes |
|---|---|---|
http | Yes | Injected as request headers on the outbound call |
mcp | Yes | Injected on the MCP tools/call fetch |
builtin | Yes | Propagated into nested agent generations |
client | No | Executes on the caller's side; nothing is sent. A {{context:}} preset is still resolved before the arguments are handed over |
pipeline | Yes | Each 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.
| Rule | Behavior |
|---|---|
| Where the token is allowed | execute.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 grammar | Identical 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 time | The 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 value | A 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 paths | Every 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.
| Rule | Behavior |
|---|---|
| Where it resolves from | This call's tool_context, the same bag the headers read. The allowlist does not gate it. |
| Missing key at call time | The 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. |
| Type | Context 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. |
| Nesting | Tokens 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. |
| Guardrails | A 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 key | Forwarded header |
|---|---|
userId | X-Soat-Context-userId |
tenantId | X-Soat-Context-tenantId |
tenantExternalId | X-Soat-Context-tenantExternalId |
tenant_external_id | X-Soat-Context-tenant_external_id |
tenant-external-id | X-Soat-Context-tenant-external-id |
tenant.external.id | X-Soat-Context-tenant.external.id |
TenantId | X-Soat-Context-TenantId |
env | X-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 key | Forwarded header | Value |
|---|---|---|
session_id | X-Soat-Context-session_id | Public ID of the session; always present |
actor_id | X-Soat-Context-actor_id | Public ID of the session's actor; omitted if not set |
actor_external_id | X-Soat-Context-actor_external_id | external_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):
- 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.keyslists the offending keys.) - No collisions — two keys must not map to the same header field. Because header names are case-insensitive,
userIdandUserIdproduce two different header strings that HTTP folds into one, and one value would be silently dropped. (meta.headernames the colliding header;meta.keyslists 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 key | Forwarded header |
|---|---|
userId | X-Acme-Context-userId |
tenant_external_id | X-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 opaquefetchfailure mid-generation. - The prefix cannot be removed. An empty or unset value keeps the default. Without a prefix, a caller-supplied
tool_contextkey could name any header at all —Authorizationincluded — 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