Create an Agent Squad
An agent squad is a team of agents plus the flow that coordinates them, deployed as a single Formation stack — see the Agent Squad example. This tutorial builds a small marketing content squad end to end: a researcher gathers facts, a writer and a reviewer work in parallel from those facts, a human approves the result, and only then does it "publish". You will design the squad, declare it in one formation template, validate and deploy it, run it through the human approval, add a member, and tear it down.
Prerequisites
- SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
- New to SOAT? Read Key Concepts to understand projects, agents, and orchestrations before diving in.
- CLI installed and configured, or SDK set up. See CLI or SDK.
- For production hardening (secrets, env vars), see Configuration.
- Ollama running locally with
qwen2.5:0.5bpulled (ollama pull qwen2.5:0.5b). - Server is at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { createConfig, SoatClient } from '@soat/sdk';
const config = createConfig({ baseUrl: 'http://localhost:5047', auth: '' });
const adminSoat = new SoatClient(config);
export SOAT_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser. See Users for full authentication details.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const { data: session } = await adminSoat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const authClient = new SoatClient(
createConfig({ baseUrl: 'http://localhost:5047', auth: session.token })
);
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
All resources are scoped to a project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name 'Content Squad' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await authClient.projects.createProject({
body: { name: 'Content Squad' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Content Squad"}' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
Step 3 — Design the squad
The squad has three agents plus a human checkpoint, wired together by one orchestration:
| Node | Type | Role |
|---|---|---|
research | agent | Gathers 3 short bullet facts about the topic |
write | agent | Drafts a two-sentence marketing blurb from the research (runs in parallel with review) |
review | agent | Writes one sentence of style guidance from the same research (runs in parallel with write) |
approve | human | Pauses the run so a person can approve the draft before it "publishes" |
publish | transform | Combines the approved draft into the final artifact |
research fans out to write and review, which fan back in at approve using an activation group — approve only activates once both finish. Because an orchestration is a formation resource type, the agents and the orchestration are declared and deployed together in the next step.
Step 4 — Write the formation template
A formation template is a JSON object with a resources map and an outputs map. ContentSquad's nodes reference Researcher, Writer, and Reviewer with { "ref": "LogicalId" } — SOAT resolves each to its physical agent_... ID before creating the orchestration, in dependency order. See Ref Expressions.
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
cat > squad.json << 'EOF'
{
"resources": {
"Provider": {
"type": "ai_provider",
"properties": {
"name": "Squad Ollama",
"provider": "ollama",
"default_model": "qwen2.5:0.5b"
}
},
"Researcher": {
"type": "agent",
"properties": {
"name": "Researcher",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a research assistant. Given a topic, reply with exactly 3 short bullet facts. Never ask follow-up questions.",
"max_steps": 3
}
},
"Writer": {
"type": "agent",
"properties": {
"name": "Writer",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a marketing writer. Given research notes, write a two-sentence marketing blurb. Never ask follow-up questions.",
"max_steps": 3
}
},
"Reviewer": {
"type": "agent",
"properties": {
"name": "Reviewer",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are an editor. Given research notes, write one sentence of style guidance for the writer. Never ask follow-up questions.",
"max_steps": 3
}
},
"ContentSquad": {
"type": "orchestration",
"properties": {
"name": "marketing-content-squad",
"input_schema": {
"type": "object",
"properties": { "topic": { "type": "string" } },
"required": ["topic"]
},
"nodes": [
{
"id": "research",
"type": "agent",
"agent_id": { "ref": "Researcher" },
"input_mapping": { "prompt": { "var": "input.topic" } },
"state_mapping":{"state.research":{"var":"output.content"}}
},
{
"id": "write",
"type": "agent",
"agent_id": { "ref": "Writer" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.draft":{"var":"output.content"}}
},
{
"id": "review",
"type": "agent",
"agent_id": { "ref": "Reviewer" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.notes":{"var":"output.content"}}
},
{
"id": "approve",
"type": "human",
"prompt": "Approve the draft for publishing?",
"options": ["approve", "reject"],
"input_mapping": { "draft": { "var": "draft" }, "notes": { "var": "notes" } },
"state_mapping":{"state.approved":{"var":"output.approved"}}
},
{
"id": "publish",
"type": "transform",
"expression": { "cat": ["Published: ", { "var": "draft" }] },
"state_mapping":{"state.published":{"var":"output.result"}}
}
],
"edges": [
{ "from": "research", "to": "write" },
{ "from": "research", "to": "review" },
{ "from": "write", "to": "approve", "activation_group": "join", "activation_condition": "all" },
{ "from": "review", "to": "approve", "activation_group": "join", "activation_condition": "all" },
{ "from": "approve", "to": "publish" }
]
}
}
},
"outputs": {
"squad_id": { "ref": "ContentSquad" }
}
}
EOF
TEMPLATE=$(cat squad.json)
const template = {
resources: {
Provider: {
type: 'ai_provider',
properties: { name: 'Squad Ollama', provider: 'ollama', default_model: 'qwen2.5:0.5b' },
},
Researcher: {
type: 'agent',
properties: {
name: 'Researcher',
ai_provider_id: { ref: 'Provider' },
instructions:
'You are a research assistant. Given a topic, reply with exactly 3 short bullet facts. Never ask follow-up questions.',
max_steps: 3,
},
},
Writer: {
type: 'agent',
properties: {
name: 'Writer',
ai_provider_id: { ref: 'Provider' },
instructions:
'You are a marketing writer. Given research notes, write a two-sentence marketing blurb. Never ask follow-up questions.',
max_steps: 3,
},
},
Reviewer: {
type: 'agent',
properties: {
name: 'Reviewer',
ai_provider_id: { ref: 'Provider' },
instructions:
'You are an editor. Given research notes, write one sentence of style guidance for the writer. Never ask follow-up questions.',
max_steps: 3,
},
},
ContentSquad: {
type: 'orchestration',
properties: {
name: 'marketing-content-squad',
input_schema: {
type: 'object',
properties: { topic: { type: 'string' } },
required: ['topic'],
},
nodes: [
{
id: 'research',
type: 'agent',
agent_id: { ref: 'Researcher' },
input_mapping: { prompt: { var: 'input.topic' } },
state_mapping: { 'state.research': { var: 'output.content' } },
},
{
id: 'write',
type: 'agent',
agent_id: { ref: 'Writer' },
input_mapping: { prompt: { var: 'research' } },
state_mapping: { 'state.draft': { var: 'output.content' } },
},
{
id: 'review',
type: 'agent',
agent_id: { ref: 'Reviewer' },
input_mapping: { prompt: { var: 'research' } },
state_mapping: { 'state.notes': { var: 'output.content' } },
},
{
id: 'approve',
type: 'human',
prompt: 'Approve the draft for publishing?',
options: ['approve', 'reject'],
input_mapping: { draft: { var: 'draft' }, notes: { var: 'notes' } },
state_mapping: { 'state.approved': { var: 'output.approved' } },
},
{
id: 'publish',
type: 'transform',
expression: { cat: ['Published: ', { var: 'draft' }] },
state_mapping: { 'state.published': { var: 'output.result' } },
},
],
edges: [
{ from: 'research', to: 'write' },
{ from: 'research', to: 'review' },
{ from: 'write', to: 'approve', activation_group: 'join', activation_condition: 'all' },
{ from: 'review', to: 'approve', activation_group: 'join', activation_condition: 'all' },
{ from: 'approve', to: 'publish' },
],
},
},
},
outputs: { squad_id: { ref: 'ContentSquad' } },
};
cat > squad.json << 'EOF'
{
"resources": {
"Provider": {
"type": "ai_provider",
"properties": { "name": "Squad Ollama", "provider": "ollama", "default_model": "qwen2.5:0.5b" }
},
"Researcher": {
"type": "agent",
"properties": {
"name": "Researcher",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a research assistant. Given a topic, reply with exactly 3 short bullet facts. Never ask follow-up questions.",
"max_steps": 3
}
},
"Writer": {
"type": "agent",
"properties": {
"name": "Writer",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a marketing writer. Given research notes, write a two-sentence marketing blurb. Never ask follow-up questions.",
"max_steps": 3
}
},
"Reviewer": {
"type": "agent",
"properties": {
"name": "Reviewer",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are an editor. Given research notes, write one sentence of style guidance for the writer. Never ask follow-up questions.",
"max_steps": 3
}
},
"ContentSquad": {
"type": "orchestration",
"properties": {
"name": "marketing-content-squad",
"input_schema": {
"type": "object",
"properties": { "topic": { "type": "string" } },
"required": ["topic"]
},
"nodes": [
{
"id": "research",
"type": "agent",
"agent_id": { "ref": "Researcher" },
"input_mapping": { "prompt": { "var": "input.topic" } },
"state_mapping":{"state.research":{"var":"output.content"}}
},
{
"id": "write",
"type": "agent",
"agent_id": { "ref": "Writer" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.draft":{"var":"output.content"}}
},
{
"id": "review",
"type": "agent",
"agent_id": { "ref": "Reviewer" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.notes":{"var":"output.content"}}
},
{
"id": "approve",
"type": "human",
"prompt": "Approve the draft for publishing?",
"options": ["approve", "reject"],
"input_mapping": { "draft": { "var": "draft" }, "notes": { "var": "notes" } },
"state_mapping":{"state.approved":{"var":"output.approved"}}
},
{
"id": "publish",
"type": "transform",
"expression": { "cat": ["Published: ", { "var": "draft" }] },
"state_mapping":{"state.published":{"var":"output.result"}}
}
],
"edges": [
{ "from": "research", "to": "write" },
{ "from": "research", "to": "review" },
{ "from": "write", "to": "approve", "activation_group": "join", "activation_condition": "all" },
{ "from": "review", "to": "approve", "activation_group": "join", "activation_condition": "all" },
{ "from": "approve", "to": "publish" }
]
}
}
},
"outputs": {
"squad_id": { "ref": "ContentSquad" }
}
}
EOF
TEMPLATE=$(cat squad.json)
Step 5 — Validate and preview the template
Validate the template's structure, then preview what will be created. See Formations — Key Concepts.
- CLI
- SDK
- curl
soat validate-formation --template "$TEMPLATE"
Expected output:
{ "valid": true }
soat plan-formation --project-id "$PROJECT_ID" --template "$TEMPLATE" | jq '.'
Expected output — 5 resources all marked as create:
[
{ "action": "create", "logical_id": "Provider", "type": "ai_provider" },
{ "action": "create", "logical_id": "Researcher", "type": "agent" },
{ "action": "create", "logical_id": "Writer", "type": "agent" },
{ "action": "create", "logical_id": "Reviewer", "type": "agent" },
{ "action": "create", "logical_id": "ContentSquad", "type": "orchestration" }
]
const { data: validation } = await authClient.formations.validateFormation({
body: { template },
});
console.log('Valid:', validation.valid);
const { data: plan } = await authClient.formations.planFormation({
body: { project_id: PROJECT_ID, template },
});
for (const change of plan) {
console.log(`${change.action.padEnd(8)} ${change.logical_id} (${change.type})`);
}
curl -s -X POST "$SOAT_URL/api/v1/formations/validate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"template\": $TEMPLATE}" | jq '.'
curl -s -X POST "$SOAT_URL/api/v1/formations/plan" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\": \"$PROJECT_ID\", \"template\": $TEMPLATE}" | jq '.'
Step 6 — Deploy the squad
Deploy the formation. SOAT provisions the provider, all three agents, and the orchestration in dependency order, and resolves every ref expression. See Formations — Examples.
- CLI
- SDK
- curl
FORMATION=$(soat create-formation \
--project-id "$PROJECT_ID" \
--name "content-squad" \
--template "$TEMPLATE")
FORMATION_ID=$(printf '%s' "$FORMATION" | jq -r '.id')
SQUAD_ID=$(printf '%s' "$FORMATION" | jq -r '.outputs.squad_id')
echo "FORMATION_ID: $FORMATION_ID"
echo "SQUAD_ID: $SQUAD_ID"
const { data: formation } = await authClient.formations.createFormation({
body: { project_id: PROJECT_ID, name: 'content-squad', template },
});
const FORMATION_ID = formation.id;
const SQUAD_ID = formation.outputs?.squad_id as string;
FORMATION=$(curl -s -X POST "$SOAT_URL/api/v1/formations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\": \"$PROJECT_ID\", \"name\": \"content-squad\", \"template\": $TEMPLATE}")
FORMATION_ID=$(printf '%s' "$FORMATION" | jq -r '.id')
SQUAD_ID=$(printf '%s' "$FORMATION" | jq -r '.outputs.squad_id')
Step 7 — Run the squad
Start a run against the squad_id output, passing { "topic": "..." } as input. research runs first, then write and review run in parallel, then the run pauses at the approve human node.
- CLI
- SDK
- curl
RUN=$(soat start-orchestration-run \
--orchestration-id "$SQUAD_ID" \
--input '{"topic": "the launch of a new project management app"}' \
--wait)
RUN_ID=$(printf '%s' "$RUN" | jq -r '.id')
printf '%s\n' "$RUN" | jq '{status, required_action}'
Expected output — the run pauses at approve:
{
"status": "awaiting_input",
"required_action": {
"type": "human_input",
"node_id": "approve",
"prompt": "Approve the draft for publishing?",
"options": ["approve", "reject"],
"context": { "draft": "...", "notes": "..." }
}
}
const { data: run } = await authClient.orchestrations.startOrchestrationRun({
body: {
orchestration_id: SQUAD_ID,
input: { topic: 'the launch of a new project management app' },
wait: true,
},
});
const RUN_ID = run.id;
console.log('Status:', run.status);
console.log('Required action:', run.required_action);
RUN=$(curl -s -X POST "$SOAT_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"orchestration_id": "'"$SQUAD_ID"'", "input": {"topic": "the launch of a new project management app"}, "wait": true}')
RUN_ID=$(printf '%s' "$RUN" | jq -r '.id')
printf '%s\n' "$RUN" | jq '{status, required_action}'
Step 8 — Resume the human approval node
Submit the human reviewer's decision with submit-human-input, naming the paused node_id. The run resumes and runs publish.
- CLI
- SDK
- curl
RESULT=$(soat submit-human-input \
--orchestration-run-id "$RUN_ID" \
--node-id "approve" \
--output '{"approved": true}')
printf '%s\n' "$RESULT" | jq '{status, state}'
Expected output:
{
"status": "succeeded",
"state": {
"research": "...",
"draft": "...",
"notes": "...",
"approved": true,
"published": "Published: ..."
}
}
const { data: resumed } = await authClient.orchestrations.submitHumanInput({
path: { orchestration_run_id: RUN_ID },
body: { node_id: 'approve', output: { approved: true } },
});
console.log('Status:', resumed.status);
console.log('Published:', resumed.state.published);
curl -s -X POST "$SOAT_URL/api/v1/orchestration-runs/$RUN_ID/human-input" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"node_id": "approve", "output": {"approved": true}}' | jq '{status, state}'
Inspect the per-node executions to see who did what:
- CLI
- SDK
- curl
soat get-orchestration-run --orchestration-run-id "$RUN_ID" | jq '.node_executions[] | {node_id, node_type, status}'
const { data: finished } = await authClient.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: RUN_ID },
});
for (const exec of finished.node_executions) {
console.log(exec.node_id, exec.node_type, exec.status);
}
curl -s "$SOAT_URL/api/v1/orchestration-runs/$RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.node_executions[] | {node_id, node_type, status}'
Step 9 — Add a squad member and redeploy
Add a Proofreader agent that also runs in parallel with write and review, feeding the same approve fan-in. Update the template's resources and the approve node's edges, then redeploy — SOAT diffs the new template against the current stack and applies only the required changes. See Formations — Update a formation.
- CLI
- SDK
- curl
UPDATED_TEMPLATE=$(printf '%s' "$TEMPLATE" | jq '
.resources.Proofreader = {
"type": "agent",
"properties": {
"name": "Proofreader",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a proofreader. Given research notes, list any factual claims that need a citation. Never ask follow-up questions.",
"max_steps": 3
}
} |
.resources.ContentSquad.properties.nodes += [{
"id": "proofread",
"type": "agent",
"agent_id": { "ref": "Proofreader" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.citationNotes":{"var":"output.content"}}
}] |
.resources.ContentSquad.properties.edges += [
{ "from": "research", "to": "proofread" },
{ "from": "proofread", "to": "approve", "activation_group": "join", "activation_condition": "all" }
]
')
soat plan-formation --formation-id "$FORMATION_ID" --template "$UPDATED_TEMPLATE" | jq '.'
soat update-formation \
--formation-id "$FORMATION_ID" \
--template "$UPDATED_TEMPLATE" | jq '{id, status}'
Expected plan — one new resource, one updated resource:
[
{ "action": "create", "logical_id": "Proofreader", "type": "agent" },
{ "action": "update", "logical_id": "ContentSquad", "type": "orchestration" }
]
const updatedTemplate = JSON.parse(JSON.stringify(template));
updatedTemplate.resources.Proofreader = {
type: 'agent',
properties: {
name: 'Proofreader',
ai_provider_id: { ref: 'Provider' },
instructions:
'You are a proofreader. Given research notes, list any factual claims that need a citation. Never ask follow-up questions.',
max_steps: 3,
},
};
updatedTemplate.resources.ContentSquad.properties.nodes.push({
id: 'proofread',
type: 'agent',
agent_id: { ref: 'Proofreader' },
input_mapping: { prompt: { var: 'research' } },
state_mapping: { 'state.citationNotes': { var: 'output.content' } },
});
updatedTemplate.resources.ContentSquad.properties.edges.push(
{ from: 'research', to: 'proofread' },
{ from: 'proofread', to: 'approve', activation_group: 'join', activation_condition: 'all' }
);
const { data: updated } = await authClient.formations.updateFormation({
path: { formation_id: FORMATION_ID },
body: { template: updatedTemplate },
});
console.log('Status:', updated.status);
UPDATED_TEMPLATE=$(printf '%s' "$TEMPLATE" | jq '
.resources.Proofreader = {
"type": "agent",
"properties": {
"name": "Proofreader",
"ai_provider_id": { "ref": "Provider" },
"instructions": "You are a proofreader. Given research notes, list any factual claims that need a citation. Never ask follow-up questions.",
"max_steps": 3
}
} |
.resources.ContentSquad.properties.nodes += [{
"id": "proofread",
"type": "agent",
"agent_id": { "ref": "Proofreader" },
"input_mapping": { "prompt": { "var": "research" } },
"state_mapping":{"state.citationNotes":{"var":"output.content"}}
}] |
.resources.ContentSquad.properties.edges += [
{ "from": "research", "to": "proofread" },
{ "from": "proofread", "to": "approve", "activation_group": "join", "activation_condition": "all" }
]
')
curl -s -X PUT "$SOAT_URL/api/v1/formations/$FORMATION_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"template\": $UPDATED_TEMPLATE}" | jq '{id, status}'
Step 10 — Tear down
Deleting a formation removes managed resources in reverse dependency order — but not resources the platform guards on their own. The run above put every squad member to work, so each agent now has generation history and teardown stops rather than destroying it implicitly: the delete fails 409 FORMATION_DELETE_FAILED with the blocking agents named in error.meta.failures, leaving the stack in delete_failed. See Formations — Resource Lifecycle.
- CLI
- SDK
- curl
# → expect-fail
soat delete-formation --formation-id "$FORMATION_ID"
soat get-formation --formation-id "$FORMATION_ID" | jq '{id, status}'
try {
await authClient.formations.deleteFormation({
path: { formation_id: FORMATION_ID },
});
} catch (error) {
// 409 FORMATION_DELETE_FAILED — meta.failures names each blocking agent.
console.log('blocked by:', error);
}
curl -s -X DELETE "$SOAT_URL/api/v1/formations/$FORMATION_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.error.meta.failures'
To finish the teardown, discard each agent's history explicitly
(soat delete-agent --agent-id "$AGENT_ID" --force true) and delete the formation
again — or declare the agents with deletion_policy: retain so the stack leaves them
standing.