How I built at-most-once sends for my AI companion's ChatGPT tab: a durable send ledger, and receipts that survive a crash
Ankita is my open-source desktop AI companion β MIT licensed, local-first, your models. In v2.6.0 she gained a ChatGPT Browser provider: she can type messages into your already-open ChatGPT conversation in Chrome, no API key, your plan's limits apply.
Sending the message was the easy part. The hard part: what happens when the process crashes after the click but before the confirmation comes back?
A duplicate send goes out under your name, in your conversation. That's not a failed API call you can shrug off β it's user-visible damage you can't unsend. So I built delivery the way databases build transactions: a durable send ledger, where only a committed record can authorize a browser click, and crash recovery reports custody instead of resubmitting work.
One row per send, and the payload can never silently change
Every send becomes a row in BrowserOperationStore (src/integrations/chatgpt-browser/operation-store.mjs). The row is validated as immutable on write: same operation ID but different text or binding throws Operation identity payload changed. A send is admitted exactly as proposed, or not at all.
The lifecycle is a one-way state machine: queued β claimed β dispatched β accepted β completed, with side exits to stopped, rejected, and uncertain. The BrowserDelivery layer enforces the custody rule: "Host-side delivery custody; only a durable first dispatch can authorize a browser click." And poll() refuses to hand out the next send while anything is still in flight β one browser send at a time.
A receipt can't precede the dispatch that created it
Reply correlation is where I refused the easy road. A reply's identity is the native tuple (conversationId, userMessageId, turnId) β the code says it outright: "never inferred from time or a reused request ID." Completion requires an answer with role === 'assistant', channel === 'final', end_turn === true, and status === 'finished_successfully'. Per the verification doc: "commentary, private analysis, quiet time and unrelated replies cannot settle it."
The store goes further: accept() throws Receipt precedes durable dispatch if a receipt arrives for an operation that was never durably dispatched, and Native receipt owner changed if a second, different receipt shows up for the same send. The first acceptance is the only acceptance β there is no "check the newest message and assume it's ours."
Crash semantics: uncertain, and never resubmitted
On restart, recover() walks the ledger: queued rows become rejected (they never left the host β safe to discard), while claimed/dispatched rows become uncertain (the click may have happened; we cannot know). The store's own contract: "Recovery reports custody and never submits browser work."
For native tool calls the rule is stricter. PersistentToolReceipts implements "durable at-most-once admission, including crash-before-receipt. No automatic mutation replay." A started-but-unsettled action is journaled as uncertain, and the model sees: "Error: tool outcome is uncertain after restart; do not retry the side effect." An uncertain action is never executed again β I'd rather surface an honest "I don't know what happened, please check" than replay a mutation and discover the first attempt had actually landed.
There's even an upgrade guard for the receipt schema: old generation-wide admissions carry a LEGACY_NATIVE_RECEIPT marker, because an admission from before per-call tracking "cannot safely be assigned to a new native call." Receipts are capped at 1,000 β the store refuses new work before it forgets old mutations.
Stop is a fenced intent, not a suggestion
Stopping a send works the same way. stop() records an epoch fence β a SHA-256 of the full browser binding (lease, tab, conversation, thread, workspace) β into up to 1,000 stopped epochs that survive restarts, then marks every non-terminal operation stopped. The guide's rule: "Stop records cancellation before another Send or action can be admitted." A stopped epoch's queue() throws outright. The remote Stop itself has durable phases β pending β dispatched β confirmed/uncertain β because inventing a confirmation that the chat never supplied is not an option.
The journal has to be crash-safe first
None of this works if the ledger itself can tear. Every write goes through DurableProfileState: temp file with exclusive create, fsync, then atomic rename; files at mode 0o600; a realpath check refuses state that leaves its owned directory; commits to the same file are serialized; 16 MB cap. The module header states the ordering invariant: "Commit precedes any action grant or receipt acknowledgement." The click is downstream of the commit. Always.
The decision I'd argue about
The whole design is fail-closed: a dispatched-but-unacknowledged send becomes uncertain and is never resubmitted, though the tab usually shows the message plainly. The alternative I rejected was auto-reconciliation β read the tab's newest user message after restart, and adopt it as ours if the text matches. More convenient, and exactly wrong when you typed something between the crash and the restart: the companion would attribute your message to itself and report a success it didn't earn.
I chose the honest failure. A message you have to glance at and confirm beats a system that might quietly claim your words. Would you have drawn the line in the same place β or is the uncertain state just failure with better branding? Genuinely curious how others would handle it.
Ankita is open source (MIT) at github.com/akyourowngames/A.N.K.I.T.A β the ChatGPT Browser provider shipped in v2.6.0, and the send ledger lives in src/integrations/chatgpt-browser/. If you've built delivery semantics around a UI you don't control, I'd love to hear what you did.













