Skip to content
AI Full-Stack Wiki

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.

Difficulty: Advanced20 min readUpdated
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#

Where agent state livesAn agent reads its context window on every model call, loads and saves session state in a database, searches long-term memory when relevant, and records each step in a durable run log so a crashed run can resume.every callload / savesearch / writecheckpointAgentthe loopContext windowworking memorySession statePostgres / RedisLong-term memoryfacts across sessionsRun logdurable steps

Hover or tap a component to see what it does.

Agent memory tiers: the context window is working memory, session state and long-term memory live in databases, and a durable run log records completed steps.
Show 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.
TierWhat it holdsLifetimeWhere
Context windowInstructions, recent history, tool results the model needs nowOne model callThe request
Session stateThe task's plan, current step, user choices, intermediate resultsOne run or conversationPostgres or Redis
Long-term memoryFacts and preferences worth keeping across sessionsMonthsPostgres (with search)
Run logCompleted steps and their results, for resumingUntil 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.

Durable execution with checkpointsWorker 1 runs step 1 with the model and saves a checkpoint, runs step 2 and saves a checkpoint, then crashes. Worker 2 loads the run, skips completed steps, and runs step 3, which sends the email exactly once.attempt 1attempt 2Worker 1RunstoreModelAPIWorker 2step 1: researchcheckpointstep 1 +resultstep 2: draftcheckpointstep 2 +resultcrash(deploy,OOM,timeout)claim run, loadcheckpointsskip steps1–2(alreadydone)step 3:send email(idempotencykey)mark run complete

Step 1 of 9

step 1: research

Each step is a unit of work: model calls and tool calls with their results.

Durable execution: each completed step is checkpointed in a database, so when a worker crashes mid-run, a new worker resumes from the last checkpoint instead of starting over or repeating side effects.
Show 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.

  1. Worker 1 → Model API: step 1: research · Each step is a unit of work: model calls and tool calls with their results.
  2. Worker 1 → Run store: checkpoint step 1 + result · The result is saved before moving on. Re-running the run will reuse it.
  3. Worker 1 → Model API: step 2: draft
  4. Worker 1 → Run store: checkpoint step 2 + result
  5. Worker 1 → Worker 1: crash (deploy, OOM, timeout) · Anything can stop a process: deploys, memory limits, lost connections. Long agent runs make it likely.
  6. Worker 2 → Run store: claim run, load checkpoints · A new worker picks up the unfinished run, for example after a lease expires.
  7. 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.
  8. 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.
  9. 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#

db/agent-runs.sql
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)
);
lib/db.ts
import pg from "pg";
 
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

The step runner#

lib/durable.ts
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 LOCKED lets 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.

lib/email.ts
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:

worker.ts
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.

lib/memory.ts
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. forgetAll is 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

  1. 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?').
  2. Run CRASH_AFTER_DRAFT=1 node worker.ts, then set lease_until to the past (UPDATE agent_runs SET lease_until = now() - interval '1 second') and run node worker.ts again. Confirm that only send-email runs.
  3. Delete the send-email row from agent_steps, expire the lease, set the status back to running, and run again. Confirm the email is not sent twice.

Check your understanding

What does durable execution save after each step?
A crash happens right after an email is sent but before the step is saved. What prevents a duplicate email?
Why use FOR UPDATE SKIP LOCKED when claiming runs?

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 LOCKED prevents 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.