Siddhant Deval
Siddhant Deval
frontend5 min read

Human-in-the-Loop & Durable Agent Workflows

Long-running agent workflows that execute destructive mutations — pushing database migrations, sending Slack announcements — cannot run in ephemeral HTTP handlers that die on server restart. This article implements step-level durable execution (Temporal/Inngest patterns) and a React approval UI that safely pauses autonomous agents pending human authorisation.

Series·Part 17 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Human-in-the-Loop & Durable Agent Workflows

An agent that runs database migrations, deploys configuration changes, or sends Slack announcements to a hundred engineers cannot execute those actions inside a stateless HTTP request handler. If the server restarts mid-execution, the agent loses its context, re-executes already-completed steps, or — worst case — applies a migration twice. The ephemeral request/response model is structurally incompatible with long-running autonomous work.

This article implements step-level durable execution for Workspace agent workflows: each completed step is checkpointed before the next begins, server restarts resume from the last checkpoint rather than from scratch, and destructive actions pause the entire workflow pending an explicit human authorisation in a React approval UI.

Architectural Note

Who this is for: Engineers who have run multi-step AI agents inside HTTP handlers and hit timeouts, lost state on server restart, or accidentally repeated a step that should have been idempotent. Durable execution makes step replay safe and human approval gates auditable.


1. The Ephemeral Handler Problem

1.1 What Breaks Inside a Stateless HTTP Handler

TYPESCRIPT
// ❌ Anti-Pattern — multi-step agent inside an HTTP handler
app.post('/agent/run', async (req, res) => {
  // Step 1: Research phase — 20 API calls, 45 seconds
  const research = await agent.research(req.body.topic)

  // Server restarts here (deployment, crash, OOM kill)
  // Step 1 is lost. Next request re-runs it. If it has side effects: duplicated.

  // Step 2: Draft document — another 30 API calls
  const draft = await agent.draft(research)

  // Step 3: Deploy schema changes — DESTRUCTIVE, IRREVERSIBLE
  await agent.deploySchema(draft.schema)  // Runs without any human review

  res.json({ done: true })
})

Three critical failures:

  1. No checkpoint: server restart re-runs all steps from scratch
  2. No human gate: destructive deploySchema runs automatically
  3. No audit trail: no record of what steps ran, with what inputs, at what time

2. Durable Execution Pattern (Inngest)

2.1 Step-Level Checkpointing

Inngest (and Temporal) provide durable functions: each step.run() call is automatically checkpointed. If the function is interrupted, it replays from the last successfully completed step — not from the beginning.

TYPESCRIPT
// ✅ Durable agent workflow with step checkpoints
// file: apps/gateway/src/workflows/ResearchAndDraftWorkflow.ts

import { inngest } from '../inngest'
import { FsmAgentRuntime } from '../agents/FsmAgentRuntime'
import { emitApprovalRequest } from '../events/ApprovalEvents'

export const researchAndDraftWorkflow = inngest.createFunction(
  {
    id: 'workspace-research-and-draft',
    retries: 3,
    concurrency: [{ limit: 10, key: 'event.data.orgId' }],
  },
  { event: 'workspace/agent.run' },
  async ({ event, step }) => {
    const { orgId, userId, taskDescription, documentId } = event.data

    // ── Step 1: Research phase (checkpointed) ──────────────────────────────
    const researchResults = await step.run('research-phase', async () => {
      const agent = new FsmAgentRuntime({
        maxSteps: 10,
        maxTokensPerStep: 2048,
        tools: [searchKnowledgeTool(orgId)],
        systemPrompt: 'You are a research assistant. Search the knowledge vault and compile findings.',
        requireHumanApproval: () => false,
      })
      return agent.run(`Research topic: ${taskDescription}`)
    })
    // ↑ If server restarts here, Inngest replays from Step 2 with researchResults already resolved

    // ── Step 2: Draft generation (checkpointed) ────────────────────────────
    const draftContent = await step.run('draft-generation', async () => {
      const agent = new FsmAgentRuntime({
        maxSteps: 8,
        maxTokensPerStep: 4096,
        tools: [appendToDocumentTool(orgId, documentId)],
        systemPrompt: 'You are a technical writer. Draft a document section based on research findings.',
        requireHumanApproval: () => false,
      })
      return agent.run(`Draft based on research:\n${researchResults}`)
    })

    // ── Step 3: Schema deployment — REQUIRES HUMAN APPROVAL ───────────────
    const schemaChangePayload = extractSchemaChanges(draftContent)

    if (schemaChangePayload) {
      // Emit approval request event — workflow pauses here
      await step.run('emit-approval-request', async () => {
        await emitApprovalRequest({
          workflowId: event.id,
          orgId,
          userId,
          action: {
            type: 'deploy_schema',
            description: 'Deploy database schema changes derived from research',
            payload: schemaChangePayload,
            blastRadius: 'All tables in workspace database',
            reversible: false,
          },
        })
      })

      // Wait for human decision — Inngest pauses the function until event arrives
      const { data: decision } = await step.waitForEvent('await-human-approval', {
        event: `workspace/approval.decided`,
        match: 'data.workflowId',
        timeout: '24h',  // Auto-cancel if no human response within 24 hours
      })

      if (decision.approved !== true) {
        return { status: 'rejected', reason: decision.reason }
      }

      // ── Step 4: Execute after approval (checkpointed) ──────────────────
      await step.run('deploy-schema', async () => {
        await deploySchemaChanges(orgId, schemaChangePayload)
      })
    }

    return { status: 'completed', documentId }
  },
)

3. React Approval UI

3.1 The Approval Interface

The approval UI must show the exact blast radius of the proposed action — not a generic "approve?" dialog.

TYPESCRIPT
// ✅ Human-in-the-Loop approval card
// file: apps/web/src/features/approvals/ApprovalCard.tsx

import { useState } from 'react'
import type { ApprovalRequest } from '@workspace/types'

interface Props {
  request: ApprovalRequest
  onDecide: (approved: boolean, reason?: string) => Promise<void>
}

export function ApprovalCard({ request, onDecide }: Props) {
  const [loading, setLoading] = useState(false)
  const [reason, setReason] = useState('')

  const handleDecision = async (approved: boolean) => {
    if (!approved && !reason.trim()) return  // Require reason for rejection
    setLoading(true)
    await onDecide(approved, approved ? undefined : reason)
    setLoading(false)
  }

  return (
    <div className="approval-card" role="dialog" aria-labelledby="approval-title">
      <header className="approval-card__header">
        <span className="approval-card__badge approval-card__badge--pending">
          ⏸ Awaiting Your Approval
        </span>
        <h2 id="approval-title">Agent Action Requires Authorisation</h2>
      </header>

      <section className="approval-card__action">
        <h3>Proposed Action</h3>
        <p>{request.action.description}</p>
      </section>

      {/* Blast radius — always shown for destructive actions */}
      <section className="approval-card__blast-radius">
        <h3>⚠ Blast Radius</h3>
        <p>{request.action.blastRadius}</p>
        <dl>
          <dt>Reversible:</dt>
          <dd className={request.action.reversible ? 'text-green' : 'text-red'}>
            {request.action.reversible ? 'Yes' : 'No — this action cannot be undone'}
          </dd>
        </dl>
      </section>

      {/* Payload preview — diffs for schema, previews for content */}
      <details className="approval-card__payload">
        <summary>View full payload</summary>
        <pre><code>{JSON.stringify(request.action.payload, null, 2)}</code></pre>
      </details>

      <footer className="approval-card__actions">
        <div>
          <label htmlFor="rejection-reason">Rejection reason (required to reject)</label>
          <input
            id="rejection-reason"
            value={reason}
            onChange={e => setReason(e.target.value)}
            placeholder="Explain why this action should not proceed..."
            disabled={loading}
          />
        </div>
        <div className="approval-card__buttons">
          <button
            className="button button--danger"
            onClick={() => handleDecision(false)}
            disabled={loading || !reason.trim()}
            aria-label="Reject and stop this agent action"
          >
            Reject
          </button>
          <button
            className="button button--primary"
            onClick={() => handleDecision(true)}
            disabled={loading}
            aria-label="Approve and allow this agent action to proceed"
          >
            {loading ? 'Processing...' : 'Approve'}
          </button>
        </div>
      </footer>
    </div>
  )
}

3.2 Real-Time Approval Notification

TYPESCRIPT
// ✅ Approval notification hook — real-time via SSE
// file: apps/web/src/features/approvals/useApprovalRequests.ts

import { useSyncExternalStore } from 'react'

export function useApprovalRequests(orgId: string) {
  // Subscribe to approval SSE channel
  const [requests, setRequests] = useState<ApprovalRequest[]>([])

  useEffect(() => {
    const controller = new AbortController()

    async function subscribe() {
      const response = await fetch(`/api/approvals/stream?orgId=${orgId}`, {
        headers: { 'Accept': 'text/event-stream' },
        signal: controller.signal,
      })
      for await (const event of fetchSSE(response)) {
        const request = JSON.parse(event.data) as ApprovalRequest
        setRequests(prev => [...prev.filter(r => r.id !== request.id), request])
      }
    }

    subscribe().catch(console.error)
    return () => controller.abort()
  }, [orgId])

  const decide = async (requestId: string, approved: boolean, reason?: string) => {
    await fetch(`/api/approvals/${requestId}/decide`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ approved, reason }),
    })
    setRequests(prev => prev.filter(r => r.id !== requestId))
  }

  return { requests, decide }
}

Summary

Problem Solution
Server restart loses step state step.run() checkpoints each phase; replay resumes from last completed step
Destructive actions run unchecked step.waitForEvent() pauses the workflow until a human approval/rejection arrives
Blank approval dialogs Approval card shows action type, description, blast radius, reversibility, and full payload
24h timeout step.waitForEvent(timeout: '24h') auto-cancels pending approvals — no zombie workflows

What's Next

Part 18 orchestrates multiple specialised agents into a collaborative supervisor-worker swarm — with a shared blackboard memory and canvas concurrency locks enabling a Researcher, Schema Engineer, and Reviewer to co-author a technical document on the shared Workspace canvas.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Human-in-the-Loop#Durable Execution#React#AI Agents#Temporal#Inngest#Node.js
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 17 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
Reading
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace