Skip to main content

Execute Agent Tool Calls in Your Own App (Client Tools)

A client tool declares a function's contract; SOAT never executes it. The generation pauses with requires_action, hands the tool calls to your app, and resumes when you submit the results — the function-calling loop of the OpenAI and Anthropic APIs, with configuration, history and traces server-side.

You build an order-support agent whose get_order_status function is a client tool.

Prerequisites

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin bypasses policy evaluation — see IAM — Authentication.

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

Step 2 — Create a project

Every resource lives inside a project.

PROJECT_ID=$(soat create-project --name "Order Support" | jq -r '.id')
echo "Project: $PROJECT_ID"

Step 3 — Create an Ollama AI provider

A local Ollama AI provider. Other providers: 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, with no execute configuration. The model sees the schema as written; parameter keys come back to your app exactly as authored (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. Three settings make the loop predictable:

  • step_rules { "step": 1, "tool_choice": { "type": "tool", "tool_name": "get_order_status" } } forces the first call of the turn. Step numbering spans the pause, so the step after you submit the output is step 2 and free to answer. Agent-level tool_choice would apply to every step, the resumed one included, re-proposing the tool on each submit until max_steps.

    Forcing is passed through to the provider. Ollama's OpenAI-compatible API ignores tool_choice, so a local Ollama agent falls back to "auto"; OpenAI, Anthropic and xAI honor it.

  • stop_conditions { "type": "has_tool_call", "tool_name": "get_order_status" } names the call that ends the turn. Required only when the agent's own tool_choice forces a tool; here it documents the intended exit.

  • max_steps is counted across the pause: the resumed turn spends what is left.

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"'"}]' \
--step-rules '[{"step":1,"tool_choice":{"type":"tool","tool_name":"get_order_status"}}]' \
--stop-conditions '[{"type":"has_tool_call","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 as for any agent. The response comes back with status: "requires_action"; each required_action.tool_calls entry has an id, the tool_name and the model-supplied args.

GEN_RESPONSE=$(soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"What is the status of order ord_1042?"}]')
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"

Response:

{
"status": "requires_action",
"required_action": {
"type": "submit_tool_outputs",
"tool_calls": [
{
"id": "call_tohrsiy1",
"tool_name": "get_order_status",
"args": { "orderId": "ord_1042" }
}
]
}
}
note

"status": "completed" with required_action: null on local Ollama is the ignored tool_choice from Step 5. Re-run, or use a provider that honors forcing.

The generation is suspended server-side; this is also where a human can review the call (Approvals).


Step 7 — Execute the function in your app and submit the output

Post the result back with the matching tool_call_id; output is any JSON value. See Tools — client.

# 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

Status is completed and output.content holds the answer:

{
"status": "completed",
"content": "The order ord_1042 has been marked as shipped. The carrier is DHL and the delivery date is August 2, 2026."
}

Several client calls in one step yield one tool_calls entry each; submit all outputs in a single tool_outputs array.


Step 8 — Inspect the pause and resume in the trace

Every generation writes a trace recording the forced tool call, your submitted output and the final text. step_count covers both halves; file_id points to the file with the 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 call that proposed get_order_status, and the resumed call that produced the answer.


Where to go next