You have been asked to build an agent workflow that can pause before a sensitive action, wait for a human decision, and then continue safely.

At first glance, the flow looks simple:

agent proposes an action
→ human reviews it
→ approve, edit, reject, or request revision
→ workflow continues

The backend challenge is making that flow durable.

A review may take hours or days. Services may restart. New code may be deployed while the workflow is waiting. The same approval may be submitted twice. The external action may succeed even if the worker times out.

This is therefore not just an approval UI. It is a long-running workflow with a human-controlled state transition.

The core architecture

The most important design decision is to separate workflow execution state from business approval state.

The workflow runtime should own:

  • Step ordering
  • Durable waits
  • Timers
  • Retries
  • Cancellation
  • Worker dispatch
  • Recovery after failure

The application should own:

  • Users and permissions
  • Approval requests
  • The exact proposed action
  • Proposal versions
  • Reviewer decisions
  • Business audit records
  • External side-effect status
Workflow runtime
    continuation, retries, timers, scheduling

Application database
    approvals, permissions, artifacts, audit history

Workflow history is useful for recovering execution, but it should not be the only record of what a human approved.

Reference design

                    ┌─────────────────┐
                    │ Client / Admin UI│
                    └────────┬────────┘
                    review and approve
                    ┌────────▼─────────┐
                    │ Application API  │
                    │ auth and tenancy │
                    └─────┬───────┬────┘
                          │       │
                 business state  workflow commands
                          │       │
              ┌───────────▼──┐ ┌──▼────────────────┐
              │ PostgreSQL   │ │ Workflow runtime  │
              │ approvals    │ │ waits and retries │
              │ artifacts    │ │ worker dispatch   │
              │ audit/outbox │ └──┬────────────────┘
              └───────────┬──┘    │
                          │       │
                     ┌────▼───────▼─────┐
                     │ Agent/tool workers│
                     │ LLMs, APIs, tools │
                     └─────────┬────────┘
                     ┌─────────▼────────┐
                     │ External systems │
                     └──────────────────┘

A typical workflow is:

  1. Start a workflow.
  2. Gather context.
  3. Run the agent.
  4. Produce a proposed action.
  5. Store an immutable approval request.
  6. Pause the workflow.
  7. Accept a human decision through the application API.
  8. Resume the workflow.
  9. Execute the approved action idempotently.
  10. Record the final result.

Approval data model

An approval should not be represented as a single boolean.

A useful approval record contains:

id
tenant_id
workflow_run_id
status
proposal_version
proposed_action
context_snapshot
assigned_user_or_group
decided_by
decision
expires_at
created_at
decided_at

Typical outcomes include:

APPROVED
APPROVED_WITH_CHANGES
REJECTED
REVISION_REQUESTED
EXPIRED
CANCELLED

proposed_action describes what the agent wants to do.

{
  "type": "publish_article",
  "artifact_id": "artifact-790",
  "destination": "company-blog"
}

context_snapshot contains, or immutably references, exactly what the reviewer saw.

An approval must apply to one specific proposal version. If the agent modifies the action after review started, the old approval should become stale.

API boundary

Keep the public API independent of the selected workflow platform:

POST /agent-runs
GET  /agent-runs/{id}
GET  /approvals?status=pending
POST /approvals/{id}/decision
POST /agent-runs/{id}/cancel

A decision request should include the expected proposal version:

{
  "decision": "APPROVED_WITH_CHANGES",
  "expected_proposal_version": 7,
  "edited_artifact_id": "artifact-791"
}

Reject the decision if the active proposal is now version 8.

The browser should not directly call a Temporal Signal, complete a Trigger.dev waitpoint, or resume a LangGraph thread. Route decisions through the application API so it can enforce:

  • Authentication
  • Tenant isolation
  • Reviewer permissions
  • Expiry
  • Proposal-version checks
  • Idempotency
  • Audit logging

Resuming the workflow reliably

When a decision is submitted, two systems need updating:

  1. The application database
  2. The workflow runtime

Calling both directly creates a dual-write problem.

For example:

save approval in PostgreSQL
→ process crashes
→ workflow never receives resume message

Use a transactional outbox instead.

In one database transaction:

  1. Validate the decision.
  2. Update the approval.
  3. Append an audit event.
  4. Insert an outbox record.
  5. Commit.

An outbox worker then sends the resume message to the workflow runtime. Failed delivery can be retried without losing the human decision.

Make external actions idempotent

Assume workers and messages may execute more than once.

A worker can successfully issue a refund, publish an article, or send an email, then crash before recording success. The workflow runtime may retry the step.

Generate a stable idempotency key from the approved action:

publish:approval-456:version-7

Store each execution in a side-effect ledger:

id
workflow_run_id
tool_name
idempotency_key
request
response
status

The worker should:

  1. Look up the idempotency key.
  2. Return the previous result if already completed.
  3. Call the provider using the same key where supported.
  4. Record the result.
  5. Reconcile ambiguous outcomes separately.

The practical guarantee is not global exactly-once execution. It is:

at-least-once delivery
+ idempotent side effects
+ reconciliation

Comparing established solutions

Temporal

Temporal is the strongest fit when the agent is one part of a long-running or cross-service business process.

A Temporal system has:

  • The Temporal Service, which stores workflow history and schedules work
  • Workflow Workers, which run orchestration logic
  • Activity Workers, which perform external I/O

Workflow code is replayed to reconstruct state, so it must remain deterministic. LLM calls, database writes, HTTP requests, and tool execution belong in Activities.

A simplified workflow looks like:

@workflow.defn
class PublishWorkflow:
    def __init__(self):
        self.review = None

    @workflow.signal
    async def submit_review(self, review):
        self.review = review

    @workflow.run
    async def run(self, request):
        draft = await workflow.execute_activity(
            generate_draft,
            request,
            start_to_close_timeout=timedelta(minutes=10),
        )

        approval = await workflow.execute_activity(
            create_approval_request,
            draft,
            start_to_close_timeout=timedelta(seconds=30),
        )

        await workflow.wait_condition(
            lambda: self.review is not None
        )

        if self.review["decision"] == "REJECTED":
            return {"status": "rejected"}

        return await workflow.execute_activity(
            execute_approved_action,
            self.review,
            start_to_close_timeout=timedelta(minutes=5),
        )

Temporal works well with existing in-house components:

Application API
    authentication, approval endpoints, product queries

PostgreSQL
    approval records, artifacts, audit log, outbox

Temporal
    durable orchestration, timers, retries, cancellation

In-house workers
    LLM gateway, payment service, publishing, notifications

Observability stack
    logs, metrics, traces, workflow identifiers

Temporal should coordinate those systems rather than replace them.

Its main advantages are:

  • Strong recovery semantics
  • Long-running workflows
  • Cross-service coordination
  • Explicit retries and cancellation
  • Independent worker scaling
  • Mature operational tooling

Its main costs are:

  • Deterministic workflow constraints
  • Workflow-versioning complexity
  • Additional infrastructure and concepts
  • Significant programming-model commitment

Temporal Cloud charges for workflow actions, history storage, and support tiers. The server is open source, which provides a self-hosting route, but active workflows and replay semantics still create meaningful architectural lock-in.

Use Temporal when failure handling and long-running coordination are core system requirements.

Trigger.dev

Trigger.dev is a more product-oriented task runtime, particularly attractive for TypeScript teams.

Its waitpoint tokens allow a task to pause until an external system completes the token.

export const publishArticle = task({
  id: "publish-article",

  run: async ({ topic }: { topic: string }) => {
    const draft = await generateDraft(topic);

    const token = await wait.createToken({
      timeout: "3d",
      idempotencyKey: `review:${draft.id}:${draft.version}`,
    });

    await createApprovalRequest({
      tokenId: token.id,
      draft,
    });

    const result =
      await wait.forToken<ReviewDecision>(token.id);

    if (!result.ok) {
      return { status: "expired" };
    }

    return publishApprovedArtifact(result.output);
  },
});

The surrounding architecture remains similar:

Application API
    owns authentication and approval logic

PostgreSQL
    owns approval and audit data

Trigger.dev
    runs tasks, waits, retries, and manages concurrency

Tool services
    execute external actions

Trigger.dev is easier to adopt when the workflow mainly consists of:

  • TypeScript tasks
  • LLM calls
  • Webhooks
  • Background processing
  • External APIs
  • Product-facing progress updates

It combines orchestration and managed execution more closely than Temporal. That reduces initial platform work but increases coupling to its SDK, queues, waitpoints, deployment model, and Realtime features.

Pricing typically depends on plan limits, compute, concurrency, and usage credits. Self-hosting is available, although the team then owns security, upgrades, scaling, and reliability.

Use Trigger.dev when speed to production matters more than having a general-purpose workflow platform.

LangGraph

LangGraph is best understood as an agent runtime rather than a complete business-process engine.

It models an agent as a graph containing:

  • Nodes
  • Edges
  • Shared state
  • Persistent checkpoints
  • Interrupts

An agent can pause using interrupt() and later resume with external input.

def review_tool_call(state):
    decision = interrupt({
        "tool": state["tool_name"],
        "arguments": state["tool_arguments"],
        "proposal_version": state["proposal_version"],
    })

    return {
        "review_decision": decision
    }

This is well suited to reviewing:

  • Tool calls
  • Generated content
  • Agent plans
  • Structured outputs
  • Revision loops

LangGraph works best when the hard part is the agent’s internal reasoning rather than the surrounding business process.

Your application should still own the formal approval record, permissions, audit trail, and side-effect ledger.

There are two common deployment patterns.

LangGraph as the main runtime

Use this when the system is mostly one agent graph and human review happens inside that graph.

agent runs
→ proposes tool call
→ LangGraph interrupt
→ human reviews
→ graph resumes

LangGraph inside Temporal

Use this when the agent is one bounded stage inside a larger process.

Temporal workflow
    → invoke LangGraph agent
    → receive proposed action
    → create formal approval
    → wait for human
    → execute approved action

Avoid leaving a LangGraph interrupt open inside a long-running Temporal Activity. Let LangGraph return the proposal, then let Temporal own the durable human wait.

LangGraph itself is open source, so direct vendor lock-in is relatively low. Lock-in increases when using managed deployment, hosted persistence, proprietary thread APIs, and platform-specific observability.

Use LangGraph when you need fine-grained control over reasoning, tool calls, and iterative human intervention.

Summary

Area Temporal Trigger.dev LangGraph
Main abstraction Durable business workflow Durable application task Stateful agent graph
HITL primitive Signal or Update Waitpoint token Interrupt and resume
Best fit Cross-service, critical workflows TypeScript product workflows Agent reasoning and tool review
External I/O Activities Task code Nodes and tools
Operational effort Medium to high Low on managed Cloud Depends on deployment
Main lock-in Workflow execution model SDK and managed runtime Graph state and checkpoints
Typical role Outer workflow runtime Combined task/workflow runtime Inner agent runtime

Other relevant options include Restate for durable service execution, Inngest for event-driven durable functions, Camunda for human-heavy BPMN processes, and AWS Step Functions or Azure Durable Functions for teams already committed to a specific cloud.

What changes in an event-driven system?

The overall architecture remains the same.

The main difference is how workflows are started, resumed, and how outcomes are published.

Instead of the API directly starting every workflow, a business event may start it:

DOCUMENT_UPLOADED
→ workflow-start consumer
→ start agent workflow

A human decision may also produce an event:

APPROVAL_GRANTED
→ workflow-resume consumer
→ signal workflow

The workflow may publish an outcome event:

ARTICLE_PUBLISHED
PAYMENT_REFUNDED
CASE_ESCALATED

Use the following ownership model:

Event broker
    distributes facts between services

Workflow runtime
    decides what happens next in one process

Application database
    stores approval and business truth

Do not use the event broker itself as the workflow engine. Reconstructing waiting workflows, timers, retries, and cancellation purely from Kafka or Pulsar events usually spreads orchestration state across consumers.

In an event-driven design:

  • Use deterministic workflow IDs to deduplicate start events.
  • Store processed event IDs or enforce unique constraints.
  • Propagate event_id, correlation_id, causation_id, and workflow_id.
  • Publish domain events through a transactional outbox.
  • Keep consumers idempotent.
  • Avoid multiple layers independently retrying the same operation.

For example:

business event
→ idempotent consumer starts workflow
→ agent creates proposal
→ workflow waits for approval
→ approval API commits decision and outbox event
→ event consumer resumes workflow
→ worker executes action idempotently
→ outcome event is published

Event-driven integration changes the edges of the system, not the core HITL model.

For a critical production system:

Application API and PostgreSQL
    approvals, permissions, artifacts, audit, outbox

Temporal
    long-running business workflow

LangGraph
    bounded agent reasoning

Tool workers
    idempotent external actions

For a smaller TypeScript product, Trigger.dev can replace Temporal while preserving the same application-owned approval boundary.

The essential flow is:

agent proposes
→ application stores immutable proposal
→ workflow pauses durably
→ human decides through authenticated API
→ decision commits with outbox message
→ workflow resumes
→ idempotent worker executes
→ application records final outcome

The workflow runtime owns continuation.

The application owns what was approved.

An event broker, when present, distributes the resulting business facts.