Skip to main content

Build an Agent Harness

A harness decides what an agent can reach and what it is forbidden — see The Layers of an Agent System. On SOAT it is composed from platform resources; code execution on your machine stays in your process via client tools.

You build a minimal file-assistant harness:

  • Reach — one read_local_file client tool, never executed by SOAT.
  • Forbidden — the harness runs under an identity whose policy allows generations only; a delete is refused.
  • The loop — the generation stops at requires_action, your code reads the file, the agent resumes.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

Admin (IAM — Authentication) only assembles the harness; the harness runs under a 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​

A project and a local Ollama AI provider. Other providers: 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​

A client tool is a name, a description and a JSON Schema in parameters, with no execute configuration. SOAT holds the contract and the pause point; the filesystem code lives in your process. The agent 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 loop predictable:

  • step_rules { "step": 1, "tool_choice": { "type": "tool", "tool_name": "read_local_file" } } 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": "read_local_file" } 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 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​

A dedicated user with a policy allowing one action, agents:CreateAgentGeneration (covers starting a generation and submitting tool outputs), scoped to this project. See IAM.

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

The runner identity cannot delete the agent it drives:

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

Step 6 — Run the harness loop​

Start a generation; the model calls the client tool and the response pauses with status: "requires_action", required_action.tool_calls listing what your process must execute. See Agents — examples.

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

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

The generation is suspended server-side. Your process reads the file locally and submits the result to resume the run. See Tools — client.

# 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

Status is completed and output.content holds the answer. A production harness loops this cycle, one iteration per requires_action, submitting all pending tool_calls each time.


Step 7 — Inspect the run in the trace​

Every generation writes a trace with the forced tool call, your submitted output and the final text. Read it as admin; the runner identity cannot.

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

Two steps: the call that proposed read_local_file, and the resumed call that produced the answer.


Where to go next​