Human Approval Node
The Human Approval node pauses the workflow run per input row, asks a human to approve or reject, and routes the row to the matching output port when they respond. It sits in the Decision group.
When to use
- Rows need a human decision before they proceed downstream (refunds above a threshold, offboarding events, terms updates).
- The human is not in the DataLug UI: reach them on Slack, Teams, or email; the reply lands back in the workflow.
- The wait is bounded — the run should time out and route the row to the
timeoutport if the human doesn't respond in a configurable window.
Ports
| Port | Direction | Meaning |
|---|---|---|
in | in | Row waiting on human approval. |
pending | out | Emits every row waiting on a decision. When a real approval backend is registered, the executor throws SuspendSignal and the run transitions to SUSPENDED until the human responds. |
approved | out | Rows are materialized into this port's downstream queue on Approve. |
rejected | out | Rows are materialized into this port's downstream queue on Reject. |
timeout | out | Rows are materialized here after expiresAt passes without a response. |
error | out | Rows the executor could not register (backend down, unknown target, etc.). |
notify | out | One control-plane row per registered approval (per_row) or one summary row (per_batch). Chain a Notification node downstream to fan out "an approval was raised" notifications. |
Configuration
| Field | Default | Meaning |
|---|---|---|
target | web | Where to deliver the approve/reject URLs: slack | teams | discord | email | web. |
targetAddrColumn | Row column carrying the delivery address (Slack handle, email, etc.). | |
messageTemplate | {{ column }} template rendered per row for the human message. | |
rowKeyColumn | Row column carrying the row identity string used for audit + display. | |
ttlSeconds | 86400 | Row-level TTL. Row auto-times-out after this many seconds. Capped server-side at 30 days. |
batchMode | per_row | per_row creates one approval per input row. per_batch creates ONE approval for the whole batch; resolution routes ALL rows to the resolved port together. |
Signed callback URLs
Each ApprovalRequest row carries a per-request secret. The register response includes signed URLs:
POST /api/approvals/<approvalId>/approve?t=<token>
POST /api/approvals/<approvalId>/reject?t=<token>
Token format: t=<issuedAtUnix>.<verb>.<hex-hmac> where the HMAC input is ${approvalId}.${verb}.${issuedAtUnix} and the key is the row's secret. Verb is pinned into the signature so an "approve" URL cannot be tampered into a "reject". A callback whose now > ApprovalRequest.expiresAt fails with expired regardless of the token being otherwise valid.
Row output shape (pending)
| Column | Notes |
|---|---|
approval_id | The persisted ApprovalRequest id, or null when no backend registrar is wired (dev mode). |
approval_status | Initially set to Pending. Flips to Approved / Rejected / TimedOut on the matching port when a human responds. |
approval_target | Normalized target (slack / teams / email / web). |
approval_message | Template-substituted message. |
approval_approve_url | Signed callback URL forwarded to the human for approve. |
approval_reject_url | Signed callback URL for reject. |
Failure modes
| Reason | Where the row lands |
|---|---|
registerApproval returns { ok:false, error } | error port with the backend's reason |
registerApproval throws | error port with the thrown message |
Delivery pipeline
The register endpoint fires an async delivery based on the request's target. A routing seam supports per-channel adapters:
| Target | Adapter | Notes |
|---|---|---|
web | (none) | Owner/Admin resolves from Account -> Manage -> Config -> Human Approvals. |
slack | sendSlackDm({ to, text, approval }) | Requires the Slack bot connector. |
teams | sendTeamsMessage({ to, text, approval }) | Uses the Teams incoming webhook. |
email | sendEmail({ to, subject, text, approval }) | Uses the existing SMTP service. |
When the corresponding sender is null, the register response still returns the approve/reject URLs (the row is still persisted); the delivery step returns { ok:false, error:"<channel>_sender_not_configured" } — the operator wires the adapter when they configure that channel.
Auto-timeout
A 60-second interval calls the timeout scheduler which flips any waiting row where expiresAt < now to TimedOut and signals the runtime with the timeout status. The operator can tune the row-level TTL from the inspector's slider (default 24 h, max 7 days) or from config.ttlSeconds.
Web review page
Two entry points list waiting / approved / rejected / timed-out rows:
- Account -> Manage -> Config -> Human Approvals - embedded card.
/account/approvals- standalone page with breadcrumb + filter controls for status, projectId, runId, and date range.
Owner/Admin can approve or reject inline with POST /api/account/approvals/:id/resolve { decision: "approve" | "reject" }. The DataLug UI is a real alternative to clicking a signed URL in Slack.
A Resend button on the WebApprovalsPanel row list retries delivery via POST /api/account/approvals/:id/resend-delivery (Owner/Admin). Each resend records a new ApprovalDelivery attempt so failed deliveries are traceable.
Delivery ledger
Every delivery attempt is persisted to the ApprovalDelivery table with { channel, status, messageId, error, attemptedAt }. Success rows also populate ApprovalRequest.deliveryMessageId so operators can correlate with Slack threads / email Message-Ids. Failed rows carry the underlying error string (channel_not_found, smtp_not_configured, etc.) so the operator can debug from the review UI without digging through server logs.
Suspend / resume state machine
The runtime suspends and resumes as follows:
- Executor throws
SuspendSignalafter the backend registers at least one approval. - Worker's outer catch snapshots the run's
inputsmap, persists aSuspendCheckpointunder<checkpointDir>/<runId>.suspend.json, and transitions the run's status toSUSPENDED. - Backend callback (approve / reject / timeout / web-resolve) fires
notifyRuntimeApprovalResolved-> runtime'sPOST /approvals/resolved. - Runtime endpoint loads the checkpoint, injects the resolved row (with
approval_statuspopulated) into the correct downstream port's queue, and persists the updated checkpoint.
Actual re-execution of the SUSPENDED run (draining the queued rows through the rest of the topology) is a downstream ops refinement; the checkpoint captures everything needed for a controller to pick it up.
Example flow fixture
qa/fixtures/approval-node-example-flow.json — source (expense reports) -> AI classify (auto vs review) -> router -> approval (email target, approverEmail column) -> load (accounting).