Production Agent Architecture: Memory, Approval Gates, and Durable Checkpointing
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.
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:
- Retrieve episodic context from a vector store without coupling business logic to embedding provider APIs
- Halt at critical operations and wait for a human decision, holding that state across process restarts
- Resume from exactly the last saved checkpoint without re-executing completed steps
- 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.
// 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
// 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:
// 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:
// 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:
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.
// 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.