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:
- Create an
httptool whoseAuthorizationheader is a{{context:userToken}}token, confined withcontext_keys. - Start an orchestration run with a
tool_context, pause at a human node, and resume. - Inspect the exact headers that reached the endpoint, and see the fail-closed
MISSING_TOOL_CONTEXT_KEYpath.
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.5bmodel pulled, as in the orchestration tutorial. - Server is at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Everything in this tutorial lives inside one project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "tool-context-tutorial" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'tool-context-tutorial' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
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"
const { data: aiProvider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const AI_PROVIDER_ID = aiProvider.id;
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
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.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
const ECHO_URL = `${process.env.SOAT_TOOL_ECHO_BASE_URL ?? 'http://localhost:8788'}/orders`;
createServer((req, res) => {
if (req.method === 'POST') {
writeFileSync('tool-echo.json', JSON.stringify({ headers: req.headers }));
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true }));
}).listen(8788);
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=$!
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 theuserTokenkey of the run'stool_contextinto this header.context_keys: ["tenant"]— the containment allowlist: onlytenantis forwarded as a prefixedX-Soat-Context-*header, so the rawuserTokencontext header is never sent.
- CLI
- SDK
- curl
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"
const { data: orderTool } = await adminSoat.tools.createTool({
body: {
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'],
},
});
const ORDER_TOOL_ID = orderTool.id;
ORDER_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
soat get-tool --tool-id "$ORDER_TOOL_ID" | jq -e '.execute.headers.Authorization == "Bearer {{context:userToken}}"'
const { data: readBack } = await adminSoat.tools.getTool({
path: { tool_id: ORDER_TOOL_ID },
});
console.log(readBack.execute?.headers?.Authorization);
// "Bearer {{context:userToken}}" — the template, never a resolved value
curl -s "$SOAT_BASE_URL/api/v1/tools/$ORDER_TOOL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -e '.execute.headers.Authorization == "Bearer {{context:userToken}}"'
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.
- CLI
- SDK
- curl
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"
const { data: agent } = await adminSoat.agents.createAgent({
body: {
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,
},
});
const AGENT_ID = agent.id;
AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
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"
const { data: orchestration } = await adminSoat.orchestrations.createOrchestration({
body: {
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' }],
},
});
const ORCHESTRATION_ID = orchestration.id;
ORCHESTRATION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
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"'
const { data: run } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: {},
tool_context: { userToken: 'alice-token-123', tenant: 'acme' },
wait: true,
},
});
const RUN_ID = run.id;
console.log(run.status); // "awaiting_input"
RUN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{},\"tool_context\":{\"userToken\":\"alice-token-123\",\"tenant\":\"acme\"},\"wait\":true}")
RUN_ID=$(printf '%s' "$RUN" | jq -r '.id')
printf '%s\n' "$RUN" | jq '{status, required_action}'
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.
- CLI
- SDK
- curl
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}]'
await adminSoat.orchestrations.submitHumanInput({
path: { orchestration_run_id: RUN_ID },
body: { node_id: 'confirm', output: { choice: 'proceed' } },
});
// Poll until the run settles
let finished;
do {
await new Promise((r) => setTimeout(r, 1000));
({ data: finished } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: RUN_ID },
}));
} while (!['succeeded', 'failed'].includes(finished.status));
console.log(finished.status); // "succeeded"
curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN_ID/human-input" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"node_id":"confirm","output":{"choice":"proceed"}}' | jq '{status}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{status}'
Step 10 — Inspect what actually reached the endpoint
The echo listener recorded the headers of the tool call. Three assertions, one per guarantee:
- The credential arrived in the real header —
Authorization: Bearer alice-token-123, substituted from the run'stool_contextby the tool's{{context:userToken}}token. - The allowlisted key arrived as a context header —
x-soat-context-tenant: acme. (Header names arrive lowercased; read them case-insensitively.) - The raw token did not — no
x-soat-context-usertokenheader, becausecontext_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.
- CLI
- SDK
- curl
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
import { readFileSync } from 'node:fs';
const { headers } = JSON.parse(readFileSync('tool-echo.json', 'utf8'));
console.log(headers['authorization']); // "Bearer alice-token-123"
console.log(headers['x-soat-context-tenant']); // "acme"
console.log('x-soat-context-usertoken' in headers); // false — contained
jq '.headers | {authorization, "x-soat-context-tenant": .["x-soat-context-tenant"]}' tool-echo.json
Expected output:
{
"authorization": "Bearer alice-token-123",
"x-soat-context-tenant": "acme"
}
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).
- CLI
- SDK
- curl
# → expect-fail
soat call-tool --tool-id "$ORDER_TOOL_ID" --input '{"note":"direct call"}'
const { error } = await adminSoat.tools.callTool({
path: { tool_id: ORDER_TOOL_ID },
body: { input: { note: 'direct call' } },
});
console.log(error?.error?.code); // "MISSING_TOOL_CONTEXT_KEY"
curl -s -X POST "$SOAT_BASE_URL/api/v1/tools/$ORDER_TOOL_ID/call" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"input":{"note":"direct call"}}' | jq '.error.code'
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.
- CLI
- SDK
- curl
# → ignore
kill $ECHO_PID
// Close the createServer() instance from Step 4, e.g. server.close()
# → ignore
kill $ECHO_PID
Where to go next
- Tool Context — the canonical contract and security notes.
- Expressions & Templating — how
{{context:...}}and{{secret:...}}compose. - Run Tool Context — why the bag survives every way a run is driven.
- Cap spend per end user — sessions and actors, where identity keys in
tool_contextare auto-populated.