Human-in-the-loop workflows
How to pause a running workflow, collect human input or approval, and resume execution.
What a HITL node does
Section titled “What a HITL node does”HITL nodes let you insert a human decision point anywhere in a workflow spec. When the interpreter reaches a HITL node it:
- Creates a
WorkflowHumanSteprow in the DB with statusPENDING. - (Optionally) sends a Slack notification to the team. The notification includes an “Open approvals” link built from the worker’s
WEB_URLenv var ({WEB_URL}/approvals, defaulthttp://localhost:3000) — set it to your dashboard URL in production or the link points at localhost. - Parks the Temporal workflow at the node — no polling, no timeout loops.
- Waits for a human to respond via the inbox UI or the REST API.
- On response, fires a Temporal signal (
hitl_<nodeId>), which unblocks the workflow and routes to the configured next node. - If the configured
timeoutelapses with no response, the workflow is automatically routed toonTimeout.
This is useful for:
- Requiring a human sign-off before a PR is opened.
- Collecting configuration values (branch names, environment targets, feature flags) that the agent can’t infer.
- Letting a reviewer read and annotate a diff before it’s merged.
- Presenting the agent with a list of options chosen by a human rather than the LLM.
Node types
Section titled “Node types”For every node type in one place, with complete specs you can paste into the editor, see workflow-nodes.md.
All four HITL node types share a timeout field and an onTimeout routing target. timeout is a
positive duration in the form Temporal parses — a number and a unit, such as "30m", "24h",
"7d", "1.5h" or "2 hours" (not ISO 8601 PT24H). A spec with any other value fails to parse,
so it is refused on save and at run start rather than at the wait. The same rule applies to a
signal node’s timeout.
humanApproval
Section titled “humanApproval”Pauses the workflow and presents a binary approve/reject choice. The most common HITL node.
type: humanApprovaltitle: "Approve implementation before opening PR" # required; 1–200 charsdescription: "Tests passed. Approve to open the PR or reject to discard." # optional; max 2000 charscontextFrom: context.currentCodeResult.summary # optional; workflow context path to displayonApprove: setAwaitingCi # required; node to route to on approvalonReject: terminateRejected # required; node to route to on rejectiononRequestChanges: fixAndReview # optional; node to route to when changes are requestedonTimeout: terminateTimedOut # required; node to route to on timeouttimeout: "24h" # required; positive durationValid API actions: approve, reject, and — only when onRequestChanges is set — request_changes. Like a reject, a change request requires a written note (the comment), resolves the step at once even on a multi-approver gate, and is the one action whose comment is also sent to the workflow, readable at nodes.<id>.output.comment. A step created for a gate without the edge never offers the action.
humanDecision
Section titled “humanDecision”Presents 2–10 labelled options and routes to the corresponding next node. Use when the workflow needs a human to choose a path that the LLM shouldn’t decide alone.
type: humanDecisiontitle: "Apply the reviewer notes before opening the PR?" # requireddescription: "..." # optionalcontextFrom: context.reviewSummary # optionaloptions: # required; 2–10 items - label: "Yes — apply the notes" value: "apply" next: addressFeedback - label: "No — looks good, open PR" value: "skip" next: openPRstoreAs: context.applyFeedbackChoice # optional; stores the whole response payloadonTimeout: openPR # requiredtimeout: "30m" # requiredValid API action: select (with value set to one of the option values).
humanInput
Section titled “humanInput”Presents a structured form with 1–20 typed fields. The submitted values are stored in the workflow context and available to downstream nodes.
type: humanInputtitle: "Configure the deployment" # requireddescription: "Fill in the deployment parameters." # optionalfields: # required; 1–20 items - key: environment label: "Target environment" type: select options: [staging, production] required: true - key: version label: "Version tag" type: text required: true - key: dryRun label: "Dry run only?" type: booleanstoreAs: context.deployConfig # optional; stores the whole response payloadonSubmit: runDeploy # required; node to route to after submitonTimeout: terminateTimedOut # requiredtimeout: "1h" # requiredField types: text, number, boolean, select. The options array is only relevant for select fields.
Valid API action: submit (with value set to an object keyed by field key).
humanReview
Section titled “humanReview”Shows content (e.g. a diff or generated document) for the reviewer to read. The reviewer submits an optional free-text note (up to 2,000 characters) and the workflow continues; the content itself is not changed.
type: humanReviewtitle: "Review the implementation diff" # requireddescription: "Leave notes for the agent. Submit to continue." # optionalcontentFrom: context.currentCodeResult.diff # required; workflow context path to showstoreAs: context.reviewFeedback # optional; stores the whole response payloadonSubmit: storeFeedback # requiredonTimeout: openPR # requiredtimeout: "8h" # requiredValid API action: submit (with value set to the reviewer’s text string).
Signal flow
Section titled “Signal flow”Workflow interpreter └─ reaches HITL node ├─ INSERT WorkflowHumanStep (status=PENDING, signalName=hitl_<nodeId>) ├─ POST Slack notification (if Slack integration configured) └─ scheduleActivity(waitForSignal, timeout=<duration>) │ │ human acts in inbox UI or via API ▼Gateway POST /api/v1/human-steps/:id/respond ├─ validate action against HITL_VALID_ACTIONS[kind] ├─ UPDATE WorkflowHumanStep SET status=RESOLVED (atomic, race-safe) └─ temporal.signalWorkflow(workflowId, signalName, [{ action, value, resolvedBy }]) │ ▼Temporal signal received └─ workflow routes to onApprove / onReject / next option / onSubmit │ ▼ (or on timeout)Temporal timer fires └─ workflow routes to onTimeoutThe response is recorded before the signal is sent, and what a failed signal does depends on why it failed:
- A transient failure is any signal error other than “the workflow does not exist”. It rolls the step back to
PENDING, because the workflow only unblocks through the signal and a step recorded as resolved would vanish from the inbox while the run stays stuck. The gateway answers503 TEMPORAL_UNAVAILABLEonly when it has not connected to Temporal yet; every other signal failure, including Temporal being unreachable or timing out after the connection was made, answers502 SIGNAL_FAILED. Either way the human can retry. If the rollback itself fails, the step staysRESOLVEDwithout a delivered signal and the error is logged. - A terminal failure (the execution never started, has completed, or was terminated) keeps the step
RESOLVED: no retry could land the signal, and rolling back would leave a step in the inbox that can never be cleared. The response succeeds withsignalSent: false.
The inbox UI
Section titled “The inbox UI”All pending steps are visible on the Approvals page (/approvals). The page polls every 10 seconds.
- Admins see all pending steps across all teams.
- Leads and Engineers see only steps from runs belonging to their teams.
Each card shows the step title, kind badge, description, and a link to the associated run. Click Respond to expand the action form:
| Kind | UI shown |
|---|---|
| APPROVAL | Approve / Reject buttons |
| DECISION | One button per option |
| INPUT | Typed form fields with a Submit button |
| REVIEW | Pre-populated textarea with a Submit button |
When a run is open at /runs/<id> and it has pending steps, those steps also appear in a Pending actions card in the right column — no need to navigate to the inbox.
The sidebar shows a count badge next to Approvals when there are pending steps.
Designing a HITL workflow
Section titled “Designing a HITL workflow”The human-code-review template is a complete example that combines humanReview and humanDecision:
- Agent implements and runs lint + typecheck.
humanReviewshows the diff; reviewer submits optional notes.humanDecisionasks whether to apply the notes.- “Yes”: agent addresses feedback, then opens PR.
- “No”: agent opens PR immediately.
Relevant snippet from packages/shared/src/workflow/templates/humanCodeReview.ts:
humanReview: { contentFrom: 'context.currentCodeResult.diff', description: 'Review the diff and leave notes for the agent. Submit to continue.', onSubmit: 'storeFeedback', onTimeout: 'openPR', storeAs: 'context.reviewFeedback', timeout: '8h', title: 'Review the implementation diff', type: 'humanReview',},applyFeedbackDecision: { onTimeout: 'openPR', options: [ { label: 'Yes — apply the notes', next: 'addressFeedback', value: 'apply' }, { label: 'No — looks good, open PR', next: 'openPR', value: 'skip' }, ], timeout: '30m', title: 'Apply the reviewer notes before opening the PR?', type: 'humanDecision',},The approve-before-pr template (packages/shared/src/workflow/templates/approveBeforePr.ts) shows the simplest pattern — implement, test, humanApproval, then open PR:
approve: { description: 'Tests passed. Approve to open the PR or reject to discard.', onApprove: 'setAwaitingCi', onReject: 'terminateRejected', onTimeout: 'terminateTimedOut', timeout: '24h', title: 'Approve implementation before opening PR', type: 'humanApproval',},Design guidelines
Section titled “Design guidelines”- Keep
timeoutrealistic.24his reasonable for approvals;8hfor reviews during a working day;30mfor a quick decision during active iteration. Long timeouts don’t hurt throughput — the workflow parks cheaply inside Temporal. - Always route
onTimeoutto a sensible default. For approvals,terminateTimedOut(non-blocking team) oropenPR(auto-proceed) are the two common choices. - Use
storeAswhen downstream nodes need the human’s response. The stored response payload is written to whatever path you name (for examplecontext.reviewNotes), and later nodes read it from there. - Use
contextFrom/contentFromto give the human relevant context. The value is fetched from the workflow context at the time the node executes and included in the step’scontextfield.
Timeout behaviour
Section titled “Timeout behaviour”When the timeout duration elapses:
- The interpreter marks the
WorkflowHumanSteprowTIMED_OUTand setsresolvedAt(resolveHumanStepinworker/src/activities/state.ts), only while the row is stillPENDING. The write is best-effort: if it fails, the workflow still routes on, and the row staysPENDINGuntil run finalization cancels it. - The workflow routes to
onTimeoutand continues normally. - A response to a step that is no longer
PENDINGis refused with409 ALREADY_RESOLVED, which coversTIMED_OUT,CANCELLEDandRESOLVED. A step that is stillPENDINGon a run that is no longer running gets409 RUN_NOT_RUNNING.
Cancellation
Section titled “Cancellation”When a workflow exits abnormally (cancelled by the user or failed), all PENDING steps for that run are bulk-updated to CANCELLED by the worker’s cleanup activity (cancelPendingHumanSteps). This prevents stale inbox entries for finished runs.
API reference
Section titled “API reference”All endpoints require at least the ENGINEER role. Visibility follows the same team-membership rules as the inbox UI.
GET /api/v1/human-steps
Section titled “GET /api/v1/human-steps”Returns up to 100 PENDING steps visible to the authenticated user, ordered by requestedAt descending.
status=ALL also returns answered steps (up to 200). actionable=true narrows the list to pending,
unexpired steps the caller may answer and has not already answered; it is what the sidebar count and
the Home “Waiting on you” section read.
answeredByMe=true narrows the list to steps the caller answered, of any kind and outcome: the step’s
resolvedBy is the caller (an approval, reject, decision, input or review, including the response that
completed a multi-approver step) or the caller has a recorded approval on it. A step resolved by a
timeout or by someone else does not match. It only narrows, so visibility and status still apply
(pass status=ALL for resolved steps). It is disjoint from actionable, which needs a PENDING
step and a PENDING step has no resolvedBy, so combining them returns nothing. limit (1-200, default 100
for PENDING and 200 for ALL) and offset page the list, and meta carries { limit, offset, total }
where total counts every matching step, so limit=1 is enough to ask whether any exist. meta is
present only when the request sets answeredByMe, limit or offset; a default request runs no count
query and omits it.
Each step also carries myResponse (the action the caller answered with: their approval row’s action,
else the stored action of a step they resolved; null otherwise), responses (every recorded
answer as { action, byName, comment, resolvedAt, value }, oldest first), and run.workRequestId.
Response:
{ "data": [ { "id": "<uuid>", "runId": "<uuid>", "nodeId": "approve", "kind": "APPROVAL", "title": "Approve implementation before opening PR", "description": "Tests passed. Approve to open the PR or reject to discard.", "status": "PENDING", "context": null, "options": null, "fields": null, "requestedAt": "2026-06-06T10:00:00Z", "run": { "id": "<uuid>", "status": "RUNNING", "workflowId": "eng-acme-payments-api-JIRA-42", "workRequest": { "externalTicketId": "JIRA-42", "description": "Add idempotency key support to /payments" } } } ], "meta": { "limit": 100, "offset": 0, "total": 1 }}meta appears only when a total was asked for (see above).
GET /api/v1/human-steps/:id
Section titled “GET /api/v1/human-steps/:id”Returns a single step by ID (same shape as one element of the list above).
Returns 404 if the step does not exist or is not visible to the caller.
POST /api/v1/human-steps/:id/respond
Section titled “POST /api/v1/human-steps/:id/respond”Submit a response to a pending step.
Request body:
{ "action": "approve" }{ "action": "select", "value": "apply" }{ "action": "submit", "value": { "environment": "staging", "version": "v1.2.3", "dryRun": false } }{ "action": "submit", "value": "The diff looks good but please add input validation on line 42." }An APPROVAL response may carry an optional comment (up to 2000 characters):
{ "action": "reject", "comment": "The migration drops a column the billing job still reads." }{ "action": "request_changes", "comment": "Handle the empty case before this merges." }The comment is stored with the response, written to the audit log, and shown to the requester on the
request panel and on the full run page, beside who decided. For request_changes it is also part of the signal the workflow receives; for approve and reject it is not. A comment on any other kind
returns 400 with INVALID_VALUE. A reject on an APPROVAL step requires a reason: the comment,
trimmed, must be at least 5 characters, or the response is 400 with REASON_REQUIRED. The rule lives
in the shared resolution path, so the web dialog, the Slack modal and direct API calls all meet it; an
approve’s comment stays optional. Timeouts and other automatic resolutions do not go through this path
and need no reason.
Slack buttons. An approval announcement carries Approve and Reject buttons. Clicking either
opens a Slack modal, the same shape as the web dialog: Reject asks for a required reason (5 to 2000
characters), Approve an optional comment — and a gate that offers it gets a Request changes button whose modal asks what should change. Submitting resolves the step through the same path as the
inbox route, with the text as the comment, so multi-approver counting, audit and the signal are
identical. A failure (a too-short reason, a step already resolved, a run that has finished, access
lost since the modal opened) shows inline in the modal rather than closing it. The modal opens only
for a step the clicker may control, and the submission is authorized again from the Slack user’s
linked account; the modal’s private_metadata is signed with the Slack signing secret and must name
the submitting user, and is never itself a grant. Decision options stay one-click, since they carry
no free text.
Slack message. The worker records where it posted a step’s announcement
(WorkflowHumanStep.slackMessage). When the step resolves, from the web or from a Slack button, the
gateway edits that message to show the decision, who made it, and the approver’s comment, and drops
the buttons. Comment text is escaped for Slack mrkdwn and clipped to 500 characters. A multi-approver
step keeps its buttons until the last required approval. The edit is best-effort: a Slack failure
never fails the response.
Valid actions per kind:
| Kind | Valid actions |
|---|---|
APPROVAL |
approve, reject, request_changes (only on steps that offer it) |
DECISION |
select |
INPUT |
submit |
REVIEW |
submit |
Returns 400 with INVALID_ACTION if the action is not valid for the step’s kind.
Returns 409 with ALREADY_RESOLVED if the step was already resolved or the workflow is no longer running.
Returns 200 with { "data": { "id": "...", "status": "RESOLVED" } } on success.
For multi-approver steps, the same user’s repeated approval is idempotent and does not increase the
approval count. The Approvals page shows current/required progress and can sort or filter overdue
steps. Platform administrators can search policy decisions from /admin/policies/decisions;
lifecycle changes and delegated configuration grants are managed under /admin/audit and
/admin/config-grants.
Limitations
Section titled “Limitations”- Only the message the worker posted is edited. A step whose announcement failed to post, or that was announced before the message was recorded, shows nothing in Slack. A pending multi-approver response confirms in the message’s thread, not privately to the approver, because a modal submission has no private reply channel.
humanInputsupports four field types —text,number,boolean,select. There is no file upload, no multi-select, and no cross-field validation beyond what the spec declares.humanDecisiontakes 2–10 options. Wider branching needs acondchain downstream.answeredByMereads who resolved the step, not an audit trail. It matches the step’s singleresolvedByor the caller’s recorded approval, so on a multi-approver step it includes only approvers whose approval was recorded and the user who completed it. If the resolver’s account is later deleted,resolvedByis cleared and the step no longer matches.- A gate answered on a run the viewer can no longer see does not count. Visibility still applies, so after a team change the checklist’s gate step can untick.
- A parked run holds a Temporal workflow open for its whole timeout. Long timeouts are cheap but not free; a 7-day approval keeps the workflow alive for 7 days unless it is cancelled.
- Some runs are visible only to their requester and platform ADMINs. Resolving a step, like
seeing or cancelling its run, needs run visibility (see
architecture.md), and some shapes match no team term: a template run launched with no connection on a GLOBAL template, and any run whose work request, channel and template all lack a team link. A team member cannot approve a step on one of those. - A PRD run is visible to the team of its first repository only. It records its primary repository as its work request’s connection; the teams of its other repositories see the per-repo work requests it submits, not the PRD run and its approval steps.
- A requester keeps visibility of their own runs after leaving the team. The requester term is not re-checked against current membership, so someone who launched a run can still see it, cancel it and resolve its steps after being removed from the team that owns its repository. Revoking that means deactivating the user.
approverCountis the one binding no lint rule reads.validateSpeccollects the bindings that must resolve (inputs,set.values,eval.target,eval.candidateRef,fanOut.over,terminate.result) but not ahumanApproval’sapproverCount, andnormalizeApproverCountreads anything that is not a positive integer — an unparseable string included — as one approver (a numeric string like"2"is converted and honoured). A mistyped path therefore silently turns a multi-approver gate into a single-approver one.humanReview.contentFromand ahumanDecisionoption’svalueare likewise unchecked — the latter is a literal rather than a binding, so what it lacks is duplicate-value validation, not provenance.- Approval ownership is not delegated or escalated. The inbox supports due/overdue visibility, but there are no reassignment chains, on-call schedules, holiday calendars, or SLA automation.
© 2026 Jorge Barnaby
Source-available under FSL-1.1-MIT