Skip to content

Human-in-the-loop workflows

How to pause a running workflow, collect human input or approval, and resume execution.


HITL nodes let you insert a human decision point anywhere in a workflow spec. When the interpreter reaches a HITL node it:

  1. Creates a WorkflowHumanStep row in the DB with status PENDING.
  2. (Optionally) sends a Slack notification to the team. The notification includes an “Open approvals” link built from the worker’s WEB_URL env var ({WEB_URL}/approvals, default http://localhost:3000) — set it to your dashboard URL in production or the link points at localhost.
  3. Parks the Temporal workflow at the node — no polling, no timeout loops.
  4. Waits for a human to respond via the inbox UI or the REST API.
  5. On response, fires a Temporal signal (hitl_<nodeId>), which unblocks the workflow and routes to the configured next node.
  6. If the configured timeout elapses with no response, the workflow is automatically routed to onTimeout.

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.

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.

Pauses the workflow and presents a binary approve/reject choice. The most common HITL node.

type: humanApproval
title: "Approve implementation before opening PR" # required; 1–200 chars
description: "Tests passed. Approve to open the PR or reject to discard." # optional; max 2000 chars
contextFrom: context.currentCodeResult.summary # optional; workflow context path to display
onApprove: setAwaitingCi # required; node to route to on approval
onReject: terminateRejected # required; node to route to on rejection
onRequestChanges: fixAndReview # optional; node to route to when changes are requested
onTimeout: terminateTimedOut # required; node to route to on timeout
timeout: "24h" # required; positive duration

Valid 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.

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: humanDecision
title: "Apply the reviewer notes before opening the PR?" # required
description: "..." # optional
contextFrom: context.reviewSummary # optional
options: # required; 2–10 items
- label: "Yes — apply the notes"
value: "apply"
next: addressFeedback
- label: "No — looks good, open PR"
value: "skip"
next: openPR
storeAs: context.applyFeedbackChoice # optional; stores the whole response payload
onTimeout: openPR # required
timeout: "30m" # required

Valid API action: select (with value set to one of the option values).

Presents a structured form with 1–20 typed fields. The submitted values are stored in the workflow context and available to downstream nodes.

type: humanInput
title: "Configure the deployment" # required
description: "Fill in the deployment parameters." # optional
fields: # 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: boolean
storeAs: context.deployConfig # optional; stores the whole response payload
onSubmit: runDeploy # required; node to route to after submit
onTimeout: terminateTimedOut # required
timeout: "1h" # required

Field 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).

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: humanReview
title: "Review the implementation diff" # required
description: "Leave notes for the agent. Submit to continue." # optional
contentFrom: context.currentCodeResult.diff # required; workflow context path to show
storeAs: context.reviewFeedback # optional; stores the whole response payload
onSubmit: storeFeedback # required
onTimeout: openPR # required
timeout: "8h" # required

Valid API action: submit (with value set to the reviewer’s text string).


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 onTimeout

The 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 answers 503 TEMPORAL_UNAVAILABLE only when it has not connected to Temporal yet; every other signal failure, including Temporal being unreachable or timing out after the connection was made, answers 502 SIGNAL_FAILED. Either way the human can retry. If the rollback itself fails, the step stays RESOLVED without 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 with signalSent: false.

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.


The human-code-review template is a complete example that combines humanReview and humanDecision:

  1. Agent implements and runs lint + typecheck.
  2. humanReview shows the diff; reviewer submits optional notes.
  3. humanDecision asks 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',
},
  • Keep timeout realistic. 24h is reasonable for approvals; 8h for reviews during a working day; 30m for a quick decision during active iteration. Long timeouts don’t hurt throughput — the workflow parks cheaply inside Temporal.
  • Always route onTimeout to a sensible default. For approvals, terminateTimedOut (non-blocking team) or openPR (auto-proceed) are the two common choices.
  • Use storeAs when downstream nodes need the human’s response. The stored response payload is written to whatever path you name (for example context.reviewNotes), and later nodes read it from there.
  • Use contextFrom / contentFrom to give the human relevant context. The value is fetched from the workflow context at the time the node executes and included in the step’s context field.

When the timeout duration elapses:

  • The interpreter marks the WorkflowHumanStep row TIMED_OUT and sets resolvedAt (resolveHumanStep in worker/src/activities/state.ts), only while the row is still PENDING. The write is best-effort: if it fails, the workflow still routes on, and the row stays PENDING until run finalization cancels it.
  • The workflow routes to onTimeout and continues normally.
  • A response to a step that is no longer PENDING is refused with 409 ALREADY_RESOLVED, which covers TIMED_OUT, CANCELLED and RESOLVED. A step that is still PENDING on a run that is no longer running gets 409 RUN_NOT_RUNNING.

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.


All endpoints require at least the ENGINEER role. Visibility follows the same team-membership rules as the inbox UI.

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).

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.

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.


  • 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.
  • humanInput supports 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.
  • humanDecision takes 2–10 options. Wider branching needs a cond chain downstream.
  • answeredByMe reads who resolved the step, not an audit trail. It matches the step’s single resolvedBy or 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, resolvedBy is 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.
  • approverCount is the one binding no lint rule reads. validateSpec collects the bindings that must resolve (inputs, set.values, eval.target, eval.candidateRef, fanOut.over, terminate.result) but not a humanApproval’s approverCount, and normalizeApproverCount reads 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.contentFrom and a humanDecision option’s value are 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

Source on GitHub

Built with Astro and Starlight