Skip to main content

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:

  1. Create a project, an AI provider, and a sonnet-writing agent.
  2. Define a workflow: triage → create_text → stanza_1 → stanza_2 → stanza_3 → stanza_4 → review → published.
  3. Wire each composing state's on_enter to dispatch the agent and route the result onward.
  4. Create a task and watch it compose itself.
  5. Send the card backward (review → stanza_4) for a new closing couplet.
  6. Guard the publish transition, close the task, read its audited history.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

Admin is the built-in superuser role (Users).

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_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.

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"

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.

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"

Step 4 — Create a task (a card)​

The task starts in the initial state, triage; its payload carries the theme.

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

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.

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

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.

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

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.

# → 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 }'

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

soat get-task-history --task-id "$TASK_ID" | 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):

soat list-tasks --project-id "$PROJECT_ID" --workflow-id "$WORKFLOW_ID" --status closed | jq -r '.data[] | "\(.title): \(.state)"'

Where to go next​