Execute Agent Tool Calls in Your Own App (Client Tools)
Most agent tools run on the SOAT server — an http tool calls an external endpoint, a soat tool calls the platform itself. But some functions can only run inside your application: a lookup in your private database, a call to an internal service behind your firewall, an action that needs to happen in the user's browser.
That is what a client tool is for. It declares the function's contract (name, description, JSON Schema parameters) so the model can decide to call it — but SOAT never executes it. Instead, the generation pauses with status requires_action, hands the pending tool calls to your app, and resumes when you submit the results. If you have used function calling with the OpenAI or Anthropic APIs, this is the same loop — with the agent's configuration, history, and traces managed server-side.
In this tutorial you build an order-support agent for a store. The order database belongs to your app, not to SOAT, so the agent's get_order_status function is a client tool:
- The user asks the agent about an order.
- The agent pauses at
requires_actionwith aget_order_statustool call. - Your app looks the order up and submits the result.
- The agent resumes and answers with real data.
Prerequisites
- SOAT running locally with Ollama. Follow the Quick Start guide, and see Key Concepts if you are new to SOAT's mental model.
- An Ollama instance accessible at
http://ollama:11434with modelqwen2.5:0.5bpulled (ollama pull qwen2.5:0.5b). See Ollama for installation. - CLI, SDK, or curl available. The server is at
http://localhost:5047. For production hardening see Configuration.
- 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. It bypasses policy evaluation entirely. See IAM — Authentication for details on JWT tokens and the admin role.
- 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: session } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: session!.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
Every resource in SOAT lives inside a project. Create one to hold the AI provider, the tool, and the agent.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Order Support" | jq -r '.id')
echo "Project: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Order Support' },
});
const projectId = 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":"Order Support"}' | jq -r '.id')
echo "Project: $PROJECT_ID"
Step 3 — Create an Ollama AI provider
Set up a local AI provider backed by Ollama. This tutorial uses a local Ollama provider so it can run without external credentials. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.
- CLI
- SDK
- curl
PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "Provider: $PROVIDER_ID"
const { data: provider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: projectId,
name: 'Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const providerId = provider!.id;
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\":\"Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" | jq -r '.id')
echo "Provider: $PROVIDER_ID"
Step 4 — Declare the function as a client tool
A client tool has a name, a description, and a JSON Schema in parameters — and deliberately no execute configuration. The schema is what the model sees, so write it the way you want the model to call your function. Parameter keys are caller-owned: SOAT hands them back to your app exactly as you author them here (orderId stays orderId).
- CLI
- SDK
- curl
TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name get_order_status \
--type client \
--description "Looks up an order in the store database and returns its status." \
--parameters '{"type":"object","properties":{"orderId":{"type":"string","description":"The order ID, e.g. ord_1042"}},"required":["orderId"]}' | jq -r '.id')
echo "Tool: $TOOL_ID"
const { data: tool } = await adminSoat.tools.createTool({
body: {
project_id: projectId,
name: 'get_order_status',
type: 'client',
description:
'Looks up an order in the store database and returns its status.',
parameters: {
type: 'object',
properties: {
orderId: {
type: 'string',
description: 'The order ID, e.g. ord_1042',
},
},
required: ['orderId'],
},
},
});
const toolId = tool!.id;
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\": \"get_order_status\",
\"type\": \"client\",
\"description\": \"Looks up an order in the store database and returns its status.\",
\"parameters\": {\"type\":\"object\",\"properties\":{\"orderId\":{\"type\":\"string\",\"description\":\"The order ID, e.g. ord_1042\"}},\"required\":[\"orderId\"]}
}" | jq -r '.id')
echo "Tool: $TOOL_ID"
Step 5 — Create the agent
Attach the tool through tool_bindings, the canonical attachment field. Two settings make the pause-and-resume loop predictable:
tool_choice{ "type": "tool", "tool_name": "get_order_status" }forces the first model call to invoke the function instead of guessing an answer. The force applies to the call that produces the pause; after you submit the output, the run continues with"auto"so the model can answer from the result. With a larger model you can leavetool_choiceat its default"auto"and let the model decide.max_stepsbounds the agent loop.
- CLI
- SDK
- curl
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$PROVIDER_ID" \
--name order-support-agent \
--instructions "You are an order-support assistant. When the user asks about an order, call the get_order_status tool with the orderId argument, then answer using the tool result." \
--tool-bindings '[{"tool_id":"'"$TOOL_ID"'"}]' \
--tool-choice '{"type":"tool","tool_name":"get_order_status"}' \
--max-steps 3 | jq -r '.id')
echo "Agent: $AGENT_ID"
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: projectId,
ai_provider_id: providerId,
name: 'order-support-agent',
instructions:
'You are an order-support assistant. When the user asks about an order, call the get_order_status tool with the orderId argument, then answer using the tool result.',
tool_bindings: [{ tool_id: toolId }],
tool_choice: { type: 'tool', tool_name: 'get_order_status' },
max_steps: 3,
},
});
const agentId = 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\": \"$PROVIDER_ID\",
\"name\": \"order-support-agent\",
\"instructions\": \"You are an order-support assistant. When the user asks about an order, call the get_order_status tool with the orderId argument, then answer using the tool result.\",
\"tool_bindings\": [{\"tool_id\": \"$TOOL_ID\"}],
\"tool_choice\": {\"type\": \"tool\", \"tool_name\": \"get_order_status\"},
\"max_steps\": 3
}" | jq -r '.id')
echo "Agent: $AGENT_ID"
Step 6 — Ask about an order: the generation pauses
Start a generation the same way as for any agent. Because the model calls a client tool, the response comes back with status: "requires_action" instead of a final answer, and required_action.tool_calls lists what your app must execute — each entry has an id, the tool_name, and the model-supplied args (with your authored parameter casing intact).
- CLI
- SDK
- curl
# A small local model occasionally answers directly on the first attempt
# instead of honoring the forced tool_choice — retry a few times.
for i in $(seq 1 5); do \
GEN_RESPONSE=$(soat create-agent-generation \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"What is the status of order ord_1042?"}]'); \
[ "$(echo "$GEN_RESPONSE" | jq -r '.status')" = "requires_action" ] && break; \
sleep 1; \
done
echo "$GEN_RESPONSE" | jq '{status, required_action}'
GEN_ID=$(echo "$GEN_RESPONSE" | jq -r '.id')
TRACE_ID=$(echo "$GEN_RESPONSE" | jq -r '.trace_id')
TOOL_CALL_ID=$(echo "$GEN_RESPONSE" | jq -r '.required_action.tool_calls[0].id')
echo "$GEN_RESPONSE" | jq -e '.status == "requires_action"' > /dev/null
echo "Generation $GEN_ID paused; pending tool call: $TOOL_CALL_ID"
// A small local model occasionally answers directly on the first attempt
// instead of honoring the forced tool_choice — retry a few times.
let generation;
for (let attempt = 0; attempt < 5; attempt++) {
const { data } = await adminSoat.agents.createAgentGeneration({
path: { agent_id: agentId },
body: {
messages: [
{ role: 'user', content: 'What is the status of order ord_1042?' },
],
},
});
generation = data;
if (generation!.status === 'requires_action') break;
}
console.log(generation!.status); // "requires_action"
const toolCall = generation!.required_action!.tool_calls[0];
console.log(toolCall.tool_name, toolCall.args); // "get_order_status" { orderId: "ord_1042" }
# A small local model occasionally answers directly on the first attempt
# instead of honoring the forced tool_choice — retry a few times.
for i in $(seq 1 5); do \
GEN_RESPONSE=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"What is the status of order ord_1042?"}]}'); \
[ "$(echo "$GEN_RESPONSE" | jq -r '.status')" = "requires_action" ] && break; \
sleep 1; \
done
echo "$GEN_RESPONSE" | jq '{status, required_action}'
GEN_ID=$(echo "$GEN_RESPONSE" | jq -r '.id')
TRACE_ID=$(echo "$GEN_RESPONSE" | jq -r '.trace_id')
TOOL_CALL_ID=$(echo "$GEN_RESPONSE" | jq -r '.required_action.tool_calls[0].id')
The response looks like this:
{
"status": "requires_action",
"required_action": {
"type": "submit_tool_outputs",
"tool_calls": [
{
"id": "call_tohrsiy1",
"tool_name": "get_order_status",
"args": { "orderId": "ord_1042" }
}
]
}
}
Nothing is executing anywhere at this point. The generation is suspended server-side, waiting for your app — this is also the seam where a human can review the call before anything happens (see Approvals).
Step 7 — Execute the function in your app and submit the output
Your application now runs the real function — a query against its own order database — and posts the result back with the matching tool_call_id. The output can be any JSON value. The agent resumes its loop with the tool result in context and produces the final answer. See Tools — client for the full flow.
- CLI
- SDK
- curl
# Your app executes the function — here, a lookup in the store's database.
ORDER_RESULT='{"orderId":"ord_1042","status":"shipped","carrier":"DHL","eta":"2026-08-02"}'
# Small local models occasionally emit raw control characters in the final
# text; strip them so jq can parse the response.
FINAL_RESPONSE=$(soat submit-agent-tool-outputs \
--agent-id "$AGENT_ID" \
--generation-id "$GEN_ID" \
--tool-outputs '[{"tool_call_id":"'"$TOOL_CALL_ID"'","output":'"$ORDER_RESULT"'}]' | LC_ALL=C tr -d '\000-\037')
echo "$FINAL_RESPONSE" | jq '{status, content: .output.content}'
echo "$FINAL_RESPONSE" | jq -e '.status == "completed"' > /dev/null
// Your app executes the function — here, a lookup in the store's database.
const orderResult = {
orderId: 'ord_1042',
status: 'shipped',
carrier: 'DHL',
eta: '2026-08-02',
};
const { data: final } = await adminSoat.agents.submitAgentToolOutputs({
path: { agent_id: agentId, generation_id: generation!.id },
body: {
tool_outputs: [{ tool_call_id: toolCall.id, output: orderResult }],
},
});
console.log(final!.status); // "completed"
console.log(final!.output!.content); // "Order ord_1042 has shipped via DHL..."
ORDER_RESULT='{"orderId":"ord_1042","status":"shipped","carrier":"DHL","eta":"2026-08-02"}'
FINAL_RESPONSE=$(curl -s -X POST \
"$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate/$GEN_ID/tool-outputs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tool_outputs":[{"tool_call_id":"'"$TOOL_CALL_ID"'","output":'"$ORDER_RESULT"'}]}' | LC_ALL=C tr -d '\000-\037')
echo "$FINAL_RESPONSE" | jq '{status, content: .output.content}'
The status flips to completed and output.content holds the assistant's answer, grounded in the data your app supplied:
{
"status": "completed",
"content": "The order ord_1042 has been marked as shipped. The carrier is DHL and the delivery date is August 2, 2026."
}
If the model had requested several client calls in one step, tool_calls would contain one entry per call and you would submit all outputs in a single tool_outputs array.
Step 8 — Inspect the pause and resume in the trace
Every generation writes a trace. For a client-tool run the trace records the whole exchange — the forced tool call, your submitted output, and the final text — which makes it the fastest way to debug an integration that submits the wrong shape. The trace's step_count covers both halves of the run, and file_id points to the file holding the full serialized steps.
- CLI
- SDK
- curl
soat get-trace --trace-id "$TRACE_ID" | jq '{id, agent_id, step_count, file_id}'
const { data: trace } = await adminSoat.traces.getTrace({
path: { trace_id: generation!.trace_id },
});
console.log(trace!.id, trace!.step_count, trace!.file_id);
curl -s "$SOAT_BASE_URL/api/v1/traces/$TRACE_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{id, agent_id, step_count, file_id}'
{
"id": "trace_Yhm0QlF6MOa67Z0v",
"agent_id": "agent_AbpwfxbwiiweroDR",
"step_count": 2,
"file_id": "file_L0SMZw81UH0aZXnQ"
}
Two steps: the model call that proposed get_order_status, and the resumed call that turned your output into the answer.
Where to go next
- Sessions — the same pause-and-resume loop works in long-lived sessions:
generate-session-responsereturnsrequires_actionandsubmit-session-tool-outputsresumes it, with SOAT keeping the conversation history for you. - Gate the call before it reaches your app — attach a guardrail to classify each client call, or require human sign-off with approvals. See Gate a Dangerous Tool with Guardrails.
- Reshape the output —
output_mappingapplies a JSON Logic transform to the output your app submits before the model sees it. - Track spend per end user — attribute each session's generations to an actor and cap budgets: Cap Spend Per End User.