Human-in-the-Loop (Approval)
Pause a workflow for a person to approve, reject, or fill in structured input before execution continues.
A human-in-the-loop (HITL) step pauses workflow execution and waits for a person to either click Approve / Discard or submit a short form. When the response comes in, the workflow resumes from that exact step with the human's answer available as output data to downstream tasks.
ByteChef ships HITL as a first-class workflow primitive through the Approval component. The component is also exposed as an AI-agent tool, so agents can request human confirmation before taking irreversible action.
When to use it
- Approving an action a workflow is about to take — a refund above a threshold, sending a contract, deleting customer data.
- Manual triage on automation output — a sales lead the AI scored as hot, a draft reply that needs human sign-off, a flagged moderation case.
- Collecting structured input mid-flow — asking for an extra field that the trigger didn't carry (e.g. "what discount should we apply?").
- Guardrailing AI agents — let the agent draft the response and call
requestApprovalas a tool, then send only what the human approved.
How it works
The Approval component combines three platform mechanics:
- Suspend / resume — when
Request Approvalruns, the action callscontext.suspend(...)with a 60-day expiry. The task state is persisted, the worker is freed, and the job sits idle until a callback arrives. - A signed resume URL — the framework derives a cryptographically signed
jobResumeIdfrom the current job. The internal callback endpoint isPOST /job/resume/{id}; the user-facing form route is/resume/{id}. Both target the same signed token, but the form lives outside/api/so it can be hit anonymously from an email link. - Approval channels (cluster elements) — children attached to the
Approval component decide how to reach the human. Today: Slack,
Gmail, Microsoft Outlook 365, and the in-app Approval Task.
Each channel receives the same
formUrland renders it however makes sense for that medium (Slack action buttons, an HTML email, or a row in the Approval Tasks inbox).
When the human responds, the SPA POSTs the form data back to
/job/resume/{id}. The action's resumePerform reshapes the payload into the
output schema and the workflow continues with the next task — approved
(boolean) and any submitted field values are now available as data pills.
Public URL required. The resume URL is generated from
bytechef.public-url (or the BYTECHEF_PUBLIC_URL env var). If that is
unset or the workflow is being tested in the editor, the action falls
back to an error: "Cannot generate approval form URL. Ensure the server's
public URL is configured…". Set the public URL to something the approver
can actually reach (your domain, an ngrok tunnel for dev, etc.).
Two interaction modes
The same Request Approval action covers both styles depending on whether
you configure form inputs:
One-click approve / discard
Leave Form Inputs empty. Channels render two buttons — Approve and
Discard — that link to {formUrl}?approved=true|false. The SPA
auto-submits on load, so the approver never sees the form. Output is just
{ "approved": true|false }.
Use this when the workflow just needs a yes/no gate.
Structured form
Add one or more Form Inputs. Channels render a single Open Approval Form link/button that opens the dynamic form in the browser. The approver fills in the fields, hits Approve or Discard, and the workflow resumes with each field name as a data pill.
Available field types: Checkbox, Custom HTML, Date Picker, Datetime Picker, Email, File, Hidden Field, Input, Number, Password, Radio Button, Select (single or multi), and Textarea.
The action's output schema is generated dynamically from your field
definitions — approved plus one property per fieldName typed to match
the field type (e.g. a Date Picker named dueDate becomes a DATE output
property). That means downstream tasks can reference
${approval_1.approved} and ${approval_1.dueDate} natively, without a
follow-up parse step.
Approval channels
A channel is a cluster element typed
APPROVAL_CHANNELS, attached to the Approval component as a child. Add as
many as you like; each one fans out a notification with the same form URL.
If you list both Slack and Gmail, the approver gets both — whoever clicks
first wins; subsequent clicks return "Form no longer available."
| Channel | Component | Behavior |
|---|---|---|
| Slack | slack/v1 | Posts a message to a channel/DM. Empty form → primary "Approve" + danger "Discard" buttons. With form fields → single "Open Approval Form" button. |
| Gmail | google-mail/v1 | Sends an HTML email with the form title/description and either Approve/Discard hyperlinks or an "Open Approval Form" link. |
| Microsoft Outlook 365 | microsoft-outlook-365/v1 | Sends an HTML email (same layout as Gmail) via Outlook 365 — the choice for organizations on Microsoft 365. |
| Approval Task | approvalTask/v1 | Creates a row in the in-app Approval Tasks page (sidebar inbox). No external delivery — useful when approvers already live in ByteChef. |
Adding more channels later (Teams, Discord, SMS, custom) is a matter of
implementing the ApprovalChannelFunction interface and registering it as a
cluster element with type(APPROVAL_CHANNELS) — see
Build a Component.
Why fan-out, not first-touch routing? Approvals are time-sensitive and different humans live in different tools. Sending the same form URL to every channel keeps the design simple: the form itself is the source of truth, and the first response wins.
Building an approval step
- In the workflow editor, click + and search for Approval.
- Select the Request Approval action.
- Set Form Title and Form Description (these become the message subject in Gmail/Slack and the page heading in the form UI).
- Add Form Inputs if you want structured input — leave empty for yes/no.
- Add one or more channels as cluster element children. For Slack, pick
the channel/user; for Gmail, set the
ToandSubject; for Approval Task, no config is required. - Wire downstream tasks to read
${approval_1.approved}(and any field names you defined) from the approval step's output.
A typical pattern is a Condition
right after the approval to branch on approved == true vs. approved == false.
Example workflow snippet
tasks:
- name: approval_1
type: approval/v1/requestApproval
label: "Approve refund"
parameters:
formTitle: "Refund request"
formDescription: "Customer ${trigger_1.email} requested a $${trigger_1.amount} refund."
clusterElements:
approvalChannels:
- name: slack_notify
type: slack/v1/slack
parameters:
channel: "C01234567"
- name: condition_1
type: condition/v1
parameters:
rawExpression: true
expression: "${approval_1.approved} == true"Using approval as an AI-agent tool
The same Request Approval is registered as a TOOLS-type cluster element
on the Approval component, so it can be attached to an
AI Agent under the Tools slot.
When the agent decides it needs human sign-off, it calls the tool, the
underlying action suspends the agent's task, the channels notify the
approver, and the resumed response is fed back into the agent's next turn.
This lets you build agents that:
- Draft an email, ask the user to confirm or edit before sending.
- Propose a code change, get explicit approval before opening the PR.
- Schedule a meeting, but ask the human "is this time OK?" first.
The agent treats the result the same as any other tool call output — the
approved flag plus any form fields are visible in the agent's reasoning.
The Approval Tasks page
If you use the Approval Task channel, requests land in the Approval Tasks inbox — a list of requests beside a detail pane, scoped to one environment. Responding there resumes the workflow the same way an external link would.
Use this when approvers are already ByteChef users (operations, ops-on-call, team leads) and you don't want to depend on Slack or email delivery.
See Approval Tasks for the page itself — its filters, the fields on a request, and why an inbox can look empty.
Lifecycle & limits
- Default expiry: 60 days. After that the suspended task is GC'd and the resume URL returns HTTP 410 Gone. Channels that present a link will show "Form no longer available."
- One submission per request. The resume token is single-use; once the job advances, further submissions to the same URL are rejected.
- Editor preview skips channel dispatch. When you run the workflow in
the editor (test mode), the action still produces a
formUrlyou can open by hand, but it does not call out to Slack / Gmail / Approval Task. Use a real deployment to exercise the full delivery path. - No
publicUrl→ no approval. Without a configured public URL, the action throws at perform time rather than silently creating an unreachable URL.
Coming soon: expanded approvals
Coming soon
The capabilities in this section — the extra delivery channels, in-place messenger resolution, the Approval Gate for AI-agent tools, the pending approvals inbox, expiry reminders and escalation, and MCP/A2A approval elicitation — are on the upcoming release track and are not yet available in the latest released version of ByteChef.
ByteChef separates two disjoint human-in-the-loop primitives: Approval (a
decision, described on this page) and Ask User Question (the LLM asking a
clarifying question — no decision semantics, answered inline in the agent's
conversation). Typing in chat never resolves an approval, in either
direction — only a card's buttons or the hosted form do. A reviewer's comment
travels back on both outcomes — approve-with-note and reject-with-note
alike — under the reserved comment key of the approval outcome (a
user-defined form field named comment takes precedence).
Approval Gate: guarding AI-agent tools
Add an Approval Gate tool to an AI Agent and attach the tools it should gate beneath it. Every invocation of a gated tool pauses the run and shows the reviewer the tool's name and the AI-chosen arguments. Approve executes the call with exactly those arguments; Discard feeds a "denied by reviewer" response back into the agent loop so it can re-plan. Enforcement is in the platform — the model cannot call a gated tool without approval, and it never sees the gate itself, only the tools it gates.
An agent can carry several gates, each with its own channels and its own expiry, so a destructive tool can escalate to Slack within four hours while a routine one goes to chat with a week to respond. Give each gate a name — it appears in the approval request and groups the tools in the editor.
More delivery channels
Beyond Slack, Gmail, Microsoft Outlook 365, and Approval Task, more channels will be available: Chat (an inline card in the conversation that started the run — no connection needed, but the workflow needs a chat trigger; a run started by webhook or schedule with only the chat channel pauses without a live card and stays resolvable from the pending approvals list and the hosted form), Discord, Telegram, Mattermost, Rocket.Chat, generic SMTP Email, WhatsApp (Meta Cloud, Twilio, or Infobip — free-form messages only deliver inside an open 24-hour customer-service window, so pair WhatsApp with a fallback channel for cold outreach), and SMS (Twilio or Infobip).
An Approval Gate left with no channels configured defaults to the chat channel. If the workflow has no chat trigger, workflow validation will warn about a task or gate whose channels resolve to chat only — including a gate sitting on that implicit chat default — since the run would then pause with no live card for anyone to see.
Channels fan out simultaneously; the first response wins. Delivery is best-effort per channel — a channel that fails to send is logged and skipped while the others still deliver, and the step only fails when every configured channel fails.
Some channels will resolve in place, without leaving the messenger:
Slack (set the app's signing secret on the connection and point its
Interactivity Request URL at <public-url>/slack/interactivity), WhatsApp
via Meta Cloud (set the app secret on the connection), Telegram (set a
webhook secret token on the connection), and Discord (set the app's public
key on the connection). Mattermost also resolves in place through its
interactive-message callback, but because that callback is unsigned, it
cannot attribute the resolving user. Twilio/Infobip SMS and WhatsApp, and
Rocket.Chat, stay on hosted-form links.
A one-click Approve/Discard link never resolves the approval on page open: it lands on the hosted form with the decision pre-selected, and a single Confirm click submits it — this keeps email link scanners and messenger preview bots, which follow links in delivered messages, from silently resolving approvals.
Where the approval card will appear
Beyond the workflow editor's test chat, an inline approval card (and its
resumed continuation) will appear on the Chats page, in AI Hub workflow
chats, and in the @bytechef/chat embeddable widget. An MCP tool call
paused on an approval will return an approval_required result with the
hosted form URL — clients that support URL elicitation are prompted to open
the form, clients that support only form elicitation get an inline
approve/comment form, and once approved the same tool call returns the
resumed run's real output (including chained approvals, up to three
elicitation rounds per call). An A2A message/send call will surface the
paused task with input-required status and the form URL; a later
tasks/get returns the final output once it's resolved.
The Approval Tasks page will also gain a pending run approvals section listing every run currently blocked on an approval, regardless of which channel delivered it.
Expiry, reminders, and escalation
Today the expiry is a fixed 60 days, set by the action itself and not configurable (see Lifecycle & limits above). Coming with this work: Expires In / Expires In Unit properties that make the window per-request, plus automatic reminders and escalation layered on top of it and operator-facing controls for all three.
An expired approval can no longer be resolved. A background sweep
(bytechef.workflow.execution.approval-expiry.enabled, on by default) fails
the paused run shortly after expiry so it doesn't linger, and a matching
Approval Tasks entry is marked Expired instead of staying open with a dead
link (that reconciliation has its own toggle,
bytechef.automation.approval-task.reconciliation.enabled). Operators can
watch bytechef_approval_pending (a gauge of runs currently blocked on an
approval) and bytechef_approval_expired (a counter of approvals that
lapsed, tagged by source); per-channel delivery is tracked separately by
bytechef_approval_request (successes) and
bytechef_approval_delivery_failure (failures), both tagged by channel.
A subscribed notification (email, webhook, or Slack) will fire once, a
configurable lead time before an approval expires — default 24 hours,
bytechef.workflow.execution.approval-reminder.lead-time, disable with
bytechef.workflow.execution.approval-reminder.enabled=false. A separate
notification will fire once an approval has sat unresolved past a
configured duration, routed to a different audience than the reminder (an
on-call channel or a manager, for example) — set
bytechef.workflow.execution.approval-escalation.after to a duration to
enable it; with no default set, escalation is opt-in and does nothing until
after is configured. Disable the escalation sweep on its own with
bytechef.workflow.execution.approval-escalation.enabled=false.
Outcome shape
The approval step's output stays flat — approved, comment, approvedBy,
plus one key per form field. approvedBy will carry the resolver's identity
only when the resolving channel authenticated the clicking user (an
in-place resolution with a verified signature); the anonymous hosted form
has no trusted identity, so approvedBy is absent there and a value
supplied in the form body is stripped rather than trusted.
Related
approval/v1— the current approval component reference.approval-task/v1— the in-app task channel component (creates rows in the Approval Tasks sidebar inbox).
How is this guide?
Last updated on