Skip to main content

Pass Per-User Credentials to Tools with Tool Context

When a run acts on behalf of a user, its tools must authenticate as that user. tool_context is a flat key/value bag attached to the run and 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 headers that reached the endpoint, and the fail-closed MISSING_TOOL_CONTEXT_KEY path.

The tool endpoint is a local header-echo listener; only Ollama is needed.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

Admin is the built-in superuser role (Users).

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

Step 2 — Create a project​

Everything 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​

Create a local Ollama AI provider. For xAI, OpenAI, Anthropic, or Amazon Bedrock, 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​

A local HTTP listener writes the headers of the last request it received to tool-echo.json.

The automated tutorial tests inject SOAT_TOOL_ECHO_BASE_URL so the server container can reach the listener, as the webhooks tutorial does. With the SOAT server in Docker and the listener on the host, use http://host.docker.internal:8788 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; the listener records only POSTs, so probing never overwrites the record Step 10 reads.


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

One tool definition carries both halves:

  • Authorization: Bearer {{context:userToken}} — a context reference; at call time the server substitutes the run's tool_context.userToken.
  • context_keys: ["tenant"] — the containment allowlist; only tenant is forwarded as a prefixed X-Soat-Context-* header, so the raw userToken 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"

Resolution happens at call time: reading the tool back returns the literal template, 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 it can never name or overwrite a standard header (Placing a value in a real header).


Step 6 — Create the agent​

A step-1 step_rules entry forces the first model call to invoke record_order, so the asserted tool call does not depend on the small local model (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​

A human node parks the run, then an agent node makes the tool call. Resuming with the bag intact proves tool_context is stored on the run (Run Tool Context). The token appears nowhere in the graph; 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). With --wait, the call returns when the run parks at confirm.

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 (parked, holding the bag, no generation 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-human-input carries no tool_context of its own; the run re-reads the bag stored at start. The record node then calls record_order, 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 == "completed"'

soat list-orchestration-run-node-executions --orchestration-run-id "$RUN_ID" \
| jq '[.data[] | {node_id, node_type, status}]'

Step 10 — Inspect what actually reached the endpoint​

Three assertions on the recorded headers:

  1. Credential in the real header — Authorization: Bearer alice-token-123, substituted by {{context:userToken}}.
  2. Allowlisted key as a context header — x-soat-context-tenant: acme. Header names arrive lowercased; read them case-insensitively.
  3. Raw token absent — no x-soat-context-usertoken, because context_keys: ["tenant"] does not list it. The credential reaches only the header this tool declared, and no other tool.
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?

Set SOAT_HEADER_PREFIX (e.g. X-Acme-) to rename the X-Soat-Context- prefix to X-Acme-Context-. {{context:...}} never uses the prefix.


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

With no userToken to resolve, the call fails with MISSING_TOOL_CONTEXT_KEY (naming the key and the header) instead of sending an empty Authorization: Bearer . call-tool invokes a tool with no run and so no tool_context; an orchestration tool node behaves the same (rules table).

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

Expected output (refused before anything reaches the endpoint):

"MISSING_TOOL_CONTEXT_KEY"

A tool declaring a {{context:...}} token must be reached through a path that carries context: an agent generation, a session, or an orchestration agent node (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​