Skip to main content

Write a Sonnet with a Workflow

The Orchestrate a Sonnet tutorial 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, that an agent advances on its own, that a human reviews, and that can move backward for a revision — the case a DAG rejects by design. The poem is composed one stanza at a time — a state per stanza, each dispatching 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, feed it the poem-so-far, and route the result to the next stanza.
  4. Create a task and watch the card compose itself stanza by stanza.
  5. Send the card backward (review → stanza_4) for a fresh closing couplet — the cycle a DAG rejects.
  6. Guard the publish transition, then close the task and read its full audited history.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide.
  • New to SOAT? Read Key Concepts for projects, agents, and tasks.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • For production hardening (secrets, env vars), see Configuration.
  • Server at http://localhost:5047.
  • Ollama running locally with a chat model available (or another third-party LLM).
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for authentication details.

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 does one job: given a theme, write a short sonnet. It is a normal agent — the workflow will call it, not the other way around.

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.

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 model the card's life. The composing states — create_text and stanza_1stanza_4 — each carry on_enter automation: entering one dispatches the agent, whose output lands in task.last_result, and on_complete routes the card onward (see Workflows & Tasks for the automation model). Each also declares on_failure: abandon_to_review, so a failed generation lands the card in front of a human instead of stalling with no route out.

review is a human state — the card parks there until a person acts. published is terminal, so entering it closes the task. The publish transition carries a guard: the card can only be published once payload.approved is 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 is placed in the initial state, triage. Its payload carries the theme the agent will read.

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 the card walks the chain on its own: each state's on_complete rule fires the next transition as the automation principal. While a generation runs the card shows automation_status: running; the poem-so-far accumulates in last_result until the card lands in review. See Per-state automation.

Five generations run back to back, so the card takes a while to arrive — poll until .state is review. The # → retry 480 budget is deliberately generous: each attempt costs about a second of sleep plus a CLI round trip, so it buys roughly ten minutes, against the 303s the five generations took on the CI sandbox in the slowest run measured so far. The sandbox model is CPU-only and shares the box with whatever the previous tutorial left running in the background, so a budget sized to the typical run expires on the slow ones.

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 now in review, holding the full sonnet it composed one stanza at a time — no application-side state, no glue code between the stages.


Step 6 — Send it backward for a revision

The reviewer wants a different ending. review → stanza_4 is a backward move — exactly 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. The revision re-enters the chain at the step that needs redoing — one generation, not five.

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

The publish transition's guard requires payload.approved == true. Firing it before approving 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 the patch, so setting approved alone keeps the composed 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 — the human start, the agent's to_stanza_1to_review chain, the backward revise, the guarded publish — is one append-only record. Automation-driven moves carry their generation_id as provenance. See Transition history.

soat get-task-history --task-id "$TASK_ID" | jq -r '.[] | "\(.from_state // "∅") → \(.to_state) [\(.principal_kind)] \(.transition // "(initial)")"'

The board query

The workflow's states are kanban columns and each task a card — one query renders a column, with no application-side state. See Tasks:

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

Where to go next