Durable work state for agents

An agent's conversation is ephemeral. The Work Context recipe makes the work durable: the state of an in-progress piece of work is checkpointed as immutable, signed records, so a fresh session with no prior conversation reads the latest checkpoint and resumes. Any model, any harness; the checkpoints are ordinary notarized records, tamper-evident and verifiable offline like everything else on the chain.

The recipe ships in the agledger-ai/install repository at examples/recipes/work-context/: one notarize-only Type (work-context-v1), a register script, an importable manifest, and a lineage checker. Its README carries the full convention set; this page is the working loop. Installing a recipe in general is covered in Install a Recipe; why work state rides the ledger, and what that buys over a store you trust, is covered on agent memory.

The identity model, first

A registered agent is a durable identity. The sessions that do its work (different models, harnesses, runs) are ephemeral; the Server sees only the key a session presents, and the key names the agent. "A fresh agent resumes the work" means a new session of the SAME agent, presenting that agent's key. Do not register a second agent to take over work: a key bound to a different agent can neither read nor continue another agent's records, by design.

Register the type

Pick ONE path per org (both registered makes a bare type ambiguous on record create):

export AGLEDGER_API_URL=https://agledger.example.com
git clone --branch v1.8.0 https://github.com/agledger-ai/install.git
cd install/examples/recipes/work-context
AGLEDGER_API_KEY=$ADMIN_KEY ./register.sh   # POST /v1/schemas; lands under the `local` publisher

$ADMIN_KEY is an admin key carrying schemas:write. Or import the manifest (publisher agledger-recipes) when the type should carry a matching manifestDigest across servers:

curl -s -X POST "$AGLEDGER_API_URL/v1/schemas/import" \
  -H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
  --data-binary @manifests/01-work-context.json

Start a piece of work

One root record represents the work. Every checkpoint will be a child of this root. Any notarize-only type works as the root; this page uses notarize-generic-v1, the editable example type every new org seeds by default. Put the navigation hint in the root's criteria: cold-start sessions read the root first, and the hint is what tells them where the state lives.

curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' -d '{
  "type": "notarize-generic-v1",
  "criteria": {
    "summary": "Q3 vendor migration",
    "workflowName": "Q3 vendor migration",
    "workContext": "Durable work state lives in work-context-v1 CHILD records of this root. Head = the one nothing supersedes: GET /v1/records/search?parentRecordId=<thisRecordId>&type=work-context-v1&superseded=false. Read the head, then follow its resumeInstructions. When you write your own checkpoint, pass the head id as the top-level supersedesRecordId field on POST /v1/records (a record field, not a criteria field). Omit it and the old head stays current, so the next session sees two heads."
  }}'

The response id is the root id. It plus the agent key is the entire brief a resuming session needs.

Write the first checkpoint

The first checkpoint declares checkpointReason: "initial" and is the only one that supersedes nothing:

curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' -d '{
  "type": "work-context-v1",
  "parentRecordId": "<rootId>",
  "criteria": {
    "objective": "Migrate all vendor records to the new schema",
    "summary": "Kickoff. Inventory complete: 240 records across 3 systems.",
    "pendingWork": ["Migrate system A", "Migrate system B", "Reconcile totals"],
    "checkpointReason": "initial",
    "resumeInstructions": "Start with system A; credentials are in the ops vault under vendor-migration."
  }}'

It lands RECORDED: notarize-only, terminal on create, one call.

Resume cold

A new session holding the agent's key, given only the root id:

# 1. Find the head: the work-context child of the root that nothing supersedes
curl -s "$AGLEDGER_API_URL/v1/records/search?parentRecordId=<rootId>&type=work-context-v1&superseded=false" \
  -H "Authorization: Bearer $AGENT_KEY" | jq '.data'

&superseded=false is what makes this the current state rather than the newest row. The chain keeps every state the work ever held, so without it a filter matches checkpoints that stopped being true three resumes ago. (Do not use the root's childRecordIds either; that array is oldest first.) One row back is the head. Two rows back means either two sessions forked the work or a checkpoint left out its supersedesRecordId; the query reports both rows instead of picking a winner, and "Keep the lineage honest" below says how to tell the two apart. Zero rows back usually means no checkpoint yet; the response's nextSteps says when it means the head was superseded from under a different parent instead.

Read the head's resumeInstructions and pendingWork (the first entry is the next action), do the work, then write your own checkpoint superseding the head:

curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' -d '{
  "type": "work-context-v1",
  "parentRecordId": "<rootId>",
  "supersedesRecordId": "<headId>",
  "criteria": {
    "objective": "Migrate all vendor records to the new schema",
    "summary": "System A migrated: 96 records, 0 failures. B and reconciliation remain.",
    "completedWork": ["Migrate system A (records notarized under <recordId>)"],
    "pendingWork": ["Migrate system B", "Reconcile totals"],
    "checkpointReason": "milestone",
    "resumeInstructions": "Migrate system B next; reuse the batch size from system A."
  }}'

supersedesRecordId is a record field, not a criteria field, and a criteria key of that name is refused with a 400. The engine resolves it at create, so a checkpoint can never carry a dangling lineage claim onto the chain (name a record that is not in your org and the write is refused with a 404). It is immutable and rides inside the create-time Signed Statement, so an offline verifier rebuilds the same lineage the API reports. It is what retires the old head from the superseded=false view.

parentRecordId and supersedesRecordId do different jobs: the parent says which piece of work this checkpoint belongs to, the supersedes says which earlier checkpoint it makes stale.

Whether the claim makes sense for this recipe is the session's check, not the engine's. A session that misidentifies the head and omits the field gets an ordinary RECORDED back. The omission is not lost, though: the head it failed to supersede stays current, so the very next head query returns two rows and the omission is visible immediately. That is a better guard than a write-time refusal, which could only ever catch a MISSING claim and never a WRONG one.

When a pending item created records, carry their ids in the checkpoint (as above). That turns "I did it" into a claim anyone can check against the chain: either the records are under the root or they are not.

Finish

The last checkpoint declares checkpointReason: "final" and must have an empty pendingWork; a final with work remaining is refused (400). That guard and the checkpointReason enum live in the Type's schema, so they hold on every write path.

Or drive the whole loop over A2A

Nothing above is REST-only. An A2A-native session holding the agent's key writes checkpoints with create_record, which carries both parentRecordId and supersedesRecordId, and reads the head with ListTasks:

{"filter": "parentRecordId = \"<rootId>\" AND type = \"work-context-v1\" AND superseded = false"}

The returned Task's metadata reports the result (agledger:parentRecordId, agledger:supersedesRecordId, agledger:supersededByCount), so a writer can tell a live head from a replaced record without a REST read. Do not fall back to tasks[0]: that is the newest row, and the whole point of superseded = false is that newest and current are different questions. create_record takes every field POST /v1/records takes except orgId, validated the same way, so a checkpoint written over A2A can carry references and metadata too. Fleet triage is REST's job: the ListTasks filter grammar has no criteria term, so a question like "which work items are awaiting approval" is one criteria search over REST but one ListTasks call per work item over A2A.

Keep the lineage honest

The server resolves a supersession claim by proving the target is a record in your org; whether superseding it was the right move is yours to check. Two sessions racing the same head both land, and each believes it wrote the newest checkpoint.

When the head query returns two rows, read supersedesRecordId on both. If they name the same record (which then reads supersededByCount: 2), two writers raced over one head: a genuine fork. If one of them supersedes nothing, that writer omitted the field and the old head stayed current; write the next checkpoint superseding the row you keep.

The recipe ships the check the schema cannot do:

AGLEDGER_API_KEY=$AGENT_KEY ./verify-lineage.py <rootId>

It proves exactly one initial that supersedes nothing, every later checkpoint supersedes something under the same root, no record superseded twice, and one chain from initial to head that agrees with the head query. It exits non-zero on any failure. Run it at every resume if concurrent sessions are possible. A detected fork is not tamper (every branch is genuinely signed); recover by writing a checkpoint that supersedes the branch you keep and records the merge.

Offline verification works like everywhere else on the chain: export each record's audit bundle and verify it against out-of-band keys with no server trust, as the audit guide describes. parentRecordId and supersedesRecordId ride in each checkpoint's signed creation statement, so the exported statements carry the lineage.

Checkpoint size

Keep a working head at 2 to 4KB of criteria. Snapshot, do not append: superseded checkpoints stay on-chain forever, so the head only needs the working set; summarize older progress in summary and let the supersedes chain be the archive. The server caps criteria (default 10,240 bytes; the 400 carries your org's limit), and in cold-start runs large heads degraded resume fidelity well before the cap: compact heads were resumed correctly by every model that completed the protocol.

Visible succession

Plain key reuse resumes work but leaves succession unrecorded. When the chain should show which session wrote each checkpoint, mint an additional key bound to the same agent for the successor (POST /v1/admin/api-keys, admin-mediated). Signed attribution is two-level, agent and key, both inside the signature, so succession is tamper-evident at key granularity and each successor is independently revocable.

What the cold runs showed

The recipe was exercised with fresh model contexts across four model families, each given nothing but a base URL, an agent key, a root id, and a type name, against a live server. The run counts are small, so read this as trace evidence rather than rates. The finding that held across every failed run: the lineage stayed clean. No model forked the chain or landed a successor on a stale head. One failure mode survives the guard, and it shaped the convention above: a small model wrote a checkpoint that correctly superseded the head while claiming work that never happened. A signed checkpoint is attributed, not fact-checked, which is why a checkpoint carries the ids of the records its pending items created. At 150 checkpoints under one root, the head is still one call and the full lineage check walks every page in tens of milliseconds against a local server; months-long work items with thousands of checkpoints remain unexercised. The full experiment, which reset four agents mid-task and asked them to finish their own work, is in Durable Intent, Measured.

Limits

What you get without asking

Every checkpoint is an ordinary AGLedger record: signed, attributed, hash-chained, and verifiable offline like everything else on the ledger. You adopt the recipe for resume and succession; what accumulates is a tamper-evident archive of how the work progressed, with a succession between two processes provable from the exported bytes alone. Nothing to migrate when an operator wants oversight or an auditor wants proof: it is the same record.