---
title: "Production Agent Architecture: Memory, Approval Gates, and Durable Checkpointing"
description: "What actually breaks when you put a LangGraph agent in production: memory interfaces for vector stores, human-in-the-loop approval routing for critical operations, TypeScript orchestrators with transactional state persistence, and the deployment considerations that don't appear in tutorials."
date: "August 05, 2025"
url: "https://himanshuat.com/blogs/production-grade-agent-architecture-memory-human-in-the-loop"
---
# Production Agent Architecture: Memory, Approval Gates, and Durable Checkpointing

Tutorial agents run to completion. A production agent reaches a critical operation (deploy to prod, wire a transfer, delete customer data) and has to stop, hold, and wait for a person.

> The hard part of a production agent is not the reasoning. It is stopping mid-run, surviving a process restart, and resuming without replaying the tool calls that already succeeded.

Three pieces carry that: a typed memory interface that keeps agent code away from vector store drivers, approval routing declared in the state schema, and an orchestrator that persists state on every transition.

---

## Chapter 0: What "production-ready" actually means here

An agent becomes production-ready when it can:

1. Retrieve episodic context from a vector store without coupling business logic to embedding provider APIs
2. Halt at critical operations and wait for a human decision, holding that state across process restarts
3. Resume from exactly the last saved checkpoint without re-executing completed steps
4. Expose an audit trail of every approval decision and state transition

None of these are available in the default LangGraph quickstart.

---

## Step 1: Put the vector store behind a typed interface

Agents should not call embedding APIs directly. Wrapping the vector store behind a typed interface makes the embedding provider swappable without touching agent logic.

```typescript
// interfaces/memory.ts
export interface MemoryRecord {
    id: string;
    content: string;
    timestamp: number;
    metadata?: Record<string, any>;
    embedding?: number[];
}

export interface IVectorStoreClient {
    /** Embeds content and stores memory records. */
    add(records: Omit<MemoryRecord, 'id' | 'embedding'>[]): Promise<void>;
    /** Cosine similarity search over stored records. */
    search(query: string, topK: number): Promise<MemoryRecord[]>;
}
```

### Qdrant implementation

```typescript
// services/vectorStoreClient.ts
import { v4 as uuidv4 } from 'uuid';
import { IVectorStoreClient, MemoryRecord } from '../interfaces/memory';
import { EmbeddingService } from './embeddingService';
import { QdrantClient } from '@qdrant/js-client-rest';

export class QdrantVectorStoreClient implements IVectorStoreClient {
    private readonly embeddingService: EmbeddingService;
    private readonly qdrantClient: QdrantClient;
    private readonly collectionName: string;

    constructor(embeddingService: EmbeddingService, qdrantHost: string, collectionName: string = "agent_memories") {
        this.embeddingService = embeddingService;
        this.qdrantClient = new QdrantClient({ host: qdrantHost });
        this.collectionName = collectionName;
    }

    public async initCollection(): Promise<void> {
        const { collections } = await this.qdrantClient.getCollections();
        const exists = collections.some(c => c.name === this.collectionName);
        if (!exists) {
            await this.qdrantClient.createCollection(this.collectionName, {
                vectors: { size: 1536, distance: 'Cosine' },
            });
        }
    }

    public async add(records: Omit<MemoryRecord, 'id' | 'embedding'>[]): Promise<void> {
        if (!records.length) return;
        const embeddings = await this.embeddingService.embedBatch(records.map(r => r.content));
        const points = records.map((record, i) => ({
            id: uuidv4(),
            vector: embeddings[i],
            payload: { content: record.content, timestamp: record.timestamp, metadata: record.metadata },
        }));
        await this.qdrantClient.upsert(this.collectionName, { wait: true, points });
    }

    public async search(query: string, topK: number): Promise<MemoryRecord[]> {
        if (!query) return [];
        const [queryEmbedding] = await this.embeddingService.embedBatch([query]);
        const results = await this.qdrantClient.search(this.collectionName, {
            vector: queryEmbedding,
            limit: topK,
            with_payload: true,
        });
        return results.map(r => ({
            id: r.id.toString(),
            content: (r.payload as any).content,
            timestamp: (r.payload as any).timestamp,
            metadata: (r.payload as any).metadata,
        }));
    }
}
```

The `EmbeddingService` is a thin wrapper over the OpenAI embeddings API. Swap it for a local `bge-small-en-v1.5` instance and the agent code does not change.

**The tell:** if replacing the embedding provider means editing a file with the word `agent` in its name, the interface is not doing its job.

---

## Step 2: Declare approval gates in the state schema

Tools declare whether they require authorization via an `isCritical` flag:

```typescript
// types/tools.ts
export interface Tool {
    name: string;
    description: string;
    func: (args: any) => Promise<string>;
    isCritical: boolean;
}

export const tools: Tool[] = [
    {
        name: "read_database",
        description: "Queries read replica analytics tables.",
        func: async (args: { query: string }) => `Data for: ${args.query}`,
        isCritical: false,
    },
    {
        name: "deploy_code",
        description: "Deploys code to production cluster.",
        func: async (args: { project_id: string; version: string }) =>
            `Deployed ${args.project_id} v${args.version}`,
        isCritical: true,
    }
];
```

The approval state lives directly in the agent state schema, so checkpointers persist pending decisions when the process suspends:

```typescript
// types/agent.ts
export interface AgentState {
    input: string;
    scratchpad: string[];
    tool_calls: { tool_name: string; args: Record<string, any> }[];
    final_answer?: string;
    awaitingHumanApproval: boolean;
    proposedAction?: {
        type: 'tool_call' | 'final_answer';
        details: any;
    };
    humanFeedback?: 'approve' | 'reject' | 'modify';
    modificationDetails?: string;
}
```

The execution graph routes critical actions to the approval gate before executing them:

```mermaid
flowchart TD
  T[agent_think] --> G{Evaluate action}
  G -->|isCritical = true| P[await_human_approval]
  G -->|isCritical = false| X[execute_tools]
  G -->|Final answer| E[END]
  P --> F[Receive human feedback]
  F --> T
  X --> T
```

**The tell:** if the pending decision lives anywhere outside the state object, the checkpointer will not restore the pause, and a restart drops the agent back into the tool call it was waiting on.

---

## Step 3: Persist state on every node transition

The orchestrator persists state before and after every node transition. A process restart loads the last checkpoint and continues from exactly that position.

```typescript
// orchestrator.ts
export class AgentOrchestrator {
    private store: Map<string, WorkflowState>;

    constructor(
        private workflowDefinition: WorkflowDefinition,
        private nodeFunctions: Record<string, AgentNodeFn>,
        store?: Map<string, WorkflowState>
    ) {
        this.store = store ?? new Map();
    }

    public async saveState(state: WorkflowState): Promise<void> {
        // Deep clone to prevent reference mutations corrupting stored state
        this.store.set(state.workflowId, JSON.parse(JSON.stringify(state)));
    }

    public async loadState(workflowId: string): Promise<WorkflowState | undefined> {
        const item = this.store.get(workflowId);
        return item ? JSON.parse(JSON.stringify(item)) : undefined;
    }

    public async execute(workflowId: string): Promise<WorkflowState> {
        let state = await this.loadState(workflowId);
        if (!state) throw new Error(`Workflow ${workflowId} not found.`);

        while (
            state.currentState !== 'complete' &&
            state.currentState !== 'failed' &&
            !state.interruptionDetails
        ) {
            const stepConfig = this.workflowDefinition[state.currentState];
            if (!stepConfig) throw new Error(`Unknown state: '${state.currentState}'`);

            const nodeFn = this.nodeFunctions[stepConfig.node];
            if (!nodeFn) throw new Error(`Missing node function: '${stepConfig.node}'`);

            try {
                state = await nodeFn(state);

                if (state.interruptionDetails) {
                    console.log(`[${state.workflowId}] Suspended at: ${state.lastExecutedNode}`);
                    await this.saveState(state);
                    return state;
                }

                state.currentState = typeof stepConfig.nextState === 'function'
                    ? stepConfig.nextState(state)
                    : stepConfig.nextState || 'complete';

                await this.saveState(state);

            } catch (error: any) {
                console.error(`[${workflowId}] Node failed:`, error.message);
                state.currentState = 'failed';
                state.data.error = error.message;
                await this.saveState(state);
                return state;
            }
        }
        return state;
    }

    public async resumeWithHumanInput(
        workflowId: string,
        humanInput: { decision: 'approved' | 'rejected'; comment?: string }
    ): Promise<WorkflowState> {
        let state = await this.loadState(workflowId);
        if (!state) throw new Error(`Workflow ${workflowId} not found.`);
        if (!state.interruptionDetails) throw new Error(`Workflow ${workflowId} is not paused.`);

        state.data.humanDecision = humanInput.decision;
        state.data.humanComment = humanInput.comment;
        state.interruptionDetails = undefined;

        if (humanInput.decision === 'approved') {
            state.data.humanApproved = true;
            state.currentState = 'propose_action';
        } else {
            state.data.humanApproved = false;
            state.currentState = 'failed';
            state.data.error = 'Operation rejected by human reviewer.';
        }

        await this.saveState(state);
        return this.execute(workflowId);
    }
}
```

**The tell:** kill the process mid-run and start it again. If the agent repeats a tool call it already made, the save is landing on the wrong side of the node boundary.

---

## What breaks it in production

**Transactional persistence.** The in-memory `Map` above is development-only. In production, replace it with Redis (for low-latency state reads) or Postgres (for durable ACID transactions). Add optimistic locking to prevent concurrent writes from two resume calls on the same workflow.

**Audit logging.** Record operator user ID, timestamp, decision, and justification comment alongside every approval state transition. This is non-negotiable for any operation that affects financial records, infrastructure, or customer data.

**Idempotent node execution.** Nodes that make external API calls must handle retries gracefully. If a process crash occurs after `deploy_code` executes but before the state is saved, the orchestrator will re-invoke the node on resume. Either use idempotency keys or check whether the action already completed before executing.

**Rejection paths need explicit states.** A rejected approval should not silently set `currentState = 'failed'` and stop. In financial or infrastructure workflows, rejected operations often need a compensation step: reversing a partial action, notifying a queue, or escalating to a higher approval tier.

---

## When this is the wrong choice

- **Low-stakes reversible operations.** If the action can be undone in under 30 seconds, the approval latency usually costs more than the mistake.
- **High-throughput pipelines.** Approval gates that block on human response time don't work at scale. Pre-approve categories of operations and use post-hoc audit review instead.
- **When the gate is always approved.** An approval gate that gets rubber-stamped 99% of the time provides no safety value and adds latency. Profile your approval decisions before building the interrupt infrastructure.

---

Source: https://himanshuat.com/blogs/production-grade-agent-architecture-memory-human-in-the-loop
