workflowshuman-in-the-loopagentsdurable-executionidempotencyclaude-skills

Human-in-the-Loop Agent Workflows: Durable Approvals + Checkpoints (No Reruns, No Double-Sends)

nNode Team10 min read

If you’ve ever shipped a human in the loop agent workflow, you’ve probably hit the same wall:

  • The agent creates something valuable (a draft, a decision, a report).
  • You need a human to approve it.
  • The workflow “waits”… and then everything falls apart: timeouts, reruns, duplicate emails, double-posts, or a missing audit trail.

This post shows a production-shaped pattern for building durable human approval workflows—the kind you can trust for real operations—using checkpoints, explicit artifacts, and idempotent side effects.

If you’re building Claude Skills (or any LLM “skill” that calls tools), this is the missing layer: your skill might be smart, but your business process needs to be reliable.

The real problem: approvals break “autonomous” agents in the last mile

Most “agent demos” assume:

  1. The model runs once.
  2. The model always finishes.
  3. You’re okay rerunning it if it doesn’t.

But approvals create a hard boundary:

  • The workflow has to pause for minutes, hours, or days.
  • The workflow must resume without repeating earlier steps.
  • The workflow must never repeat side effects (posting, sending, charging).
  • You need an audit trail for what was approved and why.

A flaky approval workflow doesn’t just waste tokens—it creates real damage: brand risk, customer confusion, duplicated work, and “I don’t trust automation” scars.

What a durable approval workflow must guarantee

Here’s the bar you should hold yourself to:

  1. Pause/resume across time (hours/days) without keeping a process alive.
  2. Resume-from-checkpoint, not “start over.”
  3. Exactly-once side effects (or at least effectively exactly-once through idempotency).
  4. Deterministic downstream steps after approval (approval decision is explicit data).
  5. Inspectable state: you can open the run and see what happened.

This is less about “prompting” and more about execution design.

Reference architecture (simple, durable, debuggable)

A clean approval flow looks like this:

[Generate] → [Research] → [Draft] → [CHECKPOINT]
                               ↓
                         [Notify Human]
                               ↓
                        [WAIT FOR APPROVAL]
                               ↓ (webhook callback)
                           [Approve?]
                         /            \
                   [Publish]        [Reject/Revise]
                      ↓                 ↓
               [Record Result]   [Create New Draft]

Two key ideas make this durable:

  • Checkpoint the workflow state before you notify.
  • Model the approval as an explicit artifact, not an implicit “someone clicked a button.”

Define your artifacts (this is where most systems go wrong)

A human-in-the-loop workflow becomes reliable when the data boundary is explicit. Here’s a practical artifact set:

  • DRAFT — the generated content (markdown, JSON, etc.)
  • CONTEXT_SOURCES — URLs / notes / citations used to draft
  • APPROVAL_REQUEST — exactly what you asked the human to approve (render-ready)
  • APPROVAL_DECISION — approved/rejected + who/when + optional feedback
  • PUBLISH_RESULT — external IDs/links, timestamps, and idempotency key used

This yields two benefits:

  1. Debuggability: You can inspect the run and see what the model produced at each step.
  2. Determinism: Downstream steps operate on the artifact values, not “whatever the model remembers.”

A concrete APPROVAL_DECISION shape

Keep it boring and machine-friendly:

{
  "decision": "approved",
  "approved_by": "@sam",
  "approved_at": "2026-01-16T14:22:10Z",
  "request_id": "apr_01H...",
  "notes": "Looks good—ship it.",
  "revision_requested": null
}

If someone asks for changes, capture it explicitly:

{
  "decision": "revise",
  "approved_by": "@sam",
  "approved_at": "2026-01-16T14:22:10Z",
  "request_id": "apr_01H...",
  "notes": "Tone is too salesy. Make it more technical.",
  "revision_requested": {
    "instructions": "Cut the hype, add an idempotency example and a failure-mode section."
  }
}

Checkpointing strategy: where to save state (and why)

The safest checkpoint pattern is end-of-step checkpointing.

  • After Draft, save state.
  • After Notify, save state.
  • After Publish, save state.

That way, when the workflow resumes, you can start from the last known-good boundary.

What should be persisted at a checkpoint?

At minimum:

  • Artifact values (the inputs to future steps)
  • Execution log (what ran, when, with what parameters)
  • External correlation IDs (approval request ID, message ID, etc.)

Don’t “wait” inside the agent

Avoid a design where your agent process sleeps and polls for approval.

Instead:

  1. The workflow creates an approval request, stores it, sends a notification.
  2. The workflow stops (checkpointed).
  3. The human click triggers a webhook callback that resumes (or starts) the next phase.

This is how you get days-long approvals without fragile infrastructure.

Approval channels: Telegram vs Slack vs email (tradeoffs)

Choose your channel based on latency and UX:

  • Telegram inline buttons: very fast, great UX for solo founders, easy approve/reject.
  • Slack interactive messages: great for teams, identity + permissions, richer workflows.
  • Email: universal, but approval UX is weaker (links, auth, delays).

The channel doesn’t matter as much as the contract:

  • Every approval action produces exactly one APPROVAL_DECISION artifact.
  • Every decision maps to a unique request_id.

The “no double-send” section: idempotency playbook for side effects

If you only take one thing from this post, take this:

Your workflow can be retried; your side effects must be idempotent.

Side effects include:

  • Sending an email
  • Posting to X/LinkedIn
  • Creating an invoice
  • Charging a card
  • Updating a CRM field

Pattern 1: Idempotency keys (the default)

Before you call the external API, compute an idempotency key.

A good key is stable, unique, and derived from deterministic state:

idempotency_key = sha256(
  workflow_run_id + ":" + step_name + ":" + request_id
)

Send it to the external system if supported (many APIs accept an idempotency header), and store it locally either way.

Pattern 2: Write-ahead record (“outbox” for automation)

Even if the external API doesn’t support idempotency, you can create your own “exactly-once-ish” behavior.

Create a table like:

create table side_effects (
  id bigserial primary key,
  workflow_run_id text not null,
  request_id text not null,
  step_name text not null,
  idempotency_key text not null,
  status text not null, -- pending | success | failed
  external_id text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (idempotency_key)
);

Then:

  1. Insert pending with unique(idempotency_key).
  2. If insert fails (duplicate), you already did it—return the stored result.
  3. Only then call the external API.
  4. Update to success with external_id.

This prevents double-sends even when your workflow runner retries.

Pattern 3: Dedupe by external IDs

For some APIs you can query by content or metadata (e.g., “find scheduled post by title + timestamp”). It’s slower and less reliable than idempotency keys, but still better than nothing.

A minimal implementation (using webhooks + artifacts)

Below is a compact reference you can adapt whether you’re using nNode, a custom orchestrator, or a Claude Skill wrapper.

Step A — Generate and checkpoint

## Step: Generate Draft
- Input: TOPIC, BRAND_GUIDELINES
- Output artifact: DRAFT
- Output artifact: CONTEXT_SOURCES

## Step: Create Approval Request
- Output artifact: APPROVAL_REQUEST
  - request_id
  - preview_text
  - deep_link_to_job

## Step: CHECKPOINT
- Persist artifacts + logs

Step B — Notify (Telegram inline buttons example)

// Pseudo-code: send Telegram message with inline buttons
await telegram.sendMessage({
  chat_id: TELEGRAM_CHAT_ID,
  text: approvalRequest.preview_text,
  reply_markup: {
    inline_keyboard: [[
      { text: "Approve", callback_data: `approve:${approvalRequest.request_id}` },
      { text: "Reject",  callback_data: `reject:${approvalRequest.request_id}` }
    ]]
  }
});

Important: store the Telegram message_id in APPROVAL_REQUEST so you can edit/update the message later.

Step C — Webhook callback → create APPROVAL_DECISION

// Node/Express-ish webhook handler for Telegram callbacks
app.post("/telegram/callback", async (req, res) => {
  const cb = req.body.callback_query;
  const [action, requestId] = cb.data.split(":");

  // 1) Convert click into an explicit APPROVAL_DECISION
  const decision = {
    decision: action === "approve" ? "approved" : "rejected",
    approved_by: cb.from.username ? `@${cb.from.username}` : String(cb.from.id),
    approved_at: new Date().toISOString(),
    request_id: requestId,
    notes: null
  };

  // 2) POST decision into your orchestrator (wake the workflow)
  await fetch(process.env.NNODE_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ request_id: requestId, approval_decision: decision })
  });

  res.sendStatus(200);
});

In nNode terms, that “wake the workflow” endpoint maps naturally to a webhook-triggered workflow (e.g., /wh/telegram_approval_callback).

Step D — Resume and publish (idempotent)

## Step: Publish
- Precondition: APPROVAL_DECISION.decision == "approved"
- Compute idempotency_key = hash(run_id + step + request_id)
- Upsert side_effects(idempotency_key)
- Call external publish API
- Output artifact: PUBLISH_RESULT

## Step: Record Result
- Save publish URL/external_id
- CHECKPOINT

If publishing fails, you can retry without double-sending because the publish step is guarded by the idempotency key + outbox row.

Failure modes (and how to debug them without panic)

1) “The callback never arrives”

Common causes: webhook misconfig, auth, Telegram/Slack blocked.

Fix by designing an observable workflow:

  • APPROVAL_REQUEST contains the channel message ID and request ID.
  • A dashboard (or logs) can show “awaiting approval since X.”
  • Add a timed reminder: if awaiting_approval > 24h, notify again.

2) “User requested edits”

Don’t hack this into the prompt. Model it as a branch:

  • If APPROVAL_DECISION.decision == "revise", run a Revise Draft step.
  • Keep the old draft as DRAFT_V1 and produce DRAFT_V2.

You now have history and can compare versions.

3) “Publish failed after approval”

This is exactly why checkpointing matters.

You should be able to:

  • Resume from Publish only (not rerun research/drafting)
  • Fix credentials / rate limits
  • Retry publish with the same idempotency key

4) “Reviewer caught a hallucination”

Great—your human-in-the-loop saved you.

Turn the correction into structured feedback:

  • Add a FACT_CHECK_NOTES artifact
  • Re-run only the draft step with constraints (“must cite the source URLs in CONTEXT_SOURCES”)

Why this maps cleanly to Claude Skills users

Claude Skills are excellent for capability: calling tools, generating drafts, transforming data.

But “production” is mostly about:

  • state
  • retries
  • audit logs
  • approvals
  • idempotency

In other words: orchestration.

If you’re wrapping a Claude Skill in a business workflow, the architecture above is what turns a cool demo into something you can trust.

Where nNode fits (and why it’s different)

nNode is a high-level programming language for building business automations that are easy to write, debug, and modify.

This approval pattern aligns with how nNode is built:

  • Artifacts as the data flow: drafts, decisions, and results are explicit and inspectable.
  • Checkpoint resumability: workflows can resume from a checkpoint instead of rerunning everything.
  • One agent, one task: approvals become a clean step boundary rather than “a mega-prompt that does everything.”
  • Webhooks + job runner model: approvals come in as callbacks that wake a workflow.

If you’ve ever tried to debug a 12,000-line agent script (or a tangled no-code zap), you’ll appreciate why “stepwise + inspectable” is the core design constraint.

Copy/paste checklist (build this in an afternoon)

Use this as your build checklist:

  1. Create DRAFT + CONTEXT_SOURCES artifacts.
  2. Create APPROVAL_REQUEST with a stable request_id.
  3. Checkpoint state before notifying.
  4. Send approval message with buttons/links.
  5. Webhook callback writes APPROVAL_DECISION.
  6. Resume workflow from checkpoint.
  7. Publish step uses idempotency keys + outbox table.
  8. Store PUBLISH_RESULT and checkpoint.
  9. Add “revise” branch and version drafts.
  10. Add a reminder for long-pending approvals.

Next: templates worth building

Once you have the pattern, it composes nicely:

  • Content approval worker (blog/social/email)
  • Outreach approval worker (lead list → draft emails → approve → send)
  • Invoice approval worker (extract line items → approve → send invoice)
  • Data change approval worker (detect change → propose action → approve → write)

Each is the same core: checkpoint → notify → callback → idempotent side effect.


If you want a workflow engine that treats this as a first-class problem—checkpointed, debuggable, artifact-driven automations—take a look at nNode.ai. Start by building one approval-gated workflow (content or outreach) and you’ll immediately feel the difference between “agent runs” and “durable business execution.”

Build your first AI Agent today

Join the waiting list for nNode and start automating your workflows with natural language.

Get Started