Graphs API
A graph is a directed network of typed nodes connected by static or conditional edges. The executor walks nodes in Pregel-style supersteps; each node produces output that downstream nodes consume via Jinja2 templates. Every graph must have exactly one begin node and at least one end node.
Agents that graph nodes reference.
Build and run graphs in the console.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/graphs |
List graphs (offset or cursor pagination) |
| POST | /v1/graphs |
Create a graph |
| GET | /v1/graphs/{id} |
Get graph by id |
| PUT | /v1/graphs/{id} |
Replace (full update) a graph |
| DELETE | /v1/graphs/{id} |
Delete a graph |
| POST | /v1/graphs/find |
Filter graphs by predicate |
| GET | /v1/graphs/{id}/status |
Validate the graph's external references |
| GET | /v1/graphs/{id}/runs/{run_id}/turn_log |
Read the graph-level turn log for a run |
| GET | /v1/graphs/{id}/runs/{run_id}/nodes/{node_id}/turn_log |
Read a single node's turn log |
Graph object
{
"id": "research-pipeline",
"description": "Research then summarize.",
"nodes": [
{"kind": "begin", "id": "start"},
{"kind": "agent", "id": "researcher", "agent_id": "research-agent"},
{"kind": "agent", "id": "summarizer", "agent_id": "summary-agent",
"input_template": "Summarize: {{ nodes.researcher.text }}"},
{"kind": "end", "id": "done", "output_template": "{{ nodes.summarizer.text }}"}
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "researcher"},
{"kind": "static", "from_node": "researcher", "to_node": "summarizer"},
{"kind": "static", "from_node": "summarizer", "to_node": "done"}
],
"max_iterations": null,
"harness_id": null
}
| Field | Required | Type | Description |
|---|---|---|---|
id |
no | string | Identifier (case-sensitive). If omitted, the server assigns a type-prefixed id (e.g. graph-7b2e44a1c0de). Immutable after creation |
description |
yes | string | Human-readable description |
nodes |
yes | GraphNode[] | At least one node; must include exactly one begin and at least one end |
edges |
no | GraphEdge[] | Static or conditional edges; default empty list |
max_iterations |
no | integer or null | Hard cap on supersteps. Required for cyclic graphs to prevent unbounded loops |
on_max_iterations |
no | string or null | Optional node id. On hitting the max_iterations cap, the executor routes once to this node to finalize gracefully instead of ending with ended_detail=max_iterations_exceeded. Null preserves the hard-fail |
harness_id |
no | string or null | Set by harness management; mutation via CRUD returns 409 when set |
Node kinds
| Kind | Description |
|---|---|
begin |
Entry point. Exactly one required per graph. No incoming edges allowed |
end |
Sink node. At least one required. Renders output_template to produce graph output. No outgoing edges allowed |
agent |
Runs a stored Agent via agent_id. Accepts input_template, response_format, description, input_schema |
graph |
Delegates to a stored sub-graph via graph_id. Accepts input_template, description |
fan_out |
Dispatches parallel branches. Targets defined on specs (not in edges). Supports broadcast, tee, and map kinds |
fan_in |
Waits for all incoming branches then renders aggregate_template. Must have at least one incoming edge |
tool_call |
Calls a tool directly by tool_id (scoped form toolset_id__bare_name). Accepts arguments dict or arguments_template |
begin node fields:
| Field | Required | Description |
|---|---|---|
id |
yes | Within-graph unique node id |
input_schema |
no | JSON Schema 2020-12. When set, session create validates graph_input against it; failure returns 422 |
end node fields:
| Field | Required | Description |
|---|---|---|
id |
yes | Within-graph unique node id |
output_template |
no | Jinja2 template rendered over GraphContext when End fires. Empty means no output payload |
output_schema |
no | JSON Schema 2020-12. When set, rendered output must parse as valid JSON; failure ends with ended_detail=end_output_invalid |
agent node fields:
| Field | Required | Description |
|---|---|---|
id |
yes | Within-graph unique node id |
agent_id |
yes | Id of the stored Agent this node executes |
input_template |
no | Jinja2 template producing the user message passed to the agent. Default concatenates initial_input |
response_format |
no | JSON Schema forwarded to the agent. Populates NodeOutput.parsed when set |
fan_out spec kinds:
| Spec kind | Required fields | Description |
|---|---|---|
broadcast |
target_node_id, count |
Spawns count synthesized instances of one target node |
tee |
target_node_ids |
Runs each named target once with the fan-out's input |
map |
target_node_id, source_node_id, source_path |
Parses a list from a source node's output and runs one instance per item |
Node input templating
Every node that takes input builds it by rendering a Jinja2 template against the graph's accumulated state. This applies to an agent/graph node's input_template, an end node's output_template, a fan_in node's aggregate_template, and each string value inside a tool_call node's arguments (or its arguments_template).
The renderer is sandboxed and uses StrictUndefined: a reference to a missing variable or attribute raises a render error (returned as 400), it does not silently produce an empty string. Guard optional fields with the default filter, e.g. {{ initial_input.note | default('') }}. Two graph-specific filters are also available: fromjson parses a node's raw JSON text into indexable data, and strip_fences unwraps a markdown code fence around generated code (see Templating for details).
Templates see exactly three top-level variables, available to every node:
| Variable | Meaning |
|---|---|
initial_input |
The graph's run input: whatever you passed as graph_input when starting the session. Its type is whatever you sent (a dict, string, or list of messages). Referenced as initial_input, not input. |
iteration |
The current superstep counter (integer). begin runs at 0, so the first agent node runs at iteration 1. Do not detect a loop's first pass with iteration == 0; test nodes.<prev> is defined instead. |
nodes |
A map of already-run node outputs keyed by node id. Each value is a NodeOutput with .text, .parsed (set only when that node had a response_format), and .history. Referencing a not-yet-run node (e.g. an un-taken conditional branch) raises under StrictUndefined - guard with {% if nodes.<id> is defined %}. Fan-out targets (broadcast/map/tee) appear as a list[NodeOutput] at nodes.<target>; index or iterate it (nodes.<target>[0].text). |
Inside a fan-out map/broadcast instance, two more variables are in scope for that instance only: fanout_index (integer) and fanout_item (that instance's item).
Structured input and per-node instructions
initial_input carries your graph_input verbatim, so you can send a structured object and have each node pull only what it needs:
{
"document": "<text>",
"instructions": {
"extract": "Extract company names only.",
"summarise": "Three bullets, formal tone."
}
}
extract node input_template: "{{ initial_input.instructions.extract }}\n\n{{ initial_input.document }}"
summarise node input_template: "{{ initial_input.instructions.summarise }}\n\n{{ nodes.extract.text }}"
Keying per-node directives by node id in one structured input lets a single reusable graph be re-parameterised each run without editing its definition. Two practical notes:
- The default
input_template(when omitted) assumesinitial_inputis a list of messages. If you send a dict or string, set an explicitinput_templateon each node. - Pin the input shape by setting
input_schema(JSON Schema) on thebeginnode;graph_inputis then validated at session-create and a mismatch returns422.
Edge kinds
Static edge (unconditional):
{"kind": "static", "from_node": "start", "to_node": "next"}
Conditional edge (router-driven):
{
"kind": "conditional",
"from_node": "judge",
"router": {
"kind": "json_path",
"branches": [
{"conditions": [{"path": "status", "op": "eq", "value": "accept"}], "to_node": "done"},
{"conditions": [{"path": "status", "op": "eq", "value": "reject"}], "to_node": "producer"}
],
"default_to": "done"
}
}
Router kinds: json_path (branch on structured output from NodeOutput.parsed) and callable (registered Python callable returning a node id).
Topology rules
The save-time validator enforces these invariants and returns 422 with a description of the violated rule on failure:
- All node ids must be unique within the graph.
- Exactly one
beginnode; at least oneendnode. beginhas no incoming edges.endnodes have no outgoing edges.- Every
endnode must be reachable via BFS frombegin. - Every edge endpoint (
from_node,to_node, router branchto_node,default_to) must reference an existing node id. fan_outnodes must not have outgoingedgesentries; targets live onspecs.- Cyclic graphs currently accepted without
max_iterations(no static cycle detection); a future validator may require it.
Graph-bound sessions
To run a graph, create a workspace session with a graph binding:
{
"binding": {"kind": "graph", "graph_id": "research-pipeline"},
"auto_start": false
}
Graph-bound sessions run on any workspace backend (local, container, or kubernetes). The graph executor persists per-node state through the workspace's git-backed state repository on whichever backend hosts the workspace.
See the Sessions API for details on session lifecycle.
Common patterns
The node kinds plus templated inputs compose into a range of orchestration shapes. The most common:
- Linear pipeline -
begin -> agent -> agent -> end; each node'sinput_templatereadsnodes.<prev>.text. - Conditional branch - an
agentwithresponse_formatplus aconditionaledge whosejson_pathrouter branches on the parsed output (a classify-then-dispatch state machine). - Scatter-gather (map-reduce) - a
fan_outmapspec spawns one instance of a target per list item (each reads{{ fanout_item }}), and afan_inaggregates them. - Best-of-N - a
fan_outbroadcastruns N copies of one agent; afan_in(or a following agent) selects the best. - Multi-lens - a
fan_outteeruns several different agents on the same input; afan_inmerges. - Iterative refinement - a cycle (writer -> critic -> writer) with
max_iterations, the writer detecting its first pass withnodes.critic is definedand the critic's structured flag driving ajson_pathrouter. - Tool-augmented -
tool_callnodes (with templatedarguments) interleaved with agents for deterministic steps without an LLM turn.
Scatter-gather example
The fan_out node has no outgoing edge (its map spec wires
scatter -> classify); each classify instance follows its own edge
into the fan_in, which waits for all instances.
{
"id": "map-reduce-reviews",
"description": "Classify each review in parallel, then aggregate.",
"nodes": [
{"id": "start", "kind": "begin"},
{"id": "split", "kind": "agent", "agent_id": "list-emitter",
"input_template": "Return the reviews as a JSON array:\n{{ initial_input.reviews }}",
"response_format": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "string"}}}}},
{"id": "scatter", "kind": "fan_out",
"specs": [{"kind": "map", "target_node_id": "classify", "source_node_id": "split", "source_path": "items"}]},
{"id": "classify", "kind": "agent", "agent_id": "sentiment",
"input_template": "Classify review #{{ fanout_index }}:\n{{ fanout_item }}"},
{"id": "gather", "kind": "fan_in",
"aggregate_template": "{% for r in nodes.classify %}- {{ r.text }}\n{% endfor %}"},
{"id": "done", "kind": "end", "output_template": "{{ nodes.gather.text }}"}
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "split"},
{"kind": "static", "from_node": "split", "to_node": "scatter"},
{"kind": "static", "from_node": "classify", "to_node": "gather"},
{"kind": "static", "from_node": "gather", "to_node": "done"}
]
}
Iterative-refinement example
The cycle write -> critique -> write requires max_iterations. The
writer detects its first pass with nodes.critique is defined (not
iteration == 0 - the first write runs at iteration 1, since begin
is iteration 0); the critic's structured done flag drives a
json_path router that ends or loops back.
{
"id": "draft-and-refine",
"description": "Draft, critique, revise up to a cap.",
"max_iterations": 6,
"nodes": [
{"id": "start", "kind": "begin"},
{"id": "write", "kind": "agent", "agent_id": "writer",
"input_template": "{% if nodes.critique is defined %}Revise using:\n{{ nodes.critique.text }}\n\nDraft:\n{{ nodes.write.text }}{% else %}Draft about: {{ initial_input.topic }}{% endif %}"},
{"id": "critique", "kind": "agent", "agent_id": "critic",
"input_template": "Critique; set done=true when good enough:\n{{ nodes.write.text }}",
"response_format": {"type": "object", "properties": {"done": {"type": "boolean"}}}},
{"id": "done", "kind": "end", "output_template": "{{ nodes.write.text }}"}
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "write"},
{"kind": "static", "from_node": "write", "to_node": "critique"},
{"kind": "conditional", "from_node": "critique", "router": {
"kind": "json_path",
"branches": [{"conditions": [{"path": "done", "op": "eq", "value": true}], "to_node": "done"}],
"default_to": "write"
}}
]
}
Create a graph
POST /v1/graphs - returns 201 Created.
curl -X POST https://your-host/v1/graphs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "research-pipeline",
"description": "Research then summarize.",
"nodes": [
{"kind": "begin", "id": "start"},
{"kind": "agent", "id": "researcher", "agent_id": "research-agent"},
{"kind": "agent", "id": "summarizer", "agent_id": "summary-agent",
"input_template": "Summarize: {{ nodes.researcher.text }}"},
{"kind": "end", "id": "done", "output_template": "{{ nodes.summarizer.text }}"}
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "researcher"},
{"kind": "static", "from_node": "researcher", "to_node": "summarizer"},
{"kind": "static", "from_node": "summarizer", "to_node": "done"}
]
}'import httpx
r = httpx.post(
"https://your-host/v1/graphs",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "research-pipeline",
"description": "Research then summarize.",
"nodes": [
{"kind": "begin", "id": "start"},
{"kind": "agent", "id": "researcher", "agent_id": "research-agent"},
{"kind": "agent", "id": "summarizer", "agent_id": "summary-agent",
"input_template": "Summarize: {{ nodes.researcher.text }}"},
{"kind": "end", "id": "done", "output_template": "{{ nodes.summarizer.text }}"},
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "researcher"},
{"kind": "static", "from_node": "researcher", "to_node": "summarizer"},
{"kind": "static", "from_node": "summarizer", "to_node": "done"},
],
},
)
assert r.status_code == 201const r = await fetch("/v1/graphs", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "research-pipeline",
description: "Research then summarize.",
nodes: [
{kind: "begin", id: "start"},
{kind: "agent", id: "researcher", agent_id: "research-agent"},
{kind: "agent", id: "summarizer", agent_id: "summary-agent",
input_template: "Summarize: {{ nodes.researcher.text }}"},
{kind: "end", id: "done", output_template: "{{ nodes.summarizer.text }}"}
],
edges: [
{kind: "static", from_node: "start", to_node: "researcher"},
{kind: "static", from_node: "researcher", to_node: "summarizer"},
{kind: "static", from_node: "summarizer", to_node: "done"}
]
})
})Response 201 Created - the full graph object.
Errors:
409- a graph with thisidalready exists422- topology validation failed (see Topology rules above)
Get a graph
GET /v1/graphs/{id} - returns 200 OK with the graph object.
curl https://your-host/v1/graphs/research-pipeline \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/graphs/research-pipeline",
headers={"Authorization": f"Bearer {token}"})const r = await fetch("/v1/graphs/research-pipeline", {
headers: {"Authorization": `Bearer ${token}`}
})Errors: 404 if the id does not exist.
List graphs
GET /v1/graphs - returns an offset or cursor page of graph objects.
Query parameters: limit (1-200, default 20), offset (default 0), cursor, order_by.
curl "https://your-host/v1/graphs?limit=50&offset=0" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/graphs",
headers={"Authorization": f"Bearer {token}"},
params={"limit": 50, "offset": 0})
page = r.json()
graphs = page["items"]const r = await fetch("/v1/graphs?limit=50&offset=0", {
headers: {"Authorization": `Bearer ${token}`}
})
const {items, total, length, offset} = await r.json()Replace a graph
PUT /v1/graphs/{id} - full replacement; returns 200 OK with the updated graph.
The body uses the same schema as POST. All fields are replaced; omitted optional fields reset to their defaults.
curl -X PUT https://your-host/v1/graphs/research-pipeline \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "research-pipeline",
"description": "Updated pipeline.",
"nodes": [
{"kind": "begin", "id": "start"},
{"kind": "agent", "id": "worker", "agent_id": "research-agent"},
{"kind": "end", "id": "done", "output_template": "{{ nodes.worker.text }}"}
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "worker"},
{"kind": "static", "from_node": "worker", "to_node": "done"}
]
}'import httpx
r = httpx.put(
"https://your-host/v1/graphs/research-pipeline",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "research-pipeline",
"description": "Updated pipeline.",
"nodes": [
{"kind": "begin", "id": "start"},
{"kind": "agent", "id": "worker", "agent_id": "research-agent"},
{"kind": "end", "id": "done", "output_template": "{{ nodes.worker.text }}"},
],
"edges": [
{"kind": "static", "from_node": "start", "to_node": "worker"},
{"kind": "static", "from_node": "worker", "to_node": "done"},
],
},
)await fetch("/v1/graphs/research-pipeline", {
method: "PUT",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "research-pipeline",
description: "Updated pipeline.",
nodes: [
{kind: "begin", id: "start"},
{kind: "agent", id: "worker", agent_id: "research-agent"},
{kind: "end", id: "done", output_template: "{{ nodes.worker.text }}"}
],
edges: [
{kind: "static", from_node: "start", to_node: "worker"},
{kind: "static", from_node: "worker", to_node: "done"}
]
})
})Errors: 404 if not found, 409 if managed by a harness, 422 on topology violation.
Delete a graph
DELETE /v1/graphs/{id} - returns 204 No Content.
curl -X DELETE https://your-host/v1/graphs/research-pipeline \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.delete("https://your-host/v1/graphs/research-pipeline",
headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 204await fetch("/v1/graphs/research-pipeline", {
method: "DELETE",
headers: {"Authorization": `Bearer ${token}`}
})Validate graph status
GET /v1/graphs/{id}/status - returns 200 OK with {"ok": bool, "issues": [string, ...]}. Checks that every agent node's agent_id resolves to an existing Agent row and every graph node's graph_id resolves to an existing Graph row. The walk is depth-1 only (does not recurse into sub-graph nodes). Status is re-evaluated on every call.
curl https://your-host/v1/graphs/research-pipeline/status \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/graphs/research-pipeline/status",
headers={"Authorization": f"Bearer {token}"})
body = r.json()
# body: {"ok": true, "issues": []}const r = await fetch("/v1/graphs/research-pipeline/status", {
headers: {"Authorization": `Bearer ${token}`}
})
const {ok, issues} = await r.json()Errors: 404 if the graph id does not exist.
Errors note
All error responses use the RFC 7807 ProblemDetails envelope with type, title, status, detail, instance, and extensions (which includes request_id and, for 422 errors, an errors array with field paths). See the REST API overview for details.