A human-in-the-loop approval workflow is the difference between “agents are helpful” and “agents just shipped an accident.” If you’re building agent automations (Claude Skills, MCP tools, custom scripts, or full multi-agent pipelines), you need a clear boundary: agents can draft and prepare—but only humans can authorize irreversible actions.
This guide gives you a beginner-friendly blueprint for Slack approval workflow and Telegram inline button approval gates that are: resumable, idempotent, auditable, and hard to misuse.
Why you need a human-in-the-loop approval workflow (even if your agent is “good”)
Most teams run into the same failure mode:
- Agents do a bunch of correct work (research, drafting, formatting).
- Then they do one irreversible thing (publish, send, spend, delete).
- That last step is where the risk lives.
So the practical rule is:
- Reversible steps (drafting, summarizing, transforming) can be automated end-to-end.
- Irreversible steps (publishing, emailing customers, running ads, moving money) must be guarded by an explicit approval.
Approval gates aren’t about mistrusting AI—they’re about building systems that remain safe at scale.
The core pattern: turn approval into data, not vibes
A robust human-in-the-loop approval workflow is just a small state machine where the approval decision is a first-class artifact.
Here’s the minimal state machine:
DRAFT
→ REQUEST_APPROVAL
→ APPROVED → PUBLISH
→ REJECTED → STOP
→ EXPIRED → STOP (or re-request)
The key idea: “approved” isn’t a chat message you parse; it’s structured data you store.
Why this matters
When approval is data, you can reliably answer:
- Who approved this?
- When did they approve it?
- What exactly did they approve (which version)?
- Did we publish once (and only once)?
- Can we resume the workflow after the human responds?
Define your approval artifacts (copy/paste schemas)
You’ll usually want four artifacts:
DRAFT_ARTIFACT— what you want to shipAPPROVAL_REQUEST— the message you sent to Slack/TelegramAPPROVAL_DECISION— the human’s decision (and metadata)PUBLISH_RESULT— the result of the irreversible action
1) DRAFT_ARTIFACT
{
"draft_id": "draft_2026_01_31_001",
"title": "Post title",
"body_markdown": "...",
"target": {
"type": "blog",
"destination": "github"
},
"created_at": "2026-01-31T18:05:12Z",
"content_hash": "sha256:..."
}
Why include content_hash? So nobody can approve Draft A and accidentally publish Draft B.
2) APPROVAL_REQUEST
{
"approval_token": "appr_8f3c6c5d2c",
"draft_id": "draft_2026_01_31_001",
"content_hash": "sha256:...",
"channel": {
"type": "slack",
"workspace_id": "T123",
"channel_id": "C123",
"message_ts": "1712345678.000100"
},
"requested_by": "workflow:publish_blog",
"requested_at": "2026-01-31T18:06:01Z",
"expires_at": "2026-01-31T20:06:01Z"
}
3) APPROVAL_DECISION
{
"approval_token": "appr_8f3c6c5d2c",
"decision": "APPROVED",
"approver": {
"type": "slack_user",
"id": "U123",
"display": "sam"
},
"decided_at": "2026-01-31T18:08:44Z",
"content_hash": "sha256:...",
"note": "LGTM"
}
4) PUBLISH_RESULT
{
"draft_id": "draft_2026_01_31_001",
"published": true,
"published_at": "2026-01-31T18:09:32Z",
"destination": {
"type": "github",
"ref": "main",
"path": "blog/2026-01-31-human-in-the-loop-approval-gates.mdx"
},
"idempotency_key": "appr_8f3c6c5d2c",
"permalink": "(optional)"
}
Slack vs Telegram: choosing the approval surface
Both work well. Pick based on who’s approving and how fast you need a decision.
Slack approval workflow: best for teams
Use Slack when:
- Approvals are shared (multiple people might review)
- You want approvals in threads
- You want a clear audit trail tied to a workspace identity
What “good” looks like in Slack:
- A preview snippet + “View full draft” link
- Two buttons: Approve / Reject
- The buttons carry an
approval_token(or an ID that maps to it)
Telegram inline button approval: best for founder speed
Use Telegram when:
- A single operator/founder is the approver
- You want one-tap approvals from your phone
- You need low-friction, fast response loops
What “good” looks like in Telegram:
- A compact preview + a short “diff” if applicable
- Inline keyboard with Approve / Reject
- A quick acknowledgement (so Telegram doesn’t keep the UI “loading”)
Implementation blueprint (channel-agnostic)
No matter what UI you use, the implementation is the same:
- Generate draft
- Send approval request (with a unique
approval_token) - Pause the workflow (checkpoint)
- Handle callback webhook (Slack action payload or Telegram callback)
- Persist
APPROVAL_DECISION - Resume workflow and publish only if approved
Architecture at a glance
[Agent: Draft] -> DRAFT_ARTIFACT
|
v
[Agent: Send Approval] -> APPROVAL_REQUEST ---> Slack/Telegram
|
v
[Workflow pauses at checkpoint]
|
v
[Webhook handler] -> APPROVAL_DECISION
|
v
[Agent: Publish (guarded)] -> PUBLISH_RESULT
Where nNode fits
This pattern is exactly what nNode is designed for:
- One agent, one task (draft agent, approval agent, publish agent)
- Artifacts as the data flow (draft → request → decision → publish result)
- Checkpoint/resume (pause a run until a human decides, then safely resume)
- Inspectable “white-box” execution (you can see every step and intermediate output)
In other words: approvals aren’t a bolted-on hack—they’re just a first-class workflow step.
Slack approval workflow: message + button payload strategy
A typical Slack approval message uses Block Kit with two buttons. The important part is that the button click triggers an interaction payload that your server receives.
Example: Slack message blocks (conceptual)
{
"text": "Approval needed: Publish blog post",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "*Publish blog post?*\n\n*Title:* Human-in-the-Loop Approval Workflow\n*Hash:* sha256:..."}},
{"type": "actions", "elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve"},
"style": "primary",
"action_id": "approve_publish",
"value": "appr_8f3c6c5d2c"
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Reject"},
"style": "danger",
"action_id": "reject_publish",
"value": "appr_8f3c6c5d2c"
}
]}
]
}
Parse the interaction safely
Two non-negotiables for Slack interactivity:
- Verify requests are really from Slack (don’t trust public webhooks)
- Ack fast and do the heavy work asynchronously
Below is a simplified Node/Express-style handler sketch.
import crypto from "crypto";
import express from "express";
const app = express();
// Important: Slack signature verification needs the *raw* request body.
app.use(
express.raw({ type: "application/x-www-form-urlencoded" })
);
function verifySlackSignature(req: express.Request) {
const timestamp = req.header("X-Slack-Request-Timestamp");
const slackSig = req.header("X-Slack-Signature");
if (!timestamp || !slackSig) return false;
// Optional but recommended: reject old timestamps to prevent replay.
const fiveMinutes = 60 * 5;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > fiveMinutes) return false;
const sigBase = `v0:${timestamp}:${req.body.toString("utf8")}`;
const mySig =
"v0=" +
crypto
.createHmac("sha256", process.env.SLACK_SIGNING_SECRET!)
.update(sigBase)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(mySig), Buffer.from(slackSig));
}
app.post("/webhooks/slack/approve", async (req, res) => {
if (!verifySlackSignature(req)) return res.status(401).send("bad signature");
// Parse x-www-form-urlencoded payload=... JSON (left as exercise)
const payload = parseSlackPayload(req.body);
// Ack quickly
res.status(200).send();
// Then: write APPROVAL_DECISION in your DB keyed by approval_token
// and trigger/resume your workflow.
await recordDecision({
approval_token: payload.actions[0].value,
decision: payload.actions[0].action_id === "approve_publish" ? "APPROVED" : "REJECTED",
approver: payload.user.id,
decided_at: new Date().toISOString()
});
});
Telegram inline button approval: callback_query strategy
Telegram approvals are typically done with an inline keyboard. When the user taps a button, you receive a callback_query.
Example: Telegram message with inline keyboard (conceptual)
{
"chat_id": 123456789,
"text": "Approve publish?\n\nTitle: Human-in-the-Loop Approval Workflow\nHash: sha256:...",
"reply_markup": {
"inline_keyboard": [[
{"text": "Approve", "callback_data": "approve:appr_8f3c6c5d2c"},
{"text": "Reject", "callback_data": "reject:appr_8f3c6c5d2c"}
]]
}
}
Secure your Telegram webhook
Telegram supports a secret_token you set when registering your webhook. Your server receives it in a header, which you can verify.
app.use(express.json());
app.post("/webhooks/telegram", async (req, res) => {
const secret = req.header("X-Telegram-Bot-Api-Secret-Token");
if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return res.status(401).send("unauthorized");
}
// Telegram update
const update = req.body;
// Always respond 200 quickly so Telegram doesn't retry excessively.
res.status(200).send("ok");
if (!update.callback_query) return;
const data: string = update.callback_query.data; // e.g. "approve:appr_..."
const [action, approvalToken] = data.split(":");
await recordDecision({
approval_token: approvalToken,
decision: action === "approve" ? "APPROVED" : "REJECTED",
approver: String(update.callback_query.from.id),
decided_at: new Date().toISOString()
});
// Then resume the paused workflow run.
await resumeWorkflowFromApprovalToken(approvalToken);
});
Reliability pitfalls (and how to avoid them)
Most “approval gate” bugs are not AI bugs—they’re distributed systems bugs. Here’s how to avoid the classics.
1) Double-clicks and webhook retries: use idempotency
If the approver taps twice (or Slack/Telegram retries a delivery), you must not publish twice.
Treat approval_token as an idempotency key everywhere:
- One decision per token
- One publish per token
Example DB constraints:
-- Only one decision per token
CREATE TABLE approval_decisions (
approval_token TEXT PRIMARY KEY,
decision TEXT NOT NULL,
approver_id TEXT NOT NULL,
decided_at TIMESTAMPTZ NOT NULL,
content_hash TEXT NOT NULL
);
-- Only one publish per token
CREATE TABLE publish_results (
approval_token TEXT PRIMARY KEY,
draft_id TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
destination JSONB NOT NULL
);
Then your publish step becomes:
- Check
APPROVAL_DECISION == APPROVED - Insert into
publish_results- If insert fails because it already exists: stop (already published)
2) “Approved the wrong version”: bind decision to content_hash
Make the UI show a short hash, and store it in both APPROVAL_REQUEST and APPROVAL_DECISION.
In the publish step, enforce:
APPROVAL_DECISION.content_hash must equal DRAFT_ARTIFACT.content_hash
3) Timeouts: explicit EXPIRED decisions
Add an expires_at to the request. If nobody approves in time, record:
decision: EXPIRED- notify the operator
- optionally re-request with a new
approval_token
4) Partial publish failures: record attempts + retry safely
Publishing often fails mid-flight (API timeouts, rate limits, 500s). Make your publish step produce a PUBLISH_RESULT even on failure:
- include an error code/message
- include whether it’s safe to retry
- include any created resource IDs (so you can detect duplicates)
Security checklist for approval gates
If you implement only one part of this post, implement this list.
Request authenticity
- Slack: verify signatures using your Slack Signing Secret.
- Telegram: set a webhook
secret_tokenand verify the header.
Approver authorization
- Maintain an allowlist of approver user IDs.
- Decide whether “anyone in the channel” can approve (usually: no).
Least-privilege credentials
- The publish agent should have only the credentials it needs.
- Separate “draft storage” credentials from “publish” credentials.
Audit logging by construction
- Store
APPROVAL_REQUESTandAPPROVAL_DECISIONartifacts. - Store
PUBLISH_RESULTartifacts. - Make them easy to inspect later.
The nNode approach: why this stays debuggable at 2,000 runs/week
Many teams can ship a working approval bot in a weekend.
The hard part is week 6, when:
- a webhook retries at 2am
- someone approves from their phone while you deploy
- a publish action partially succeeds
- you need to answer “what happened?” in 60 seconds
nNode’s architecture is built for this reality:
- Artifact-driven pipelines mean every step produces a named output you can inspect.
- One agent, one task keeps prompts small and failures local.
- Checkpoint/resume means you don’t keep a process running while you “wait for a human.” You pause deterministically, then resume safely when the decision arrives.
- Workflow-level observability (job history + artifact viewer) makes approvals auditable without building a custom admin UI from scratch.
If you’re currently wiring approval gates into a single massive agent script, nNode is the “middle layer” that makes it maintainable: more control than Zapier/n8n, less complexity than rolling your own orchestration framework.
Starter templates (copy the pattern, swap the channel)
Template A: Draft → Telegram approve → publish
- Agent generates draft and content hash
- Agent sends Telegram message with inline buttons
- Workflow pauses at checkpoint
- Telegram webhook handler records decision (idempotent)
- Workflow resumes and publishes if approved
Template B: Draft → Slack approve (thread) → publish
- Agent generates draft and content hash
- Agent posts Slack message (optionally in a thread)
- Workflow pauses at checkpoint
- Slack interaction handler records decision (signature-verified, idempotent)
- Workflow resumes and publishes if approved
Final takeaway
A human-in-the-loop approval workflow is not just “ask for confirmation.” It’s a repeatable, secure, resumable pattern:
- Approval is data (an artifact)
- Callbacks are idempotent
- Publishing is guarded
- Everything is auditable
If you want to build agent workflows that move fast without shipping accidents, nNode was built for exactly this style of “white-box” automation.
If you’d like, you can try nNode.ai and model your first approval-gated workflow as an artifact-driven pipeline (Draft → Approval → Publish) with checkpoint/resume and an inspectable run history.