Skip to main content

Build an Agent Harness

A harness is the layer that decides what an agent can reach and what it is forbidden — see The Layers of an Agent System. Terminal harnesses bundle that layer with local process execution; on SOAT you compose the same layer from platform resources, and the one piece SOAT deliberately does not own — executing code on your machine — stays in your process via client tools.

In this tutorial you build a minimal file-assistant harness:

  • Reach — the agent's only capability is a read_local_file function, declared as a client tool so SOAT never executes it.
  • Forbidden — the harness process runs under a dedicated identity whose policy allows generations and nothing else; you prove the ceiling by watching a delete be refused.
  • The loop — your process drives the pause-and-resume cycle: the generation stops at requires_action, your code reads the file locally, and the agent resumes with the real content.

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).
  • CLI, SDK, or curl available. The server is at http://localhost:5047. For production hardening see Configuration.
  • Familiar with the client-tool pause-and-resume flow? If not, run Client Tools first — this tutorial builds the identity and policy shell around that loop.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role and bypasses policy evaluation entirely — see IAM — Authentication. You use it only to assemble the harness; the harness itself will run under a far smaller identity.

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

Step 2 — Create a project and an AI provider

Every resource lives inside a project. The AI provider is a local Ollama instance so the tutorial runs without external credentials. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.

PROJECT_ID=$(soat create-project --name "File Harness" | jq -r '.id')

PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')

echo "Project: $PROJECT_ID"
echo "Provider: $PROVIDER_ID"

Step 3 — Declare the reach: a client tool

The harness's local capability is declared as a client tool: a name, a description, and a JSON Schema in parameters — and deliberately no execute configuration. SOAT holds the contract and the pause point; the code that actually touches your filesystem lives only in your process. This is the whole reach of the agent — it has no other tool.

TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name read_local_file \
--type client \
--description "Reads a file from the local workspace and returns its content." \
--parameters '{"type":"object","properties":{"path":{"type":"string","description":"Workspace-relative file path, e.g. notes.txt"}},"required":["path"]}' | jq -r '.id')
echo "Tool: $TOOL_ID"

Step 4 — Create the agent

Attach the tool through tool_bindings. Three settings make the harness loop predictable:

  • step_rules { "step": 1, "tool_choice": { "type": "tool", "tool_name": "read_local_file" } } forces the first call of the turn to invoke the function. Step numbering spans the pause, so the step that runs after you submit the output is step 2 and free to answer.

    Forcing at agent level instead (tool_choice) would apply to every step of the turn, the resumed one included — the run would propose the same client tool again on each submit until max_steps ended it.

    Forcing is passed through to the provider, so it works only where the provider implements it. Ollama's OpenAI-compatible API does not support tool_choice and ignores the field, so a local Ollama agent falls back to "auto". OpenAI, Anthropic, and xAI all honor it.

  • stop_conditions { "type": "has_tool_call", "tool_name": "read_local_file" } names the call that ends the turn. It is required only when the agent's own tool_choice forces a tool — a step rule leaves the agent at "auto", so here it is documentation of the intended exit.

  • max_steps bounds the agent loop, counted across the pause: the resumed turn spends what is left of it, never a fresh budget.

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$PROVIDER_ID" \
--name file-assistant \
--instructions "You are a file assistant. When the user asks about a file, call the read_local_file tool with the path argument, then answer using the tool result." \
--tool-bindings '[{"tool_id":"'"$TOOL_ID"'"}]' \
--step-rules '[{"step":1,"tool_choice":{"type":"tool","tool_name":"read_local_file"}}]' \
--stop-conditions '[{"type":"has_tool_call","tool_name":"read_local_file"}]' \
--max-steps 3 | jq -r '.id')
echo "Agent: $AGENT_ID"

Step 5 — Forbid everything else: the harness identity

The process that runs the loop should not hold admin power. Create a dedicated user and attach a policy that allows exactly one action — agents:CreateAgentGeneration, which covers both starting a generation and submitting tool outputs — scoped to this project. See IAM for how policies evaluate.

RUNNER_ID=$(soat create-user --username harness-runner --password Runner1234! | jq -r '.id')

RUNNER_POLICY_ID=$(soat create-policy \
--name "harness-runner-policy" \
--description "Generations only, in the File Harness project" \
--document '{
"statement": [
{
"effect": "Allow",
"action": ["agents:CreateAgentGeneration"],
"resource": ["srn:'"$PROJECT_ID"':*:*"]
}
]
}' | jq -r '.id')

soat attach-user-policies --user-id "$RUNNER_ID" --policy-ids '["'"$RUNNER_POLICY_ID"'"]'

RUNNER_TOKEN=$(soat login-user --username harness-runner --password Runner1234! | jq -r '.token')

Prove the ceiling before trusting it: the runner identity cannot even delete the agent it drives.

# → 403
SOAT_TOKEN="$RUNNER_TOKEN" soat delete-agent --agent-id "$AGENT_ID"

Step 6 — Run the harness loop

This is the whole harness runtime, from your process's point of view: start a generation, and because the model calls a client tool, the response comes back paused with status: "requires_action"required_action.tool_calls lists what your process must execute. See Agents — examples for the generation call itself.

printf 'Standup is moved to 10:30 on Fridays.\n' > notes.txt

GEN_RESPONSE=$(SOAT_TOKEN="$RUNNER_TOKEN" soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"What does notes.txt say?"}]')
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"
note

Running against local Ollama and got "status": "completed" with required_action: null? That is the ignored tool_choice described in Step 4 — the model chose to answer instead of calling the function. Re-run the generation, or point the agent at a provider that honors forcing.

Nothing is executing anywhere at this point — the generation is suspended server-side. Now your process performs the local half of the harness — the read happens on your machine, under whatever OS-level confinement your process runs in — and submits the result to resume the run. See Tools — client for the full flow.

# The local execution half of the harness: read the file this process can see.
FILE_CONTENT=$(cat notes.txt)

# Small local models occasionally emit raw control characters in the final
# text; strip them so jq can parse the response.
FINAL_RESPONSE=$(SOAT_TOKEN="$RUNNER_TOKEN" soat submit-agent-tool-outputs \
--agent-id "$AGENT_ID" \
--generation-id "$GEN_ID" \
--tool-outputs '[{"tool_call_id":"'"$TOOL_CALL_ID"'","output":{"path":"notes.txt","content":"'"$FILE_CONTENT"'"}}]' | 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 answer, grounded in a file only your process could read. A production harness wraps exactly this cycle in a loop — one iteration per requires_action, submitting all pending tool_calls each time — while SOAT keeps the configuration, history, policy checks, and traces server-side.


Step 7 — Inspect the run in the trace

Every generation writes a trace recording the forced tool call, your submitted output, and the final text — the audit half of the harness, for free. Read it as admin: the runner identity cannot, which is the ceiling working as designed.

soat get-trace --trace-id "$TRACE_ID" | jq '{id, agent_id, step_count, file_id}'

Two steps: the model call that proposed read_local_file, and the resumed call that turned your output into the answer.


Where to go next

The harness you built is minimal on purpose. Each piece hardens independently:

  • Gate the tool call itself — attach a guardrail to classify each call from its actual arguments before your process sees it, and route risky ones to a human approvals queue: Gate a Dangerous Tool with Guardrails.
  • Cap the agent, not just the caller — an agent-side ceiling with boundary_policy, so even a broader caller cannot widen this agent's reach.
  • Bound the spendquotas fail closed on request, token, or cost caps: Metering and Budgets.
  • Make it conversational — the same pause-and-resume loop works in long-lived sessions, with SOAT keeping the history.
  • Ship it declaratively — define the provider, tool, agent, and policies as one formation template: Formations.