An Agent Plan That Runs Exactly Once
Before one change in the action engine, a redeploy in the middle of a plan sent two invoices twice. Nothing in the model misbehaved. The plan ran, its steps finished, and then the process shut down before it had written down that they were finished. A retried request picked the same plan up in another instance, saw no record of it, and did it all again: two customers got the same email twice.
That is the problem with letting an agent act. Reading is forgiving: a search that runs twice costs a few milliseconds. An issued invoice gets a number in its series that can never be reused, and an email can't be called back. So once Suma, the assistant in Sumalos, can do things, every action needs an answer to five questions. What exactly will happen? Who says yes? What if it runs twice? Can it be taken back? Can it be stopped halfway?
This post follows one request through all five:
"Make an invoice for María Carmona for the logo design, email it to her, and remind her five days before it's due if I haven't marked it paid."
One plan, one preview
The model doesn't call four tools for that sentence. It calls one, actuar, with a plan of four steps:
factura.crear_borradorfor María, one line, the due date from her contact's payment termsfactura.emitiron$1, the draft step 1 createsfactura.enviaron$1factura.programar_recordatorioson$1, five days before
$1 is how a step points at what an earlier step will make. The model can only reference steps before it, and only of a type the field accepts.
The engine then builds a preview of every step, in order, with real figures. Not the model's arithmetic: each action's preview calls the same domain functions the invoice editor does, and the totals come from there. If anything is missing (a due date, María's email, the tax ID needed to issue), the whole plan comes back as invalid with the question to ask. Nothing is assumed. With everything in place, the person sees one summary (here in English):
Draft invoice for María Carmona: "Logo design", 500,00 € + VAT 105,00 € = 605,00 €, due 18/11/2026. Issue the draft for 605,00 € with the next number in the series. Email the invoice to maria@…. Remind her of the payment on 13/11/2026 (5 days before it's due) if it isn't paid.
Each action declares a risk. Creating a draft is safe: it runs at once. Issuing, voiding, deleting, emailing a customer, collecting payments in bulk and switching on automatic sends are confirm. Some actions decide from their input: deleting one expense is safe, deleting several asks. A plan with even one risky step stops after the preview and asks once, for the whole plan. Four questions for four steps would train the person to click without reading, which is the opposite of the point.
Who may run each step, and why that's checked again at execution, is the post on tool permissions. This one is about what happens to the plan itself.
A yes that only one person can give
A risky plan comes back with a confirmation token. The model never sees it; the clear proxy post covers how the token stays out of its context. What's left is what the token is, and what using it checks.
It's 24 random bytes, kept in Redis for ten minutes. What it stands for lives on the server: the plan, a SHA-256 of the canonical JSON of its resolved steps, the person, the organization and the conversation, all signed with an HMAC under a key derived for this one purpose. Using it goes through every one of those, in an order that matters:
const { mac, ...record } = stored;
// Anything changed in what was kept (steps, hash, who, where) breaks the MAC or the hash.
if (!mac || !same(mac, macOf(secret, clean, record)) || planHash(record.organizationId, record.steps) !== record.hash) return { ok: false, problem: "tampered", ...ref };
if (record.kind !== kind || record.principal !== binding.principal || record.organizationId !== binding.organizationId || record.conversationId !== binding.conversationId)
return { ok: false, problem: "mismatch", ...ref };
if (record.expiresAt <= now()) return { ok: false, problem: "expired", ...ref };
// Only one use wins: whoever takes the exact value read.
const taken = await kv.take(main(clean));
// A double click: the other request took it first; the outcome of that run is what this one gets (engine.ts).
if (taken !== raw) return { ok: false, problem: "used", ...ref, attempt: record.kind === "confirm" ? record.attempt : null };Two details carry most of the weight. The hash is recomputed from the stored steps, so editing what's kept in Redis (a different amount, another recipient) breaks it even if the MAC were somehow valid. And a token presented by the wrong person, from another conversation or in another organization, is refused but not consumed. If it were, anyone who got hold of it could cancel someone else's plan just by trying it. Only a full match takes it, with GETDEL, so it works once.
A second Redis entry, kept for a day, remembers that the token existed. That's how the log tells "expired" from "already used" from "never existed", three answers that look the same from outside.
The yes itself comes from a button on the web and from the person's next message on WhatsApp. On WhatsApp it's the agent's own code that reads that message, never the model, and it is deliberately hard to satisfy:
/**
* Whether a reply is a clear yes: only yes words ("sí", "confirmo", "vale, adelante",
* "sí, por favor", …), at most six, no question, no "no", no "pero". Strict on purpose:
* anything else cancels, and asking again is cheap; running the wrong thing is not.
*/"Sí, pero cambia la fecha" ("yes, but change the date") is not a yes. It cancels the plan, and the message then goes to the model as usual, which proposes a new plan with the new date. For a bulk delete of three or more records, the plan goes further and asks for typed words: the number of records, or "BORRAR" (delete). A plain "sí" then cancels it.
Running it once, from four directions
Duplicates come from more places than I expected when I started. WhatsApp delivers a message twice. A browser retries a request. The person sends the same thing again. The model, in a loop, calls actuar twice with the same plan. Someone double-clicks Confirm. A network retry repeats the confirmation. Each of those arrives with a different shape, so the engine has a layer for each, from the most exact to the most approximate:
The first four are exact and live in one Postgres table, ai_idempotency, with the key as primary key. Whoever inserts the key first owns the work, with INSERT … ON CONFLICT; anyone else waits for the result and gets it back. Stripe's idempotency keys are the closest thing I know, with one difference: here nobody sends the key. The engine derives it from who is asking and what they asked, so a model that "forgets" a key can't skip the check.
const claim = await store.claim<T>(key, meta, leaseSeconds);
if (claim.state === "done") return { result: claim.result, replayed: true };
if (claim.state === "running") {
const result = await waitFor<T>(store, key, { waitMs });
return result === null ? { running: true } : { result, replayed: true };
}A claim carries a lease. If the process holding it dies, the lease runs out and the next caller takes over; if the work throws, the claim is released so it can be tried again. The tests fire five identical actuar calls at once and expect one draft, and ten concurrent claims against the table and expect one effect.
The fifth layer is different in kind. A person who types "make the invoice for María" twice, in two messages, produces two different keys, and the second request is legitimate as far as any key can tell. So before creating a draft, the preview looks for a draft or invoice for the same customer with the same total in the last ten minutes. If it finds one, the step turns risky, whatever its declared risk, and the card shows the existing invoice next to the new one, marked "Possible duplicate". It never blocks: the person decides.
When step three fails
Say the draft is created, the invoice is issued, and the email fails because María's mail server refuses it. The engine stops at the first failure and says exactly where it stopped:
Done: Invoice 2026-0004 issued. Couldn't: email the invoice to maria@… (the email didn't go out: …). Not done because of that: remind her of the payment. Shall I retry?
The plan ends as partial, with a new token for a second attempt. A retry is the same plan, not a new one, and that's what makes it safe:
const key = `${plan.planId}:${i}`;
const stored = await deps.results.get(key);
if (stored) {
// Done on an earlier attempt: its result, not a second time.
made.push(stored as unknown as ExecResult);
states[i] = "done";Each step's result is stored under the plan and its position. On attempt two, steps one and two find their results and are marked replayed; the invoice isn't issued again and doesn't get a second number. Only steps three and four run. The exec: key includes the attempt number, so the retry is allowed to run while the first attempt still can't run twice.
This is also where the double-sent invoices came from. Running a plan used to be able to end in an error after its steps had run, for example when Redis closed during a shutdown and the retry token couldn't be issued. An error releases the act: and exec: claims, by design, so the work can be tried again. Released claims plus finished steps meant a resumed request saw a plan that had never happened. Two rules came out of it. A plan that has run any step never ends in an error: without Redis there is no retry or undo token, but the result is saved and the claims stay. And on shutdown, core-api waits up to 30 seconds for running plans (drainPlans) before it closes Redis and the database.
Undo, where it exists
When a plan finishes, the person may get an Undo button, valid for an hour and usable once, with the same checks as a confirmation. Not every plan gets one. Each action that can be reverted returns, with its result, the data its undo needs:
return { summary: `Cobro de ${money(amount, inv.currency)} registrado`, effects: [{ type: "payment", id: paymentId }], primary: { type: "invoice", id }, cards: [await cardOf(ctx, id)], undo: input.avisar_cliente ? null : { paymentId } };Recording a payment can be undone, unless the customer was told about it. Removing the payment would fix the books, but the customer already has the email saying it was received. The action knows that, so it declines to offer an undo instead of offering a half one.
The engine adds two rules on top. Only a safe plan can be undone, and only when every step in it can be: a plan with an issued invoice in it has no undo at all, since an issued invoice can only be corrected with a rectifying invoice, never deleted. And undo runs from the last step to the first, checking permissions again for each one, because each step may depend on the one before it. If one undo step fails, the person reads which ones were reverted and which weren't.
The request in this post has an issue step and an email, so it gets no Undo. That's the right answer, and the confirmation is where the person was asked to accept it.
Stopping it halfway
A plan of several steps shows a progress card while it runs ("Step 2 of 4"), and the card has a Cancel button. Cancelling has to work from any tab, any device, and whichever instance of core-api happens to be running the plan, so it doesn't talk to the plan at all. It leaves a mark:
async request(planId, userId) {
return (await redis.send("SET", [k(planId), userId, "NX", "EX", String(TTL_SECONDS)])) === "OK";
},The engine reads that mark before each step. What's done stays done. The step in flight finishes, because a step is the unit that runs exactly once and never halfway. The rest are marked "Not done", and the card says "Cancelled: 1 of 4 done". If what ran before the cancel can be undone, the Undo button comes with it.
Only whoever started the plan, or an owner or admin of the organization, can cancel it; anyone else is told it doesn't exist. Pressing twice, or after the plan finished, changes nothing. The model is told the person cancelled and what wasn't done, with an instruction not to do it on its own.
What the person gets
Every one of those paths ends in a sentence a person can act on: what was done, what wasn't, and what they can do next (retry, undo, nothing). Behind each sentence there are three tables: the plan with its hash, every event in its life (proposed, confirmed, cancelled, expired, rejected with its reason, finished, undone), and one row per step per attempt, with its checks, its status and its effects. The model never had to be careful for any of this to be true. It proposed a plan in JSON, and from then on the engine and the person took it.
The trade is friction, and it's spent on purpose. A risky plan costs a click or a "sí", a typo'd yes on WhatsApp costs a second question, and a look-alike invoice costs a second look. In exchange, the invoice that went to María went once, carries the number it was given, and can be traced from her inbox back to the sentence that asked for it.
Related articles
Payment terms said on the web on Monday should be known on WhatsApp on Thursday, by the right people only. How Sumalos designs its AI agent's memory.
One AI agent, many companies, many people. Its knowledge comes in stacked layers, and the work is keeping each layer from leaking past who it belongs to.