Agent Memory, State, and Durable Execution with Postgres
Give agents memory that lasts and runs that survive crashes: memory tiers, checkpointed steps in Postgres, leases, and idempotent side effects.
On this page· Four places agent state lives
Why this matters
A chat request lasts seconds. An agent run can last minutes or hours: researching, waiting for a tool, waiting for a human. Over that time, processes restart for deploys, crash on memory limits, and lose connections. Without durable state, a crash means starting over, paying for every model call again, and possibly sending the same email twice. And without deliberate memory, an agent forgets everything the moment a run ends. This article covers both: what an agent should remember, and how to make its work survive failures.
Four places agent state lives#
Hover or tap a component to see what it does.
Show text descriptionHide text description
A model call remembers nothing on its own. Decide deliberately what lives in each tier and when it moves into the context window.
- Agent: Stateless between model calls: everything it remembers is in one of the stores on the right.
- Context window: What the model sees on this call: instructions, recent history, tool results. Small, expensive, rebuilt every turn.
- Session state: Structured facts about the current task: plan, step, open questions, user choices. Loaded at the start of each turn.
- Long-term memory: Durable facts and preferences, searched by relevance and written deliberately, never dumped wholesale into context.
- Run log: An append-only record of completed steps and their results. After a crash, the run resumes from the last completed step.
| Tier | What it holds | Lifetime | Where |
|---|---|---|---|
| Context window | Instructions, recent history, tool results the model needs now | One model call | The request |
| Session state | The task's plan, current step, user choices, intermediate results | One run or conversation | Postgres or Redis |
| Long-term memory | Facts and preferences worth keeping across sessions | Months | Postgres (with search) |
| Run log | Completed steps and their results, for resuming | Until the run is done (then archived) | Postgres |
The model itself remembers nothing between calls. Every tier is a design decision: what to store, when to load it into context, and when to forget it. The multi-turn chat article covers managing the context window itself (trimming, summarizing, compaction); this article covers the other three.
Durable execution: checkpoints, not retries#
The idea behind durable execution is simple: split a long run into steps, save each step's result as soon as it completes, and on restart replay saved results instead of re-running steps. Model calls that already happened are not paid for again, and side effects that already happened are not repeated.
Step 1 of 9
step 1: research
Each step is a unit of work: model calls and tool calls with their results.
Show text descriptionHide text description
A three-step agent run (research, draft, send email) interrupted by a crash after step 2. Participants, left to right: Worker 1, Run store, Model API, Worker 2.
- Worker 1 → Model API: step 1: research · Each step is a unit of work: model calls and tool calls with their results.
- Worker 1 → Run store: checkpoint step 1 + result · The result is saved before moving on. Re-running the run will reuse it.
- Worker 1 → Model API: step 2: draft
- Worker 1 → Run store: checkpoint step 2 + result
- Worker 1 → Worker 1: crash (deploy, OOM, timeout) · Anything can stop a process: deploys, memory limits, lost connections. Long agent runs make it likely.
- Worker 2 → Run store: claim run, load checkpoints · A new worker picks up the unfinished run, for example after a lease expires.
- Worker 2 → Worker 2: skip steps 1–2 (already done) · Completed steps are replayed from stored results: no repeated model cost, no repeated side effects.
- Worker 2 → Model API: step 3: send email (idempotency key) · The side effect uses an idempotency key derived from the run and step, so it happens exactly once even if this step retries.
- Worker 2 → Run store: mark run complete
Dedicated engines (Temporal, Inngest, Restate, and similar) provide this as a service, and are worth adopting for complex systems. The core mechanism fits in one file on top of Postgres, which is the best way to understand what those engines do.
The tables#
CREATE TABLE agent_runs (
id text PRIMARY KEY,
goal text NOT NULL,
status text NOT NULL DEFAULT 'queued', -- queued | running | done | failed
attempts int NOT NULL DEFAULT 0,
lease_until timestamptz, -- a worker owns the run until this time
result jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE agent_steps (
run_id text NOT NULL REFERENCES agent_runs(id),
step_key text NOT NULL,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, step_key)
);import pg from "pg";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });The step runner#
import { pool } from "./db.ts";
export interface Run {
id: string;
goal: string;
attempts: number;
}
const LEASE_SECONDS = 120;
const MAX_ATTEMPTS = 5;
/** Claim one runnable run: queued, or running with an expired lease (its worker died). */
export async function claimRun(): Promise<Run | null> {
const { rows } = await pool.query<Run>(
`UPDATE agent_runs
SET status = 'running', attempts = attempts + 1, lease_until = now() + make_interval(secs => $1)
WHERE id = (
SELECT id FROM agent_runs
WHERE status IN ('queued', 'running') AND (lease_until IS NULL OR lease_until < now()) AND attempts < $2
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, goal, attempts`,
[LEASE_SECONDS, MAX_ATTEMPTS],
);
return rows[0] ?? null;
}
/**
* Run `fn` once per (run, key). If the step already completed in an earlier attempt,
* return its saved result without running it again.
*/
export async function step<T>(runId: string, key: string, fn: () => Promise<T>): Promise<T> {
const saved = await pool.query<{ result: T }>("SELECT result FROM agent_steps WHERE run_id = $1 AND step_key = $2", [runId, key]);
if (saved.rows[0]) return saved.rows[0].result;
const result = await fn();
await pool.query(
"INSERT INTO agent_steps (run_id, step_key, result) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[runId, key, JSON.stringify(result)],
);
// Long runs keep their lease alive as they make progress.
await pool.query("UPDATE agent_runs SET lease_until = now() + make_interval(secs => $2) WHERE id = $1", [runId, LEASE_SECONDS]);
return result;
}
export async function finishRun(runId: string, status: "done" | "failed", result: unknown) {
await pool.query("UPDATE agent_runs SET status = $2, result = $3, lease_until = NULL WHERE id = $1", [runId, status, JSON.stringify(result)]);
}Three mechanisms make this safe:
FOR UPDATE SKIP LOCKEDlets many workers poll the same table without two claiming the same run.- Leases handle crashed workers: a dead worker stops renewing, its lease expires, and another worker claims the run.
- The
(run_id, step_key)primary key makes each step's result write-once.
Side effects need idempotency keys#
A crash can happen after a side effect succeeds but before its step is saved. On resume, the step runs again. For model calls that only costs money; for sending an email or charging a card, it is a bug. The fix is an idempotency key: a stable ID for the action, which the receiving system uses to ignore duplicates. Most payment and email APIs accept one; for your own systems, a unique constraint does the job.
import { pool } from "./db.ts";
// Your email provider's API would take the idempotency key directly.
// Here a unique constraint plays that role: a second send with the same key is a no-op.
export async function sendEmail(idempotencyKey: string, to: string, body: string): Promise<{ sent: boolean }> {
const { rowCount } = await pool.query(
"INSERT INTO sent_emails (idempotency_key, recipient, body) VALUES ($1, $2, $3) ON CONFLICT (idempotency_key) DO NOTHING",
[idempotencyKey, to, body],
);
if (rowCount === 0) return { sent: false }; // already sent in an earlier attempt
console.log(`[email] to ${to}: ${body.slice(0, 60)}…`);
return { sent: true };
}A durable agent run#
The worker claims a run and executes it as named steps. Each model call and side effect is a step:
import Anthropic from "@anthropic-ai/sdk";
import { claimRun, finishRun, step } from "./lib/durable.ts";
import { sendEmail } from "./lib/email.ts";
import { pool } from "./lib/db.ts";
const client = new Anthropic();
const text = (m: Anthropic.Message) => m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
const run = await claimRun();
if (!run) {
console.log("no runnable runs");
} else {
console.log(`run ${run.id}, attempt ${run.attempts}`);
try {
const research = await step(run.id, "research", async () => {
console.log(" running step: research");
return text(await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
messages: [{ role: "user", content: `List the key facts needed to answer: ${run.goal}` }],
}));
});
const draft = await step(run.id, "draft", async () => {
console.log(" running step: draft");
return text(await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
messages: [{ role: "user", content: `Write a short email answering "${run.goal}" using these facts:\n${research}` }],
}));
});
if (process.env.CRASH_AFTER_DRAFT) process.exit(1); // simulate a crash for this demo
const delivery = await step(run.id, "send-email", async () => {
console.log(" running step: send-email");
return sendEmail(`${run.id}:send-email`, "customer@example.com", draft);
});
await finishRun(run.id, "done", { delivery });
console.log(" done", delivery);
} catch (error) {
// Leave the run claimable: when the lease expires, another attempt resumes from the last step.
console.error(" attempt failed:", error instanceof Error ? error.message : error);
}
}
await pool.end();Run it once with CRASH_AFTER_DRAFT=1 and again without. The second attempt skips research and draft (no model calls, no cost) and sends the email exactly once:
$ CRASH_AFTER_DRAFT=1 node worker.ts
run run_1, attempt 1
running step: research
running step: draft
$ node worker.ts # after the lease expires
run run_1, attempt 2
running step: send-email
[email] to customer@example.com: …
done { sent: true }Long-term memory#
Long-term memory is information worth keeping across sessions: "the user prefers TypeScript examples", "the team deploys on Thursdays". The safest designs make memory explicit: the agent decides to save a fact with a tool, facts are small and reviewable, and they are retrieved by relevance rather than loaded wholesale.
import { pool } from "./db.ts";
// CREATE TABLE memories (id bigserial PRIMARY KEY, user_id text NOT NULL, fact text NOT NULL,
// created_at timestamptz NOT NULL DEFAULT now(),
// tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', fact)) STORED);
export async function remember(userId: string, fact: string): Promise<string> {
if (fact.length > 300) throw new Error("Memories must be a single short fact (max 300 characters)");
await pool.query("INSERT INTO memories (user_id, fact) VALUES ($1, $2)", [userId, fact]);
return "Saved.";
}
export async function recall(userId: string, query: string, limit = 5): Promise<string[]> {
const { rows } = await pool.query<{ fact: string }>(
`SELECT fact FROM memories
WHERE user_id = $1 AND tsv @@ websearch_to_tsquery('english', $2)
ORDER BY ts_rank(tsv, websearch_to_tsquery('english', $2)) DESC, created_at DESC
LIMIT $3`,
[userId, query, limit],
);
return rows.map((r) => r.fact);
}
export async function forgetAll(userId: string): Promise<void> {
await pool.query("DELETE FROM memories WHERE user_id = $1", [userId]);
}Expose remember and recall to the agent as tools (scoped to the authenticated user, as always), and load the top few recalled facts into context at the start of a session. For larger memory stores, swap the keyword search for the hybrid search from the RAG article.
Memory carries real obligations:
- Users must be able to see and delete it.
forgetAllis not optional; privacy law often requires it. - Never store secrets (passwords, keys, card numbers) and avoid sensitive personal data unless your product needs it.
- Memories can be wrong or stale. Store when a fact was saved, prefer recent facts, and let the agent update or remove them.
- Memory is an injection vector. A fact saved from a malicious document is replayed into future sessions. Save facts from the user, not from tool output, unless reviewed.
Common mistakes#
- Keeping run state in process memory. A deploy loses every in-flight run. Persist state as you go.
- Retrying a whole run from scratch. Every retry pays for every model call again and repeats side effects. Checkpoint steps.
- Side effects without idempotency keys. Exactly-once delivery comes from the receiver ignoring duplicates, not from hoping crashes do not happen.
- No lease or timeout. A run claimed by a dead worker stays stuck forever.
- Dumping all memories into every prompt. Retrieve the few that are relevant.
Try it
Crash it on purpose
- Create the tables plus
sent_emails (idempotency_key text PRIMARY KEY, recipient text, body text), and insert a run:INSERT INTO agent_runs (id, goal) VALUES ('run_1', 'How do I export invoices?'). - Run
CRASH_AFTER_DRAFT=1 node worker.ts, then setlease_untilto the past (UPDATE agent_runs SET lease_until = now() - interval '1 second') and runnode worker.tsagain. Confirm that onlysend-emailruns. - Delete the
send-emailrow fromagent_steps, expire the lease, set the status back torunning, and run again. Confirm the email is not sent twice.
Check your understanding
Key takeaways
- Agent state lives in four tiers: the context window, session state, long-term memory, and a durable run log.
- Durable execution checkpoints each step's result and replays it on resume, saving cost and avoiding repeated work.
- Leases recover runs from dead workers;
FOR UPDATE SKIP LOCKEDprevents double claims; idempotency keys make side effects exactly-once. - Make long-term memory explicit, small, relevant, user-visible, and deletable.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Build an AI Agent Loop from Scratch in TypeScriptWrite a production-ready agent loop in TypeScript: Zod-typed tools, turn, token, and time limits, parallel tool calls, error handling, events for your UI, and unit tests.
- Multi-Turn Chat and Conversation Memory with the Claude APIBuild multi-turn chat in Node.js: keep history, replay content blocks correctly, and control cost with sliding windows, summaries, compaction, and long-term memory.
- Reliable LLM Apps: Timeouts, Retries, Fallbacks, and Circuit BreakersKeep LLM features working when providers are slow or down: SDK retries and timeouts, deadlines, fallback models, circuit breakers, and graceful degradation in TypeScript.