Guides
Human-in-the-loop
A human gate is the difference between an agent that drafts and an agent that acts. This guide covers the entire pause-and-approve lifecycle — down to the exact state key the decision lands in, and what happens when a gate sits inside a parallel graph.
A human_gate node executes nothing of its own. Its only job is to stop the run and wait for a person, then either resume the graph or fail it. Everything else on this page follows from that one fact.
What happens when a run reaches a gate
When a gate becomes the next node the engine would run, it does not just stop on the spot. It first lets the rest of the current work settle, so no concurrent branch is ever discarded. Concretely, the executor:
- finishes every other node that is currently in flight, and lets their successors that are not themselves gated run too — a gate only parks the run once nothing else is runnable (see deferred merges below)
- records the gate as a step with status
waitingand input{"prompt": "..."} - writes a checkpoint whose
queuebegins with the gate node and preserves everything still pending behind it — including any merge that is waiting on branches - sets the run to
paused, recordspending_gate, publishes arun_pausedevent, and fires any configured alert
The run object now carries everything a reviewer needs:
{
"status": "paused",
"pending_gate": {
"node_id": "approve",
"prompt": "Review the drafted support reply before it is sent."
},
"steps": [
{ "label": "Draft Reply", "status": "success", "output": { "text": "..." } },
{ "label": "Review Reply", "status": "waiting", "input": { "prompt": "Review the ..." } }
]
}
A paused run is free and durable
The checkpoint lives in the database, so a paused run consumes no compute and survives process restarts and deploys. It can wait seconds or weeks. Restart recovery explicitly leavespaused runs untouched — only interrupted running and pending runs are swept. See Reliability & checkpoints.Resolving a gate
One endpoint resolves a gate: POST /api/runs/{id}/approve. The body is the same for both outcomes; only approved differs. It returns 400 unless the run is actually paused on a gate.
| Field | Type | Default | Description |
|---|---|---|---|
| approved | boolean | required | true continues the run past the gate; false fails the run immediately. |
| input | object | {} | Optional reviewer payload. On approval its keys are merged into run state at the top level, so downstream nodes can template against them. |
Approving (approve → resume)
{
"approved": true,
"input": { "reviewer": "lukas", "edited_reply": "Hi — we have issued the refund." }
}
On approval the engine rewrites the gate checkpoint so execution can continue past it, doing three things atomically:
- records the full decision under
approval_<gate_node_id>in state — for a gate whose id isapprovethat key isapproval_approve—{"approved": true, "input": {...}} - merges each key of your
inputinto top-level state, so a downstream tool can send{edited_reply}instead of the agent's original draft - marks the gate node completed, removes it from the queue, appends the gate's outgoing targets, sets the run to
pending, and resumes from the rewritten checkpoint — completed nodes never re-execute
Approve from wherever the reviewer is:
# curl
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/approve \
-H 'content-type: application/json' \
-d '{"approved": true, "input": {"reviewer": "lukas"}}'
# Python SDK (approved defaults to True)
client.approve("af772b3602ef", input={"reviewer": "lukas"})
# Console: the approval queue on /dashboard exposes Approve / Reject / Cancel
# inline, with a field for the reviewer input object.
Rejecting (reject → failed)
{ "approved": false }
The gate step is marked failed and the run fails with the message Rejected at human gate 'approve'. Nothing downstream runs. Because a rejected run keeps its checkpoint, it is technically resumable — but resume re-presents the same gate, so rejection is best read as a hard stop, not a branch.
Resolving over MCP
Ballast is itself an MCP server at POST /api/mcp, so an external agent (Claude Code, Cursor, any MCP client) can find and clear gates without touching the REST API directly. Two of its six tools are for exactly this: list_pending_approvals (capability view) and approve_gate (capability approve_gates).
# 1. find everything waiting on a human
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "list_pending_approvals", "arguments": {} } }
# -> [ { "run_id": "af772b3602ef", "workflow_name": "Support triage",
# "gate": { "node_id": "approve", "prompt": "Review the ..." } } ]
# 2. resolve one — same semantics as POST /approve, capability-checked
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "approve_gate",
"arguments": { "run_id": "af772b3602ef", "approved": true,
"input": { "reviewer": "claude" } } } }
approve_gate calls the same code path as the REST endpoint, so injected input, the approval_<node> key, and resume behavior are identical. A viewer-scoped credential can list_pending_approvals but not approve_gate. Full tool schemas are on the MCP page.
Injecting reviewer input and routing on it
The input you pass on approval is the mechanism for a reviewer to change what happens next, not just to say yes. Two patterns cover almost everything.
Override a value. Approve with an edited field and have the downstream node template against the reviewer's version rather than the agent's:
# approve with an override
{ "approved": true, "input": { "edited_reply": "Hi — refund issued, sorry for the trouble." } }
# the notify tool references the reviewer's text instead of {draft_reply}
{ "id": "notify", "type": "tool",
"config": { "tool_name": "slack_message",
"params": { "text": "Sending approved reply: {edited_reply}" } } }
Route on the decision. Because the decision lands in state, a condition node placed after the gate can branch on it — either on the structured approval record or on a free-form field the reviewer supplied:
# branch on the structured decision
approval_approve.get('approved') == True
# ...or branch on a reviewer-supplied field, e.g. {"input": {"decision": "escalate"}}
decision == 'escalate'
Gate edges are unconditional
Unlike a condition node, a gate follows allof its outgoing edges on approval — it cannot itself send approved and rejected work down different paths. To fork on the reviewer's intent, approve with aninput field (e.g. {"decision": "escalate"}) and branch on it with a condition node downstream. Rejection always ends the run.Multi-stage, conditional & timed approvals
A single yes/no is the default, but a gate can carry a full approval policy — a sequence of stages, quorum per stage, stages that apply only under certain conditions, and a reminder/escalation ladder with a safe action at the deadline. It all lives in the gate's config.
Sequential stages with quorum
Set stages to a list and the run advances through them in order — it only resumes after the final stage signs off, and a rejection at any stage ends the chain immediately. Each stage names its own approvers, a mode (any / all / majority) or explicit min_approvals, and an optional per-stage deadline_seconds.
Conditional stages
Give a stage a whenexpression and it applies only when that expression is true for the run — the classic example being a legal review that's required only for customers outside the US. Stages that condition out are skipped; if every stage is skipped the gate auto-approves. The expression runs the same safe language as condition nodes, over the run's state.
{
"type": "human_gate",
"config": {
"prompt": "Approve this refund",
"stages": [
{ "approvers": [{"type": "role", "value": "admin"}], "mode": "any" },
{ "approvers": [{"type": "role", "value": "finance"}], "mode": "any" },
{ "approvers": [{"type": "role", "value": "legal"}], "mode": "any",
"when": "region != 'US'" }
]
}
}
Reminder & escalation ladder
A gate can nudge, escalate, and finally act on its own if no one responds. Set reminders to a list of { after_seconds, action } rungs — where action is remind or escalate (an escalate rung folds the fallback_approvers into the eligible set) — plus a terminal deadline_seconds with an on_timeout of auto_approve, auto_reject, or escalate. Each rung fires exactly once as its elapsed-time threshold passes, and the deadline outcome is written to the audit trail.
{
"type": "human_gate",
"config": {
"prompt": "Approve the payout",
"approvers": [{"type": "role", "value": "admin"}],
"fallback_approvers": [{"type": "role", "value": "manager"}],
"reminders": [
{ "after_seconds": 1800, "action": "remind" },
{ "after_seconds": 3600, "action": "escalate" }
],
"deadline_seconds": 14400,
"on_timeout": "auto_reject"
}
}
Alerts on pending gates
When a run pauses, the engine fires a fire-and-forget notification alongside the run_paused event. It is entirely env-gated — silent in local development, real in production:
SLACK_WEBHOOK_URLset → a Slack message like[Ballast] Run af77… of 'Support triage' is paused at a human gate: Review the …RESEND_API_KEYandALERT_EMAILset → an email with subject[Ballast] Approval needed — Support triage
The same alerting path fires on run failure, so reviewers and on-call get told the moment attention is needed. Alert delivery never raises into the executor — a broken webhook cannot break a run.
run_paused ({"type": "run_paused", "node_id": "approve", "prompt": "..."}); full payloads are in Events & streaming.Deferred merges across a gate
The most subtle case is a gate that sits inside one branch of a parallel graph, where a downstream merge is waiting on both that branch and a sibling. Consider a fan-out where one branch must be approved and the other runs freely, both feeding a merge:
fork ──▶ gate ──▶ draft ──▶ merge ──▶ finish
└────▶ research ─────────▶ merge
"edges": [
{"id": "e1", "source": "fork", "target": "gate"},
{"id": "e2", "source": "fork", "target": "research"},
{"id": "e3", "source": "gate", "target": "draft"},
{"id": "e4", "source": "draft", "target": "merge"},
{"id": "e5", "source": "research", "target": "merge"},
{"id": "e6", "source": "merge", "target": "finish"}
]
Here is the exact sequence the engine follows:
forkstarts both branches.researchruns to completion and addsmergeto the queue — but the merge is a barrier anddrafthas not run, so it is not ready and is held back (deferred)- the other branch reaches
gate. Nothing else is runnable — the only queued nodes are the gate and the not-ready merge — so the run pauses - the gate checkpoint's
queueis written as[gate, merge]: the deferred merge is preserved so it survives the pause (and any restart) - on approval, the checkpoint is rewritten with
gatecompleted and its targetdraftqueued. Resume runsdraft; that completes the merge's last incoming branch, the merge fires, andfinishruns
The takeaway: a gate never strands a merge. Sibling work that finished before the pause stays durable in the checkpoint, and the barrier resolves correctly the instant the gated branch catches up. The same wave rules are covered from the concurrency side in Parallel execution.
Worked example, end to end
Using the Support Triage Crew from your first workflow, this is the whole lifecycle in five calls — trigger, observe the pause, approve with an edited reply, confirm completion:
# 1. trigger
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/runs \
-H 'content-type: application/json' \
-d '{"input": {"ticket": "Charged twice for March"}}'
# -> { "id": "af772b3602ef", "status": "pending" }
# 2. moments later it has parked at the gate
$ curl -s http://localhost:8000/api/runs/af772b3602ef | jq '.status, .pending_gate.node_id'
# -> "paused" "approve"
# 3. approve, overriding the drafted reply with the reviewer's wording
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/approve \
-H 'content-type: application/json' \
-d '{"approved": true, "input": {"reviewer": "lukas", "edited_reply": "Refund issued — apologies."}}'
# 4. the run resumes past the gate and completes
$ curl -s http://localhost:8000/api/runs/af772b3602ef | jq '.status, .output.approval_approve.approved'
# -> "success" true
The final output contains category, draft_reply, the merged reviewer and edited_reply fields, the approval_approve record, and notification. Every action — trigger, approve — is written to the audit trail with the acting credential.