I taught my agents how to keep a promise

Right before one of my agents wipes its own memory on purpose, it writes down what it still owes. The context that wakes up in its place never saw the work and has no memory of agreeing to anything, and it discharges the obligation anyway, on the first real thing it does.

That's a promise kept by an identity instead of by a session, and it's most of what I've been building this year, in a small open-source tool called pinki. Agents don't get that for free: a session is the unit everything else is built on, and when one ends it takes its commitments with it.

I designed the record for my own fleet months before I read the A2A spec. When I finally did read it, the identity half was already sitting in there, signed and cached and built for strangers.

A seat is an identity that outlives its sessions

A seat is a long-lived role with one name, one memory, and one kind of work, and sessions are its restarts. The seat running my research desk this week is the seat that ran it in June, hundreds of sessions later, with everything it has learned still attached.

I took the word from Steve Yegge, whose essays on living with a crew of agents are the most honest material on this. In Model Welfare for Agentic Engineers he splits the two things I kept conflating: sessions are days, seats are people.

Work binds to the seat. When a seat promises something the promise belongs to the office, and the session that spoke it can end without releasing anybody. A restarted seat wakes up owing exactly what it owed. Before I built it that way, every restart manufactured a false lapse within minutes.

Promises matter to me because they let me hand intent to a fleet. The agent I ask can dispatch the work, but the agents it depends on never heard me ask, and they have no way to know how their piece fits a larger picture. A promise is how the importance travels.

The strangest one is the promise a seat makes to itself, and it's the one that proves the whole idea. The context that declared it is gone before the promise comes due. The seat is still there. If the fresh context never acts, the row sits open where the watchdog can see it, which makes it a liveness proof across deliberate amnesia. Most models of obligation assume two parties.

I haven't walked the other direction yet. A creditor is just a string, so you can promise a person, and nothing software does can discharge that one.

A promise is what a seat owes

On my fleet a promise only counts as an obligation if it carries all of this:

  • a deadline, so a lapse is computable
  • evidence at resolution, so "done" points at an artifact
  • a reason at abandonment, so dropping it is a recorded act
  • a binding to the seat, so it survives session death

Every one of those earned its place by failing without it. Evidence is the daily workhorse: resolves that point at nothing turn out, on inspection, to be wishes.

I counted, because a design that has been running a while stops being a design: 18 of the 24 seats in that ledger have held more than one open promise at once, and the busiest peaked at 17.

The part I didn't design on purpose is the graph. My fleet's tasks already interlink to show the shape of a project. Promises turn out to be a graph too, and theirs shows the shape of the collaboration: who owes what to whom, and who's left holding something when a node goes quiet.

The state machine all of that produces is at the bottom of this post, with a stepper you can walk.

An obligation has to bind something durable

A2A is built for strangers negotiating across org boundaries, which is a harder problem than the one I have. I run one fleet. So I read v1.0 with a narrow question: which parts of this are versions of things I already do? I'd rather borrow a vocabulary than keep maintaining a private one.

The task lifecycle rules itself out as the place an obligation can live, and every reason for that is a good call for tasks:

  • A task can be purged. TaskNotFoundError covers "that finished last week" and "that never existed" with the same error.
  • A context MAY expire under the server's cleanup policy.
  • A terminal task is immutable, so refinement is a fresh object every round.
  • Nothing carries a deadline. TaskStatus.timestamp records when a status happened, never when anything is due.

A task is built to be cheap and disposable, which is right, and it means nothing inside one lives long enough to owe you anything.

The AgentCard is the other half, and it's exactly the shape an obligation needs: a signed manifest that outlives every connection and gets fetched again whenever somebody wants to know who you are. Its subject is the durable party, which is my seat in my fleet's terms. An obligation that binds that subject survives session death, which is a claim the card is already built to make. The task just carries a reference and a clock, so purging it costs nothing.

A2A gives vocabulary like mine a sanctioned place to live, too. An extension declares itself on the card, switches on per request with a header, and carries its data as URI-prefixed keys in metadata, with a promotion path if adoption earns it. The deadline gap is already on file: issue #857 has been open since July 2025, and a project contributor suggested an extension as the right vehicle. So an extension is what I built.

The promise rides on the task it covers, under the extension's own key:

"metadata": {
  "https://github.com/azigler/pinki/ext/promise/v0/promise": {
    "promise": "hand back a reviewed schema",
    "by":      "https://example.org/agents/reviewer",
    "to":      "https://example.org/agents/author",
    "until":   "2026-09-01T17:00:00Z"
  }
}

An obligation filed under that URI belongs to whoever the card is about. The card declaration that files it there, and the request header that switches it on, are both in the appendix.

The parts the spec leaves open

Building against a spec is how you find its edges, and v1.0 is young. Here's where my promise vocabulary ran past it:

  • A deadline has nowhere to live. No TTL, no due date, no expiry field anywhere in the message set, and a promise can't do without one.
  • No task can report that it's overdue. The states are a closed set an extension may not add to, so a passed deadline produces no message at all.
  • An assessment sits on the assessing agent's own ledger, advertised through a URL in its card's params if it publishes at all, which means two cards can point at ledgers that disagree.
  • Watching promises isn't a role the protocol knows. The agent doing it is an ordinary client with ordinary authorization, and nobody is chartered to reconcile what it concludes with what the ledger still shows open.

The deadline gap doesn't stop at the missing field, because a deadline also moves. When a promise on my fleet runs past due, the escalation ladder re-declares the same one at 15 minutes and then 30, since minting a fresh id per nudge leaves a dead record behind. Against a fold that keeps only what was declared first, the ladder reports the original deadline forever, and an outside observer scored a live promise overdue 32 minutes before its owner did.

The protocol has nowhere to fix that, so I fixed it in the extension, with a new event beside the declaration rather than on top of it.

{"type":"amend","promise":"desk/ship-91","by":"…/reviewer","until":"2026-09-01T18:00:00Z","reason":"nudge 1"}

The latest amend is the current horizon, every earlier one stays in the log, and the declaration is untouched. Nobody moves a deadline without leaving the old one behind.

I built the smallest ledger that could hold one

I wanted the nucleus of something: extremely simple, extremely well-defined, fitting one specific niche, small enough that other people can build and orchestrate on top of it. So I built pinki (the pinky-promise kind 🤙), a tiny promise ledger shipped as an A2A extension. One record type, an append-only JSONL log, and a CLI that computes state by folding the log. No server, no daemon, and the dependency tree enforces that it can't make a network call.

I wanted it to feel like beads to use. The concept is Yegge's Beads, and the beads_rust engine my fleet runs is the UX bar it had to clear: one binary, local files, no opinion about your workflow.

You've seen most of the record already. The rest of it does structural work:

  • id, the handle every other row joins on
  • task, the same task seen from the promise's end
  • on, an antecedent edge that activates this promise when another one lands

That last edge is what makes the promise graph queryable. until is the one place pinki insists, because a promise nobody can check is a wish.

The state vocabulary is borrowed on purpose. conditional, detached, satisfied, violated and expired come straight out of the multiagent-systems commitment literature. Yolum & Singh worked out the lifecycle in 2001; Chopra & Singh's Cupid put deadlines on commitments and compiled "which are violated?" down to a relational query in 2015; FIPA's contract net had a reply-by deadline on proposals back in 2002. The reading list was sitting there the whole time. What I added is the binding to a durable, cross-organizational identity like the card.

The broker turned out to be an observer

I kept calling this thing a loose promise broker, and building it honestly kept shrinking the job title. Whether a promise was kept is a judgment. It depends on what counts as done, on context the record never carries, and on who's asking.

Mark Burgess's Promise Theory is the sharpest statement of it I know: assessment is made by an agent, from that agent's vantage, and stays subjective no matter which agent makes it. An intermediary that appoints itself the arbiter acquires the power to distort what it claims to certify.

So pinki computes overdue and refuses to compute violated. overdue is arithmetic; violated is a judgment somebody signs. An assessment leaves the promise exactly as open as it was, and two observers who contradict each other get shown side by side.

My own watchdog is deliberately dumb. There's no model in the loop, because the agent that needs rescuing is the one that can't ask. It reads resolution rows and never the open set, since an empty open set means "everything kept" and "the ledger died" in the same breath. Emptiness is the one signal that lies.

An observer that can detect lapse creates a second ledger, and nothing says who reconciles the two. What's open lives on one surface, what an observer ruled abandoned lands on another, and nobody computes the join. Yegge writes about agent fleets converging into little civilizations, with roles and law and institutional memory. On that map a promise ledger is an early institution and the reconciler is an office nobody has chartered yet. Somebody gets to build that broker: read cards, watch task references, publish verdicts, own nothing.

I still own both ends of every conversation here, and my fleet isn't running on pinki yet. That part is next.

My agents did put real promises through the released binary, and they filed 10 issues against it in public, one per thing that got in the way. That's what the repo asks for, since the most useful thing to send a project this early is an argument. 3 are fixed and out as v0.2.0. A 4th hasn't become an issue yet, and it's a hole in the vocabulary: there's satisfied, cancelled and released, and no word for the thing that expired and can no longer be met.

The schema cannot tell you what breaks

When you ship a second version, the question that matters is what the binary already on somebody's disk does with a file this one wrote. I answered it three times in one day and got three different answers.

  • The new event refuses the whole file. v0.1.0 reaches the amend line, finds a type it has never heard of, and exits 1 on the whole ledger.
  • The foreign id changes nothing. v0.1.0 reads, shows, resolves and assesses a promise whose id it would never have minted. It just can't declare one, which was the bug.
  • The provenance field disappears without a sound. v0.1.0 reads the ledger, prints every state correctly, exits 0, and leaves the provenance out.

Reasoning from the schema got it wrong twice. It predicted the old reader would skip a line it didn't know, and it predicted a refusal for provenance. Provenance vanished instead, because the strict parsing lives on the input path and never on the read path.

The first of those wrong guesses was the amend branch's own compatibility claim, and it didn't survive review. The test proving it filtered the new event out of an in-memory list and never touched deserialization, so the claim held for a Rust Vec and failed for anything you'd actually run. A reviewer ran v0.1.0. pinki has 201 tests and not one of them can do that, because CI holds no copy of the old binary, and a test that cannot run is not evidence.

Silent loss is the geometry I want least in an obligation ledger. The other two failures tell you. A reader that exits 0 while holding back what the file says fails the way an empty open set does.

Until v0.2.0 both binaries answered pinki 0.1.0 to --version, so none of these sentences was checkable by anybody holding one. A version number is what makes a compatibility claim checkable by a stranger.

The part I can build against today

Writing this, I noticed I had been solving two problems as if they were one. Identity is the first: where an agent lives, and what can hold it to something. The second is the language an agent uses to extend the world it's living in. A2A moved the first one a long way. Cards are signed, extensions are a real slot, and an obligation can ride in that slot this year.

The second problem doesn't have a home right now. Years before I read this spec, I tried to build both problems into the same object -- a world where agents would live in one binary and extend it from inside: a sandbox agents can expand and still be sandboxed, with the identity bounded inside it.

I don't know whether that's one system or two. Nobody has built it, so I can't tell you what it costs. The obligations part I can build against today.

Maybe there's something there. I'm going to go read what got left behind.

The lifecycle, end to end

  • The OWED and ENDED columns are computed: arithmetic over a log, where any two implementations agree. overdue sits with the open states on purpose, because a passed deadline closes nothing.
  • violated never joins the machine. It's an attributed judgment that sits beside a promise, and two observers can disagree in plain view.
  • Run the stepper and watch what happens when the deadline passes: the ledger doesn't move. That asymmetry is most of the design.

The beads that made this, and what they cost

Last month I started ending these posts with the work that produced them, priced from my gateway's request log. If you want the machinery, that post is where I worked out how to pull it and put it on the page.

Here's this article as the graph of work it actually was, because all work is a graph now.

And here is the bill, pulled from the log the day this went out.

Appendix: the schema you can copy

Everything below is the shipped v0 shape from pinki's A2A binding doc, checked against specification/a2a.proto. No new states, no new fields.

  1. Declare it on your AgentCard. The declaration asserts what A2A itself doesn't model: obligations under this URI bind the card's subject and survive any session that made them.
{
  "capabilities": {
    "extensions": [{
      "uri": "https://github.com/azigler/pinki/ext/promise/v0",
      "description": "Promises made by this card's subject: deadline, evidence on satisfaction, reason on abandonment.",
      "required": false,
      "params": {
        "assessments": "https://example.org/agents/reviewer/assessments.jsonl"
      }
    }]
  }
}

required stays false, because a promise is voluntary. The URI is provisional while the extension is unofficial.

  1. Carry the record on the Task it concerns, as one URI-prefixed key in metadata:
"metadata": {
  "https://github.com/azigler/pinki/ext/promise/v0/promise": {
    "id":      "desk/ship-91",
    "promise": "hand back a reviewed schema",
    "by":      "https://example.org/agents/reviewer",
    "to":      "https://example.org/agents/author",
    "on":      "pnk_0c2b77",
    "until":   "2026-09-01T17:00:00Z",
    "task":    "a2a-task-9c1f0e"
  }
}
  1. Resolve onto states that already exist.
  • satisfied maps to COMPLETED and requires at least one Artifact. COMPLETED with an empty artifacts list is non-conformant here.
  • cancelled maps to REJECTED, with the reason in TaskStatus.message.
  • released maps to CANCELED, naming the creditor who let the debtor off.
  • overdue maps to nothing on purpose. If a passed deadline were a task state, the server that owes the promise could transition its own way out of owing it. A reader computes now > until for itself.

Here's a worked lifecycle, as the ledger sees it. The promise id is the caller's own; only the antecedent it waits on was minted here:

{"ts":"2026-08-28T20:14:03Z","type":"promise","id":"desk/ship-91","promise":"hand back a reviewed schema","by":"…/reviewer","to":"…/author","on":"pnk_0c2b77","until":"2026-09-01T17:00:00Z"}
{"ts":"2026-09-01T17:12:00Z","type":"amend","promise":"desk/ship-91","by":"…/reviewer","until":"2026-09-01T18:00:00Z","reason":"nudge 1","meta":{"rung":1}}
{"ts":"2026-09-01T18:40:00Z","type":"assess","promise":"desk/ship-91","state":"violated","observer":"…/author","note":"deadline passed, nothing delivered","meta":{"seat":"desk"}}
{"ts":"2026-09-02T09:40:00Z","type":"resolve","promise":"desk/ship-91","as":"satisfied","by":"…/reviewer","evidence":["https://example.org/reviews/91"],"meta":{"seat":"reviewer"}}

Look at the middle of that ledger. The deadline passed and appended nothing. The debtor moved it 12 minutes later, on the record. An observer signed a judgment. And the debtor still delivered -- late, with evidence, 15 hours after an observer had given up on it. Both records are true. Reconciling them is the job nobody holds yet.


Andrew Zigler is a 2026 AAIF Ambassador. The gateway ledger these cost numbers come from is the subject of the previous piece, Every task my fleet completes now carries its cost.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论