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 waiting and input {"prompt": "..."}
  • writes a checkpoint whose queue begins with the gate node and preserves everything still pending behind it — including any merge that is waiting on branches
  • sets the run to paused, records pending_gate, publishes a run_paused event, and fires any configured alert

The run object now carries everything a reviewer needs:

GET /api/runs/{id} (excerpt)
{
  "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 leaves paused 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.

FieldTypeDescription
approvedbooleantrue continues the run past the gate; false fails the run immediately.
inputobjectOptional 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)

POST /api/runs/{id}/approve
{
  "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 is approve that key is approval_approve {"approved": true, "input": {...}}
  • merges each key of your input into 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:

Three ways to approve
# 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)

POST /api/runs/{id}/approve
{ "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).

JSON-RPC over POST /api/mcp
# 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:

Reviewer edits the outgoing reply
# 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:

Condition expressions (either works)
# 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 an input 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.

Manager → Finance → Legal (Legal only when non-US)
{
  "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.

Remind at 30m, escalate at 1h, auto-reject at 4h
{
  "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"
  }
}
Reminders and deadlines are driven by a background sweeper on real (UTC) elapsed time, so the ladder is correct across time zones and survives restarts — a run paused for approval keeps its place and its clock through a redeploy.

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_URL set → a Slack message like [Ballast] Run af77… of 'Support triage' is paused at a human gate: Review the …
  • RESEND_API_KEY and ALERT_EMAIL set → 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.

UIs should not poll for gates. Subscribe to the run's WebSocket stream and act on 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:

Graph (a gate inside a parallel branch)
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:

  • fork starts both branches. research runs to completion and adds merge to the queue — but the merge is a barrier and draft has 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 queue is written as [gate, merge]: the deferred merge is preserved so it survives the pause (and any restart)
  • on approval, the checkpoint is rewritten with gate completed and its target draft queued. Resume runs draft; that completes the merge's last incoming branch, the merge fires, and finish runs

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:

Full pause-and-approve cycle
# 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.