Skip to main content

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:

  1. The user asks the agent about an order.
  2. The agent pauses at requires_action with a get_order_status tool call.
  3. Your app looks the order up and submits the result.
  4. 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:11434 with model qwen2.5:0.5b pulled (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.
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.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_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.

PROJECT_ID=$(soat create-project --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.

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"

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).

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"

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 leave tool_choice at its default "auto" and let the model decide.
  • max_steps bounds the agent loop.
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"

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).

# 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"

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.

# 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

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.

soat get-trace --trace-id "$TRACE_ID" | 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-response returns requires_action and submit-session-tool-outputs resumes 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 outputoutput_mapping applies 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.