Skip to main content

Pass Per-User Credentials to Tools with Tool Context

A scheduled or orchestrated flow often acts on behalf of a user: the tools a run's agents call must authenticate as that user, not as the platform. tool_context is the channel for that — a flat key/value bag attached to the run, forwarded as request headers on every tool call.

You will:

  1. Create an http tool whose Authorization header is a {{context:userToken}} token, confined with context_keys.
  2. Start an orchestration run with a tool_context, pause at a human node, and resume.
  3. Inspect the exact headers that reached the endpoint, and see the fail-closed MISSING_TOOL_CONTEXT_KEY path.

The tool endpoint is a local header-echo listener, so no external services are needed beyond Ollama.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • New to SOAT? Read Key Concepts to understand projects, agents, tools, and runs first.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • Ollama reachable by the server with the qwen2.5:0.5b model pulled, as in the orchestration tutorial.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Everything in this tutorial lives inside one project.

PROJECT_ID=$(soat create-project --name "tool-context-tutorial" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create an AI provider

Set up a local AI provider backed by Ollama, so the tutorial runs without external credentials. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"

Step 4 — Start a header-echo endpoint

The tool needs somewhere to call, and the whole point of this tutorial is to inspect exactly which headers arrive there. Start a tiny local HTTP listener that writes the headers of the last request it received to tool-echo.json.

In the automated tutorial tests, SOAT_TOOL_ECHO_BASE_URL is injected so the server container can reach this listener — the same mechanism the webhooks tutorial uses for its own listener. Running the SOAT server in Docker against a listener on your host? Use http://host.docker.internal:8788 as the base instead of localhost.

ECHO_URL="${SOAT_TOOL_ECHO_BASE_URL:-http://localhost:8788}/orders"

node -e '
const http = require("http");
const fs = require("fs");
http
.createServer((req, res) => {
if (req.method === "POST") {
fs.writeFileSync("tool-echo.json", JSON.stringify({ headers: req.headers }));
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true }));
})
.listen(8788);
' > echo-listener.log 2>&1 &
ECHO_PID=$!
echo "Echo listener PID: $ECHO_PID"

# → retry 10
node -e 'require("http").get("http://localhost:8788/health", (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on("error", () => process.exit(1))'

The readiness probe is a GET, and the listener only records POST bodies — so probing never overwrites the header record the assertions in Step 10 read.


Step 5 — Create the tool: a {{context:...}} header plus a context_keys allowlist

This one tool definition carries both halves of the credential story:

  • Authorization: Bearer {{context:userToken}} — a context reference: at call time the server substitutes the userToken key of the run's tool_context into this header.
  • context_keys: ["tenant"] — the containment allowlist: only tenant is forwarded as a prefixed X-Soat-Context-* header, so the raw userToken context header is never sent.
ORDER_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "record_order" \
--type "http" \
--description "Records an order for the signed-in user" \
--parameters '{"type":"object","properties":{"note":{"type":"string","description":"Free-text note for the order"}}}' \
--execute '{"url":"'"$ECHO_URL"'","method":"POST","headers":{"Authorization":"Bearer {{context:userToken}}"}}' \
--context-keys '["tenant"]' | jq -r '.id')
echo "ORDER_TOOL_ID: $ORDER_TOOL_ID"

The token is resolved at the point of use, never at rest: reading the tool back returns the literal template, exactly like a {{secret:...}} reference.

soat get-tool --tool-id "$ORDER_TOOL_ID" | jq -e '.execute.headers.Authorization == "Bearer {{context:userToken}}"'
Why not put the token in tool_context under the key Authorization?

A tool_context key always lands under the context prefix, so a caller-supplied key can never name (or overwrite) a standard header. See Placing a value in a real header.


Step 6 — Create the agent

A small agent that carries the tool. A step-1 step_rules entry forces the first model call to invoke record_order, so the tool call — the thing this tutorial asserts on — does not depend on what a small local model feels like doing. See client tools for the forcing semantics.

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Order Clerk" \
--instructions "You record orders. Call the record_order tool exactly once, then reply with a one-line confirmation. Never ask follow-up questions." \
--tool-bindings "[{\"tool_id\":\"$ORDER_TOOL_ID\"}]" \
--step-rules '[{"step":1,"tool_choice":{"type":"tool","tool_name":"record_order"}}]' \
--max-steps 3 | jq -r '.id')
echo "AGENT_ID: $AGENT_ID"

Step 7 — Create the orchestration: a pause before the tool call

Two nodes: a human node that parks the run, then the agent node that makes the tool call. A paused run has no request in flight that could carry a tool_context, so resuming with the bag intact proves it is stored on the run itself (Run Tool Context). The token appears nowhere in the graph — the graph is reusable for every user; the credential arrives per run.

ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Record Order For User" \
--nodes '[
{"id":"confirm","type":"human","prompt":"Proceed with recording the order?","options":["proceed","cancel"]},
{"id":"record","type":"agent","agent_id":"'"$AGENT_ID"'","prompt":"Record order #1234 for the signed-in user."}
]' \
--edges '[{"from":"confirm","to":"record"}]' | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"

Step 8 — Start the run with the user's credential

Pass tool_context when starting the run (Run Tool Context). In production the caller is whoever holds the per-user token. With --wait, the call returns as soon as the run parks at the confirm node.

RUN=$(soat start-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--input '{}' \
--tool-context '{"userToken":"alice-token-123","tenant":"acme"}' \
--wait)

RUN_ID=$(printf '%s' "$RUN" | jq -r '.id')
printf '%s\n' "$RUN" | jq '{status, required_action}'
printf '%s' "$RUN" | jq -e '.status == "awaiting_input"'

Expected output — the run is parked, holding the bag, with no generation started yet:

{
"status": "awaiting_input",
"required_action": {
"type": "human_input",
"node_id": "confirm",
"prompt": "Proceed with recording the order?",
"options": ["proceed", "cancel"]
}
}

Step 9 — Resume, and let the tool call happen

Submit the human decision with submit-human-input. The resume request carries no tool_context of its own — the run re-reads the bag it stored at start. The record agent node then runs: the model is forced to call record_order, and the server builds the outbound request — substituting {{context:userToken}} into Authorization and forwarding the allowlisted tenant key as a context header.

soat submit-human-input \
--orchestration-run-id "$RUN_ID" \
--node-id "confirm" \
--output '{"choice":"proceed"}' | jq '{status}'

# → retry 120
soat get-orchestration-run --orchestration-run-id "$RUN_ID" | jq -e '.status == "succeeded"'

soat get-orchestration-run --orchestration-run-id "$RUN_ID" \
| jq '[.node_executions[] | {node_id, node_type, status}]'

Step 10 — Inspect what actually reached the endpoint

The echo listener recorded the headers of the tool call. Three assertions, one per guarantee:

  1. The credential arrived in the real headerAuthorization: Bearer alice-token-123, substituted from the run's tool_context by the tool's {{context:userToken}} token.
  2. The allowlisted key arrived as a context headerx-soat-context-tenant: acme. (Header names arrive lowercased; read them case-insensitively.)
  3. The raw token did not — no x-soat-context-usertoken header, because context_keys: ["tenant"] does not list it. The credential exists at this endpoint only where the tool declared it, and would not reach any other tool at all.
jq '.headers | {authorization, "x-soat-context-tenant": .["x-soat-context-tenant"]}' tool-echo.json

jq -e '.headers.authorization == "Bearer alice-token-123"' tool-echo.json
jq -e '.headers["x-soat-context-tenant"] == "acme"' tool-echo.json
jq -e '.headers | has("x-soat-context-usertoken") | not' tool-echo.json

Expected output:

{
"authorization": "Bearer alice-token-123",
"x-soat-context-tenant": "acme"
}
Self-hosting under your own brand?

The X-Soat-Context- prefix is deployment configuration: set TOOL_CONTEXT_HEADER_PREFIX (e.g. X-Acme-Context-) and every context header is emitted under your name instead. The {{context:...}} mechanism is unaffected — it never uses the prefix.


Step 11 — The fail-closed path: a call with no context at all

When substitution has no userToken to resolve, the tool call fails with MISSING_TOOL_CONTEXT_KEY — naming the key and the header — instead of sending an empty Authorization: Bearer . The shortest way to see it is call-tool, which invokes a tool directly with no run behind it and therefore no tool_context (an orchestration tool node behaves the same — see the rules table).

# → expect-fail
soat call-tool --tool-id "$ORDER_TOOL_ID" --input '{"note":"direct call"}'

Expected output — the call is refused before anything reaches the endpoint:

"MISSING_TOOL_CONTEXT_KEY"

A tool that declares a {{context:...}} token must therefore be reached through a path that carries context: an agent generation, a session, or an orchestration agent node — as in Steps 8 and 9.


Step 12 — Clean up

Stop the Node.js echo listener from Step 4.

# → ignore
kill $ECHO_PID

Where to go next