Guides
Your first workflow
One end-to-end pass: classify a ticket, draft a reply, hold it behind a human gate, notify the team. You will build it three ways — in the console, with curl, and with the Python SDK — and finish with a completed, audited run.
The workflow you build here is the same one the Support Triage Crew template ships with. Its shape is four nodes on a straight line, with a human approval gate in the third slot:
classify (agent) -> draft (agent) -> approve (human gate) -> notify (tool)
| | | |
category draft_reply pauses the run slack_message
Every node writes a value into a shared, per-run state dictionary under its output_key, and later nodes read those values with {placeholder} templating. That is the entire data-passing mechanism — there is nothing else to learn about how nodes talk to each other.
| Field | Type | Default | Description |
|---|---|---|---|
| classify | agent | category | LLM call. Classifies the ticket into billing / bug / feature_request / other; writes the answer to state as category. |
| draft | agent | draft_reply | LLM call. Reads {ticket} and {category}, writes a support reply to state as draft_reply. |
| approve | human_gate | approval_approve | Pauses the whole run until a reviewer approves or rejects. Records the decision under approval_approve. |
| notify | tool | notification | Built-in slack_message tool. Fires only after approval; simulated locally with no webhook configured. |
0. Prerequisites
You need the backend running on http://localhost:8000. In local development the default auth mode is open, so no API key or token is required, and every node in this guide uses the deterministic mock model — the run completes with no provider keys and costs $0.00. Confirm the server is up:
$ curl -s http://localhost:8000/api/health
{"status": "ok", "service": "ballast"}
The Python SDK snippets below assume it is installed from the repo (pip install -e sdk/python). Because auth is open locally, the client needs only a base URL:
from agentos_sdk import AgentOS
client = AgentOS("http://localhost:8000") # api_key="aos_..." in AUTH_MODE=required
1a. The fast path — instantiate the template
The quickest way to a running workflow is to clone the seeded template. In the console, open /dashboard or /workflows, click New from template, pick Support Triage Crew, and give it a name. You land in the builder on a full, editable copy — skip to step 2.
# every seeded template, each with its full graph
$ curl -s http://localhost:8000/api/templates
# clone the Support Triage Crew into a new workflow
$ curl -s -X POST http://localhost:8000/api/templates/{template_id}/instantiate \
-H 'content-type: application/json' \
-d '{"name": "Support triage"}'
# -> 201 { "id": "9bc62f209c52", "status": "draft", "version": 1, "graph": {...} }
The clone is a normal draft workflow with its own id and version history; editing it never touches the template. If you prefer to understand every node before you run one, build it by hand instead.
1b. The instructive path — build it from scratch
A single POST /api/workflows carries the whole graph — nodes and edges together. The id you give each node is yours to choose; it becomes both the step identity in the timeline and the fallback state key. position only affects canvas layout. Using the same ids as the template means the two paths converge exactly.
The four nodes, field by field
classifyhas no incoming edge, so the engine treats it as the start nodeoutput_keynames where a node's result lands in state; omit it and the node id is useduser_messageis a template —{ticket}resolves from the run input,{category}from the upstream node's outputmodel: "mock"keeps the guide keyless; switch any agent toclaude-haiku-4-5,claude-sonnet-4-6, or agpt-4ovariant once you set the matching provider key
$ curl -s -X POST http://localhost:8000/api/workflows \
-H 'content-type: application/json' \
-d '{
"name": "Support triage",
"description": "Classify, draft, review, notify.",
"graph": {
"nodes": [
{
"id": "classify",
"type": "agent",
"position": {"x": 0, "y": 150},
"config": {
"label": "Classify Ticket",
"model": "mock",
"system_prompt": "Classify the support ticket into: billing, bug, feature_request, or other. Reply with just the category.",
"user_message": "Ticket: {ticket}",
"temperature": 0.2,
"max_tokens": 50,
"output_key": "category"
}
},
{
"id": "draft",
"type": "agent",
"position": {"x": 280, "y": 150},
"config": {
"label": "Draft Reply",
"model": "mock",
"system_prompt": "Write a friendly, concise support reply for the given ticket and category.",
"user_message": "Ticket: {ticket}\nCategory: {category}",
"temperature": 0.7,
"max_tokens": 400,
"output_key": "draft_reply"
}
},
{
"id": "approve",
"type": "human_gate",
"position": {"x": 560, "y": 150},
"config": {
"label": "Review Reply",
"prompt": "Review the drafted support reply before it is sent."
}
},
{
"id": "notify",
"type": "tool",
"position": {"x": 840, "y": 150},
"config": {
"label": "Notify Slack",
"tool_name": "slack_message",
"params": {"text": "Support reply approved and sent. Category: {category}"},
"output_key": "notification"
}
}
],
"edges": [
{"id": "e1", "source": "classify", "target": "draft"},
{"id": "e2", "source": "draft", "target": "approve"},
{"id": "e3", "source": "approve", "target": "notify"}
]
}
}'
# -> 201 { "id": "9bc62f209c52", "status": "draft", "version": 1, ... }
In the console the same graph is drag-and-drop: open /workflows → New workflow, drop an agent node and fill in its model, system prompt, and output key in the inspector; repeat for the second agent, a human_gate, and a toolnode; then drag from each node's output handle to the next to draw the three edges. The canvas writes exactly the JSON above.
The same graph in Python, built with the fluent GraphBuilder so you never hand-write node JSON:
from agentos_sdk import AgentOS, GraphBuilder
g = GraphBuilder()
classify = g.agent("classify", label="Classify Ticket",
system_prompt="Classify the support ticket into: billing, bug, "
"feature_request, or other. Reply with just the category.",
user_message="Ticket: {ticket}", temperature=0.2,
max_tokens=50, output_key="category")
draft = g.agent("draft", label="Draft Reply",
system_prompt="Write a friendly, concise support reply.",
user_message="Ticket: {ticket}\nCategory: {category}",
max_tokens=400, output_key="draft_reply")
approve = g.gate("approve", label="Review Reply",
prompt="Review the drafted support reply before it is sent.")
notify = g.tool("notify", label="Notify Slack", tool_name="slack_message",
params={"text": "Support reply approved. Category: {category}"},
output_key="notification")
g.edge(classify, draft)
g.edge(draft, approve)
g.edge(approve, notify)
client = AgentOS("http://localhost:8000")
wf = client.create_workflow("Support triage", g.build())
print(wf["id"], wf["status"]) # -> 9bc62f209c52 draft
2. Validate before running
Validation is structural and expression-level: it catches edges that point at missing nodes, tool nodes referencing unknown tools, malformed or unsafe expressions, and graphs with no start node. Errors block a run; warnings do not.
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/validate
{"valid": true, "errors": [], "warnings": []}
# Python
>>> client.validate(wf["id"])
{'valid': True, 'errors': [], 'warnings': []}
400 before any node runs. Validating up front just turns that into a fast, side-effect-free check while you are still editing.3. Publish (optional)
Drafts are fully runnable, so you can skip this. Publishing is a status flag that marks a workflow as the blessed version for your team and makes it count toward active_workflows in metrics. In the console it is the Publish toggle in the builder header; over the API it is a status update:
$ curl -s -X PUT http://localhost:8000/api/workflows/9bc62f209c52 \
-H 'content-type: application/json' \
-d '{"status": "published"}'
4. Trigger a run with input
A trigger validates the graph, creates the run in status pending, and returns 201 immediately — execution is asynchronous, so the response comes back before any node has finished. The input object seeds state: both input (the whole object) and each of its top-level keys become variables, which is why {ticket} resolves.
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/runs \
-H 'content-type: application/json' \
-d '{"input": {"ticket": "My invoice was charged twice this month"}}'
# -> 201 { "id": "af772b3602ef", "status": "pending", "cost_usd": 0, ... }
The Python SDK can trigger and then block until the run settles. wait=True polls until the run reaches success, failed, or paused and returns it with every step attached — for this workflow it returns as soon as the run parks at the gate:
run = client.run("Support triage",
{"ticket": "My invoice was charged twice this month"},
wait=True)
print(run["status"]) # -> paused
print(run["pending_gate"]) # -> {'node_id': 'approve', 'prompt': 'Review the ...'}
Make triggers safe to retry
Send anIdempotency-Key header (or idempotency_key="..." in the SDK) and a repeated call returns the original run instead of starting a duplicate — the right default for anything that might be retried by a queue or a webhook.5. Watch the step timeline
Open the run in /runs to watch each step flip from running to success live, or poll GET /api/runs/{id}. The two agents execute in order, then the gate step is recorded as waiting and the run itself becomes paused:
{
"id": "af772b3602ef",
"status": "paused",
"cost_usd": 0,
"output": null,
"pending_gate": { "node_id": "approve", "prompt": "Review the drafted support reply before it is sent." },
"steps": [
{ "node_id": "classify", "node_type": "agent", "label": "Classify Ticket", "status": "success",
"output": { "text": "billing", "model": "mock" } },
{ "node_id": "draft", "node_type": "agent", "label": "Draft Reply", "status": "success",
"output": { "text": "[mock:mock] ...", "model": "mock" } },
{ "node_id": "approve", "node_type": "human_gate", "label": "Review Reply", "status": "waiting",
"input": { "prompt": "Review the drafted support reply before it is sent." } }
]
}
Reaching the gate wrote a checkpoint containing the full state and the exact continuation queue, so this paused run is durable — it consumes nothing and survives restarts. For live updates without polling, subscribe to the WebSocket stream and act on the run_paused event.
6. Approve the human gate
In the console, the paused run surfaces in the approval queue on /dashboard with inline Approve / Reject buttons and a field for reviewer input. Over the API it is a single call. The optional input merges into state at the top level, so downstream nodes can template against it:
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/approve \
-H 'content-type: application/json' \
-d '{"approved": true, "input": {"reviewer": "lukas"}}'
# Python SDK
client.approve(run["id"], input={"reviewer": "lukas"})
# ...or from an MCP client (Claude Code, Cursor): tools/call -> approve_gate
{ "name": "approve_gate", "arguments": { "run_id": "af772b3602ef", "approved": true } }
On approval the engine, in one atomic checkpoint rewrite:
- records the decision in state under
approval_approve— i.e.approval_<gate_node_id>—{"approved": true, "input": {"reviewer": "lukas"}} - merges your
inputkeys into top-level state (so{reviewer}is now a variable) - marks the gate step
success, drops the gate from the queue, and resumes from the checkpoint
The notify tool then fires. With no Slack webhook configured locally it reports {"sent": false, "simulated": true} and the run completes.
7. The finished run
The run settles to success, and its output is the public state dictionary — everything the workflow accumulated:
{
"status": "success",
"cost_usd": 0,
"duration_ms": 41,
"output": {
"input": { "ticket": "My invoice was charged twice this month" },
"ticket": "My invoice was charged twice this month",
"category": "billing",
"draft_reply": "[mock:mock] Processed the input ...",
"reviewer": "lukas",
"approval_approve": { "approved": true, "input": { "reviewer": "lukas" } },
"notification": { "sent": false, "simulated": true, "text": "Support reply approved and sent. Category: billing" }
}
}
On the mock model every step costs nothing, so cost_usd is 0. Point the agents at a real model and the same field aggregates the true per-token spend of every LLM step. The whole run — trigger, approve, completion — is written to the append-only audit trail at GET /api/audit.
Iterating on the graph
Change the graph andPUT it back — the previous graph is snapshotted as an immutable version automatically (visible under History in the builder, or at GET /api/workflows/{id}/versions) and the version number bumps. Every run records the version it executed, so old runs stay reproducible.Where to go next
- Make the gate deeper — inject edited replies, route on the reviewer's decision, resolve gates over MCP: Human-in-the-loop
- Fan the draft step out into parallel research + drafting merged at the end: Parallel execution
- Add a condition node that routes billing tickets differently — the expression language and the node reference