Write a Sonnet with a Workflow
Orchestrate a Sonnet builds a DAG that runs forward and terminates. This tutorial builds the same sonnet as a workflow a task lives in: a card that moves through named states, advanced by an agent, reviewed by a human, and able to move backward for a revision. One state per stanza dispatches the agent to append the next quatrain, handing the poem-so-far forward through the task payload.
You will:
- Create a project, an AI provider, and a sonnet-writing agent.
- Define a workflow:
triage → create_text → stanza_1 → stanza_2 → stanza_3 → stanza_4 → review → published. - Wire each composing state's
on_enterto dispatch the agent and route the result onward. - Create a task and watch it compose itself.
- Send the card backward (
review → stanza_4) for a new closing couplet. - Guard the publish transition, close the task, read its audited history.
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start). - Key Concepts if new to SOAT.
- CLI or SDK set up.
- Configuration for production hardening.
- Ollama running locally with a chat model (or another third-party LLM).
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser role (Users).
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project, provider, and agent
The agent writes a short sonnet from a theme; the workflow calls it, not the other way around. For xAI, OpenAI, Anthropic, or Amazon Bedrock instead of Ollama, see Connect Third-Party LLMs.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Sonnet Workflow" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider ollama \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--name "Sonnet Writer" \
--ai-provider-id "$AI_PROVIDER_ID" \
--instructions "You are a poet. Given a theme, write a short sonnet about it. Reply with only the poem." | jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Sonnet Workflow' },
});
const PROJECT_ID = project.id;
const { data: aiProvider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const AI_PROVIDER_ID = aiProvider.id;
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: PROJECT_ID,
name: 'Sonnet Writer',
ai_provider_id: AI_PROVIDER_ID,
instructions:
'You are a poet. Given a theme, write a short sonnet about it. Reply with only the poem.',
},
});
const AGENT_ID = agent.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Sonnet Workflow"}' | jq -r '.id')
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","name":"Local Ollama","provider":"ollama","default_model":"qwen2.5:0.5b"}' | jq -r '.id')
AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","name":"Sonnet Writer","ai_provider_id":"'"$AI_PROVIDER_ID"'","instructions":"You are a poet. Given a theme, write a short sonnet about it. Reply with only the poem."}' | jq -r '.id')
Step 3 — Define the workflow
Eight states. The composing states (create_text, stanza_1…stanza_4) carry
on_enter automation: entering one dispatches the agent, its output lands in
task.last_result, and on_complete routes the card onward
(Workflows & Tasks). Each declares
on_failure: abandon_to_review, so a failed generation lands in front of a human.
review is a human state (the card parks until a person acts). published is
terminal (entering it closes the task). The publish transition carries a
guard: payload.approved must be true.
- CLI
- SDK
- curl
STATES='[
{ "name": "triage", "initial": true },
{ "name": "create_text",
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "'"$AGENT_ID"'",
"input_mapping": {
"prompt": { "cat": ["In two sentences, sketch the imagery and argument for a sonnet about ", { "var": "task.payload.theme" }, ". Reply with only the plan."] }
}
},
"on_complete": [ { "when": true, "transition": "to_stanza_1" } ],
"on_failure": "abandon_to_review"
}
},
{ "name": "stanza_1",
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "'"$AGENT_ID"'",
"input_mapping": {
"prompt": { "cat": ["Plan: ", { "var": "task.last_result.content" }, "\nWrite the FIRST quatrain (4 lines) of a sonnet about ", { "var": "task.payload.theme" }, ". Reply with only those 4 lines."] }
}
},
"on_complete": [ { "when": true, "transition": "to_stanza_2" } ],
"on_failure": "abandon_to_review"
}
},
{ "name": "stanza_2",
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "'"$AGENT_ID"'",
"input_mapping": {
"prompt": { "cat": ["Sonnet so far:\n", { "var": "task.last_result.content" }, "\nAppend the SECOND quatrain (4 more lines). Reply with the complete poem so far, nothing else."] }
}
},
"on_complete": [ { "when": true, "transition": "to_stanza_3" } ],
"on_failure": "abandon_to_review"
}
},
{ "name": "stanza_3",
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "'"$AGENT_ID"'",
"input_mapping": {
"prompt": { "cat": ["Sonnet so far:\n", { "var": "task.last_result.content" }, "\nAppend the THIRD quatrain (4 more lines). Reply with the complete poem so far, nothing else."] }
}
},
"on_complete": [ { "when": true, "transition": "to_stanza_4" } ],
"on_failure": "abandon_to_review"
}
},
{ "name": "stanza_4",
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "'"$AGENT_ID"'",
"input_mapping": {
"prompt": { "cat": ["Sonnet so far:\n", { "var": "task.last_result.content" }, "\nWrite the closing COUPLET (2 final lines), replacing any couplet already there. Reply with the complete 14-line poem, nothing else."] }
}
},
"on_complete": [ { "when": true, "transition": "to_review" } ],
"on_failure": "abandon_to_review"
}
},
{ "name": "review", "kind": "human" },
{ "name": "published", "terminal": true }
]'
TRANSITIONS='[
{ "name": "start", "from": ["triage"], "to": "create_text" },
{ "name": "to_stanza_1", "from": ["create_text"], "to": "stanza_1" },
{ "name": "to_stanza_2", "from": ["stanza_1"], "to": "stanza_2" },
{ "name": "to_stanza_3", "from": ["stanza_2"], "to": "stanza_3" },
{ "name": "to_stanza_4", "from": ["stanza_3"], "to": "stanza_4" },
{ "name": "to_review", "from": ["stanza_4"], "to": "review" },
{ "name": "abandon_to_review",
"from": ["create_text", "stanza_1", "stanza_2", "stanza_3", "stanza_4"],
"to": "review" },
{ "name": "revise", "from": ["review"], "to": "stanza_4" },
{ "name": "publish", "from": ["review"], "to": "published",
"guard": { "==": [{ "var": "task.payload.approved" }, true] } }
]'
WORKFLOW_ID=$(soat create-workflow \
--project-id "$PROJECT_ID" \
--name "Sonnet Pipeline" \
--description "A sonnet card composed stanza by stanza by an agent, reviewed by a human, guarded publish." \
--states "$STATES" \
--transitions "$TRANSITIONS" \
--payload-schema '{"properties":{"theme":{"type":"string"}}}' | jq -r '.id')
echo "WORKFLOW_ID: $WORKFLOW_ID"
const dispatchState = (args: {
name: string;
prompt: unknown;
next: string;
}) => {
return {
name: args.name,
on_enter: {
dispatch: {
kind: 'agent',
agent_id: AGENT_ID,
input_mapping: { prompt: args.prompt },
},
on_complete: [{ when: true, transition: args.next }],
on_failure: 'abandon_to_review',
},
};
};
const { data: workflow } = await adminSoat.workflows.createWorkflow({
body: {
project_id: PROJECT_ID,
name: 'Sonnet Pipeline',
description:
'A sonnet card composed stanza by stanza by an agent, reviewed by a human, guarded publish.',
states: [
{ name: 'triage', initial: true },
dispatchState({
name: 'create_text',
next: 'to_stanza_1',
prompt: {
cat: [
'In two sentences, sketch the imagery and argument for a sonnet about ',
{ var: 'task.payload.theme' },
'. Reply with only the plan.',
],
},
}),
dispatchState({
name: 'stanza_1',
next: 'to_stanza_2',
prompt: {
cat: [
'Plan: ',
{ var: 'task.last_result.content' },
'\nWrite the FIRST quatrain (4 lines) of a sonnet about ',
{ var: 'task.payload.theme' },
'. Reply with only those 4 lines.',
],
},
}),
dispatchState({
name: 'stanza_2',
next: 'to_stanza_3',
prompt: {
cat: [
'Sonnet so far:\n',
{ var: 'task.last_result.content' },
'\nAppend the SECOND quatrain (4 more lines). Reply with the complete poem so far, nothing else.',
],
},
}),
dispatchState({
name: 'stanza_3',
next: 'to_stanza_4',
prompt: {
cat: [
'Sonnet so far:\n',
{ var: 'task.last_result.content' },
'\nAppend the THIRD quatrain (4 more lines). Reply with the complete poem so far, nothing else.',
],
},
}),
dispatchState({
name: 'stanza_4',
next: 'to_review',
prompt: {
cat: [
'Sonnet so far:\n',
{ var: 'task.last_result.content' },
'\nWrite the closing COUPLET (2 final lines), replacing any couplet already there. Reply with the complete 14-line poem, nothing else.',
],
},
}),
{ name: 'review', kind: 'human' },
{ name: 'published', terminal: true },
],
transitions: [
{ name: 'start', from: ['triage'], to: 'create_text' },
{ name: 'to_stanza_1', from: ['create_text'], to: 'stanza_1' },
{ name: 'to_stanza_2', from: ['stanza_1'], to: 'stanza_2' },
{ name: 'to_stanza_3', from: ['stanza_2'], to: 'stanza_3' },
{ name: 'to_stanza_4', from: ['stanza_3'], to: 'stanza_4' },
{ name: 'to_review', from: ['stanza_4'], to: 'review' },
{
name: 'abandon_to_review',
from: ['create_text', 'stanza_1', 'stanza_2', 'stanza_3', 'stanza_4'],
to: 'review',
},
{ name: 'revise', from: ['review'], to: 'stanza_4' },
{
name: 'publish',
from: ['review'],
to: 'published',
guard: { '==': [{ var: 'task.payload.approved' }, true] },
},
],
payload_schema: { properties: { theme: { type: 'string' } } },
},
});
const WORKFLOW_ID = workflow.id;
# The states/transitions JSON is identical to the CLI tab; keep it in shell
# variables so the request body stays readable.
WORKFLOW_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/workflows" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","name":"Sonnet Pipeline","states":'"$STATES"',"transitions":'"$TRANSITIONS"',"payload_schema":{"properties":{"theme":{"type":"string"}}}}' \
| jq -r '.id')
Step 4 — Create a task (a card)
The task starts in the initial state,
triage; its payload carries the theme.
- CLI
- SDK
- curl
TASK_ID=$(soat create-task \
--project-id "$PROJECT_ID" \
--workflow-id "$WORKFLOW_ID" \
--title "Sonnet: the sea" \
--payload '{"theme":"the sea"}' | jq -r '.id')
echo "TASK_ID: $TASK_ID"
soat get-task --task-id "$TASK_ID" | jq '{ state, status }'
const { data: task } = await adminSoat.tasks.createTask({
body: {
project_id: PROJECT_ID,
workflow_id: WORKFLOW_ID,
title: 'Sonnet: the sea',
payload: { theme: 'the sea' },
},
});
const TASK_ID = task.id;
const { data: created } = await adminSoat.tasks.getTask({
path: { task_id: TASK_ID },
});
console.log(created.state, created.status); // triage open
TASK_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","workflow_id":"'"$WORKFLOW_ID"'","title":"Sonnet: the sea","payload":{"theme":"the sea"}}' | jq -r '.id')
curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ADMIN_TOKEN" | jq '{ state, status }'
The card is open in triage.
Step 5 — Advance the card; the agent composes the sonnet
Firing start moves the card into create_text; from there each state's
on_complete rule fires the next transition as the automation principal.
During a generation the card shows automation_status: running; the poem-so-far
accumulates in last_result until the card lands in review
(Per-state automation).
Five generations run back to back, so poll until .state is review. The
# → retry 480 budget (about a second per attempt, roughly ten minutes) covers
the 303s the slowest CI sandbox run took on a CPU-only model sharing the box
with the previous tutorial's leftovers.
- CLI
- SDK
- curl
soat transition-task --task-id "$TASK_ID" --transition start | jq '{ state, automation_status }'
# → retry 480
soat get-task --task-id "$TASK_ID" | jq -e '.state == "review"'
soat get-task --task-id "$TASK_ID" | jq '{ state, status, sonnet: .last_result.content }'
await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'start' },
});
const waitForState = async (target: string) => {
// ~8 minutes: the five sandbox generations measured 303s end to end.
for (let attempt = 0; attempt < 480; attempt += 1) {
const { data: current } = await adminSoat.tasks.getTask({
path: { task_id: TASK_ID },
});
if (current.state === target) return current;
await new Promise((resolve) => {
return setTimeout(resolve, 1000);
});
}
throw new Error(`Task never reached ${target}`);
};
const reviewed = await waitForState('review');
console.log(reviewed.last_result?.content);
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"transition":"start"}' | jq '{ state, automation_status }'
# Poll until the card finishes composing all four stanzas and parks in review.
until curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -e '.state == "review"' > /dev/null; do sleep 1; done
curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{ state, status, sonnet: .last_result.content }'
The card is in review, holding the full sonnet, with no application-side state.
Step 6 — Send it backward for a revision
review → stanza_4 is a backward move, the cycle a DAG rejects. Firing revise
re-enters stanza_4 for a new closing couplet, then routes back to review
through the same to_review transition: one generation, not five. See
Workflows & Tasks.
- CLI
- SDK
- curl
soat transition-task --task-id "$TASK_ID" --transition revise --note "tighten the closing couplet" | jq '{ state }'
# → retry 240
soat get-task --task-id "$TASK_ID" | jq -e '.state == "review"'
await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'revise', note: 'tighten the closing couplet' },
});
const revised = await waitForState('review');
console.log(revised.last_result?.content);
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"transition":"revise","note":"tighten the closing couplet"}' | jq '{ state }'
until curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -e '.state == "review"' > /dev/null; do sleep 1; done
Step 7 — Guarded publish
Firing publish before payload.approved == true is rejected
(TASK_GUARD_REJECTED) with no state change. Approve via a payload patch, then
publish; entering the terminal state closes the task.
PATCH /tasks/{task_id} shallow-merges, so
setting approved alone keeps the sonnet in last_result.
- CLI
- SDK
- curl
# → 400
soat transition-task --task-id "$TASK_ID" --transition publish
soat update-task --task-id "$TASK_ID" --payload '{"approved":true}' | jq '{ approved: .payload.approved, sonnet_kept: (.last_result.content != null) }'
soat transition-task --task-id "$TASK_ID" --transition publish | jq '{ state, status }'
try {
await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'publish' },
});
} catch (error) {
console.log('Rejected by the guard (approved is not set yet):', error);
}
await adminSoat.tasks.updateTask({
path: { task_id: TASK_ID },
body: { payload: { approved: true } },
});
const { data: published } = await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'publish' },
});
console.log(published.state, published.status); // published closed
# Rejected by the guard (approved is not set yet).
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"transition":"publish"}'
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"payload":{"approved":true}}' | jq '{ approved: .payload.approved, sonnet_kept: (.last_result.content != null) }'
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"transition":"publish"}' | jq '{ state, status }'
The card is published and closed.
Step 8 — Read the audited history
Every move (start, the automated to_stanza_1…to_review chain, revise,
publish) is one append-only record; automation-driven moves carry their
generation_id (Transition history).
- CLI
- SDK
- curl
soat get-task-history --task-id "$TASK_ID" | jq -r '.data[] | "\(.from_state // "∅") → \(.to_state) [\(.principal_kind)] \(.transition // "(initial)")"'
const { data: page } = await adminSoat.tasks.getTaskHistory({
path: { task_id: TASK_ID },
});
for (const entry of page?.data ?? []) {
console.log(
`${entry.from_state ?? '∅'} → ${entry.to_state} [${entry.principal_kind}] ${entry.transition ?? '(initial)'}`
);
}
curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/history" -H "Authorization: Bearer $ADMIN_TOKEN" \
| jq -r '.data[] | "\(.from_state // "∅") → \(.to_state) [\(.principal_kind)] \(.transition // "(initial)")"'
The board query
States are kanban columns and tasks are cards; one query renders a column (Tasks):
- CLI
- SDK
- curl
soat list-tasks --project-id "$PROJECT_ID" --workflow-id "$WORKFLOW_ID" --status closed | jq -r '.data[] | "\(.title): \(.state)"'
const { data: board } = await adminSoat.tasks.listTasks({
query: {
project_id: PROJECT_ID,
workflow_id: WORKFLOW_ID,
status: 'closed',
},
});
for (const card of board.data) {
console.log(`${card.title}: ${card.state}`);
}
curl -s "$SOAT_BASE_URL/api/v1/tasks?project_id=$PROJECT_ID&workflow_id=$WORKFLOW_ID&status=closed" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.data[] | "\(.title): \(.state)"'
Where to go next
- Workflows & Tasks — data model, guards, automation.
- Orchestrate a Sonnet — the same poem as a DAG.