All posts
Engineering

There's a New alfred_ in Town
How we rebuilt the engine behind it

We rebuilt the engine behind alfred_ around one rule: the model proposes, code decides. How a gate, a judged yes and a ledger took authority away from the model.


Quick Answer

How does alfred_ keep an AI agent from acting on the wrong thing?

  • The model proposes actions. One pure function in code answers allow, ask or refuse for every model call, tool call, send, memory write and job
  • A small typed model judges each reply to a question, and code binds a yes to a stored offer, so what runs is what you were shown
  • An intent row is written before every effect, and the model and the reply only see receipts written by code

The rule underneath all of it: every hard lesson of the rebuild was about authority, not intelligence.

On October 6, one of our heaviest users sent our assistant 87 texts in under two and a half hours. It held or refused 23 of the email actions it proposed. Twice, it acted on the wrong email. Both times the user had said yes.

The yes was real. It had simply attached to the wrong thing: an older action the assistant had been holding, not the one on the screen.

That session did not expose a weak model or a bad prompt. It exposed an architecture in which the model held authority it should not have had. This post is about the engine we built to take that authority away, which we call V8.

Figure 1One session, October 6
Drag along the timeline
87
texts
23
email actions held or refused
2
actions on the wrong email
The totals are measured. The position of each mark in time, and so the running counts, are illustrative.

An agent that kept getting patched

The engine before V8 was a good engine that kept getting patched: one agent loop over about 170 tools, with a cached prompt. Each incident added a guard. By October the guards were fighting each other.

Tracing that session gave us four causes. None of them was a bug in the usual sense.

  • Two judges of “yes”, and no contract between them. The prompt told the model to read approval generously, and a long server word list decided too. “Yes skip” counted as yes. “Delete” counted as nothing.
  • A yes attached to “some pending card”, not to one card.
  • Six stacked refusal checks, one per past incident, with overlapping conditions. In one sample, 42 of 321 email delete and archive calls ended as refused confirms.
  • Preferences written down but not enforced. Of 118 standing instructions we checked, code honoured 52. Of the 36 that existed only as prompt text, it honoured none.

We had first tried to make the old engine faster. A cheap first pass in front of it answered 13 to 21% of turns and added 2 to 4 seconds to everything else. Speed on a patched engine does not fix a design problem.

Every one of these was a place where the model had been given authority that belonged in code.

Judgment, authority and truth

In most agents, the model decides what to do and then does it. V8 splits those apart into three planes.

  • Judgment belongs to the model. It reads, plans, writes and proposes any tool call it likes. It cannot execute a call or report a result it was not handed.
  • Authority belongs to code: one pure gate, a consent judge that can only return one of five labels, a floor of actions that ask, and a taint bit for text a stranger wrote.
  • Truth belongs to a ledger: an intent row before every effect and a receipt after it. The model learns what happened only from receipts.

Around those planes, the runtime is deliberately plain. Each text becomes one job row. A worker takes a chat lease, so a chat does not run two turns at once, and loads the whole context in one database call. The model loop runs for at most 8 model calls or 100 seconds, and every call inside it goes through the gate.

Figure 2The engine on one page
Click any part
Runtime
Judgment
Authority
Truth
The world
decide()
Bubbles go out one by one. "Sent to Priya."
The structure is real; the message is illustrative.

The hard part of an assistant that acts is not writing the email. It is knowing that “ok send it to her” means send, and that “yes skip” does not.

We judge consent with a small typed-decision model. It reads three things: your newest text, up to four of your earlier texts, and code-built labels of the open offers. It does not see assistant text, tool results or email bodies, so nothing a stranger wrote can argue it into a yes. What comes back is a type, not prose:

// What the judge returns. Not prose: a label and a probability.
type ConsentLabel = "confirm" | "decline" | "modify" | "unrelated" | "unclear";
interface Judgment { label: ConsentLabel; p: number }

The thresholds came from data. In calibration, every confirm at or above 0.9 was right: 70 of 70, over 247 cases. The one unsafe confirm that remained sat around 0.72. So code acts on a confirm at 0.9, and at 0.8 when the reply answers the single offer our last message asked about. In production that judge takes about 130 ms at the median and costs about $0.00005 a call.

When the judge is not sure, code does not guess. The reply goes back to the agent, which reads it and can either ask or call a confirm_offer tool. That tool is held to the same bind checks below, and it cannot confirm an offer in the run that staged it. A model-supplied confirmed: true is refused outright.

We got here by trying the obvious thing first. A small general-purpose model as the judge confirmed “wait no” and “keep it” on a delete, and “thanks” on a send: 18 false confirms on hand-labelled sets, all in testing. A typed model with a calibrated threshold turns consent from a sentence in a prompt into a number you can audit.

Figure 3From a yes to an effect
Judge
confirm at 0.95
Route
code takes it
Bind
stored offer matches
decide()
allow
Ledger
intent, receipt
Reply
written by code
Scores and wording are illustrative; the two bars, the bind checks and the reasons are the real ones.

Binding a yes to a stored offer

Judging a yes is half the problem. The October 6 failures were binding failures: a real yes attached to the wrong action. In V8, a card that asks you is a row, and a yes consumes it with a conditional update.

-- A yes consumes a stored offer, or nothing at all.
update offers
   set consumed_at = now()
 where id         = :offer_id
   and owner_id   = :user_id
   and expires_at > now()
   and args_hash  = :stored_args_hash  -- what you saw is what runs
   and staged_run <> :this_run         -- no confirming in the staging run
returning args;

What runs is the stored arguments, not arguments the model regenerated afterwards. We learned that one the hard way: three “send” texts once staged three offers for one email, because the confirm hashed freshly generated arguments. Every test had used “yes”. Now we test with “send”, because real approvals look like “ok send it to her”, and that sentence also names a target. With several offers open, code picks: a bare yes answers the newest one, or the card your reply is threaded onto.

One gate for every effect

Everything ends in one function. decide(op, ctx) is pure: no I/O and no model, so it can be tested exhaustively, and there is one place to read to know what can happen.

export function decide(op: Op, ctx: RunContext): Verdict {
  // A "confirmed" flag from the model is refused.
  if ("confirmed" in op.args) return refuse("first_call_confirm");
  // No intent row, no effect.
  if (!ctx.ledger.canWrite(op)) return refuse(LEDGER_DOWN);
  if (op.kind === "read") return allow("ok");
  if (ctx.yes?.boundTo(op)) return allow("judged_confirm");
  // After a stranger's text, anything that leaves your account asks.
  if (ctx.tainted && sinkOf(op) !== "local") return ask("tainted_outward");
  // The floor: outside sends, removes and unknown tools ask.
  if (isOutsideSend(op)) return ask("external_send");
  if (isRemove(op) || !known(op)) return ask(FLOOR);
  // Reversible, and your own data.
  return allow("ok");
}

The real function runs more checks than that, always in the same order, and every verdict carries a reason from a closed list, so every row in the ledger says exactly why.

Outside sends, removes and unknown tools ask, unless you have told alfred_ that a specific person is fine to send to directly. A reversible change to your own data, such as a task, a note, a label or an archive, runs on your word, with the provider’s history as the undo. And because there is one gate, code that refuses or re-asks on top of its verdict is a design violation, and we review for it.

One input to the gate deserves a sentence. Email, invites, files and web pages are text a stranger could have written, so the run carries a taint bit that latches the first time it reads any of them. After that, outward and standing writes ask. An instruction hidden in an email can be read and quoted, but it cannot become a send on its own.

Figure 4One turn, across the three planes
You decide step 7
Judgmentthe model proposes
Authoritycode decides
Truththe ledger records
Proposes get_thread(Sam)
allow, reason ok
Proposes reply_to_email(to: Sam)
ask, reason external_send
Offer staged, arguments hashed
Shows you the draft
Your turn
Draft to Sam: "Thursday works." Send it?
Judge: confirm 0.95, one offer open
Intent row, provider call, receipt
"Sent to Sam." written by code
Code stops here and waits for you. Nothing moves until you answer.
Names and messages are illustrative; the verdicts and reasons are the real ones.

The ledger is the source of truth

Every gate decision writes to the ledger: ids, hashes, status, counts, timings and cost, not content. Every effect writes an intent row before it runs. A duplicate key returns the earlier result instead of acting twice, and if the ledger cannot be written, the operation is refused. When we ask whether something happened, we read the ledger, not the reply.

The model learns what happened the same way. It sees receipts that code writes from the ledger, not raw provider output:

{ "tool": "reply_to_email",
  "status": "waiting_for_user_yes",
  "executed": false,
  "note": "NOT SENT" }

A queued job is “started”, not “done”, because the model once wrote “Done” for a job that then failed. Status lines, short confirmations and proactive texts are written by code too, with the wording picked from a small pool by hashing the run id: varied, natural, identical on replay, and free of model calls. And no model checks or rewrites a reply after it is written. We tried that once.

Down to the last bubble

The same discipline holds at delivery. A reply is often several message bubbles, and a worker can die between any two of them.

// Every bubble is journaled before the first one goes out.
for (const [i, text] of reply.bubbles.entries())
  await journal.pending(`${inbound.id}:${i}`, text);

for (const row of await journal.unsent()) {
  // A worker that has been replaced sends nothing.
  if (!(await claim.stillMine())) return;
  // A duplicate key returns the earlier result instead of sending twice.
  await channel.send(row.key, row.text);
}

A reaper notices a dead worker after 30 seconds of silence, and the next worker resumes from the journal, resending only what is provably unsent, with no model call.

Figure 5The journal, under failure
Worker A
holds the claim, beating every 5 s
Journal
in_4821:0bubble 1sent
in_4821:1bubble 2sent
in_4821:2bubble 3sent
when is the review?
Found Dana's email.
It moved to Thursday.
Thursday at 3.
Every bubble was written to the journal before the first one left. Now try to break it.
Time is sped up. The journal rules, the 30-second reaper and the claim token are real; the messages and key are illustrative.

A context the engine owns

What the model reads is as deliberate as what it may do. V8 owns its own tool text, under byte limits that CI enforces. A small core of tools is always loaded, and everything else is deferred and looked up when a turn needs it. The rules prompt has a byte ceiling in CI too, and only a handful of prose rules may still be waiting for an enforcer, because a rule that matters should be code that acts.

What changes every turn lives in one uncached block of ranked sections under a 12,000-character cap. When a turn has more to say than fits, the lowest rank goes first. “Waiting for your OK” is not evicted, because an offer the model cannot see is an offer it can bind wrongly.

Figure 6The turn block, under a budget
Drag the cap
11,910of 14,800 characters kept
4 sections evicted
12,000
Waiting for your OK · pinnedThis threadDay shapeRecent receiptsOpen tasksPeople in this threadCalendar, next 24 hYour directivesWorking memoryRecent episodesJobs in flightDocuments in playChannel and capabilitiesUnread from people you knowGap stampsQuiet hoursRecent sendsContacts mentionedIntegration notes
The cap, rank-first eviction and the pinned section are real; section names and sizes are illustrative.

The layers are ordered by how often they change (tools, the shared core, you, this turn, the messages), so the provider’s prefix cache stays warm across everyone for free.

Texts, web chat and routines run the same engine

An assistant that acts for you has more ways in than a chat box: texts, web chat, reminders and briefings, document edits, routines that run on a schedule. V8 runs them through one gate and gives each only the shape it needs.

Capabilities differ by service, so V8 fails closed: only iMessage gets reactions, typing and threads. RCS bubbles are joined into one message, because RCS delivered the second bubble seconds after the first. A text that arrives mid-turn is folded in at two checkpoints, and only when nothing is open or staged. Document edits leave the chat loop entirely: a planner emits a closed set of operations, and fixed code applies and verifies them.

Figure 7Every lane meets the same gate
Pick a lane
decide()

Fails closed, per service

Only iMessage gets reactions, typing and threads. RCS bubbles are joined into one message, because a second bubble arrived seconds after the first.
The lanes and rules are real.

Proving it before anyone relied on it

Replacing the engine under a live assistant has one failure that matters more than the rest: a send nobody approved. How we shipped V8 was built around that.

We audited the old engine first. Five read-only crawls of it found 166 items, 133 distinct behaviours, from the cross-device history window to entity references. We treated them as requirements rather than a to-do list. They folded into five systems (a tool contract, a context registry, enforcers, the turn runtime and a ledger projection), and the go-live bar became one number: zero high-impact gaps.

We made go-live test decisions, not infrastructure. Our first go-live check passed six of six: tables existed, functions were deployed, the flag was off. Then we ran the deployed decision code over the verbs that send and remove, and 52 of 53 were allowed. Nobody had been routed yet, so no one was exposed. Every safety check now asserts a verdict: this operation, this context, this decision.

Figure 8Two kinds of go-live check
6 of 6
Infrastructure checks pass
Tables exist
Functions deployed
Route flag off
Three more of the same kind
52 of 53 allowed
Send and remove verbs that should have asked you
allowedasked younot run yet
The infrastructure result and the 52 of 53 are real. Which dot is which is illustrative. Nobody had been routed yet, so no one was exposed.

We tested the real path. Our first bench ran real turns with the real model against a write overlay, and it found three go-live blockers that fake-model tests had missed. The harness that replaced it pushes messages into the real SMS seam on a fenced tenant with one injected clock, and grades from the ledger first. Model-judged checks flipped about 31% of the time between identical runs and code-scored checks about 4%, so only the code-scored part counts as firm.

We moved traffic with the kill switch written first. The rollout dial moved only on an explicit call, with kill metrics agreed in advance: any send without a yes, fallback above 8%, silent turns above 2%, p90 above 25 seconds. Memory went before traffic, so nobody arrived at the new engine as a stranger.

Where it landed

Measure Result
Judged confirms at or above 0.9 that were right, in calibration 70 of 70, over 247 cases
Text turns handed back to the old engine 16 of 5,391 (0.3%), none since October 19
Model calls per text turn about 1.9
Median text turn time, first week to third week 9.1 s to 9.7 s
Consent judge, median and 95th percentile 130 ms and 280 ms

Production figures cover text turns from October 13 to November 3, 2026, excluding internal and test accounts.

What we learned

Code decides what runs. Every hard lesson of the rebuild was about authority, not intelligence.

Consent is judged, not matched. Word lists missed “yees” and “fine” and read “yes skip” as yes. A typed judge with a calibrated threshold, bound to stored arguments, is the version we trust.

Do not put a model after the reply. Our first voice step ran a check on every reply and rewrote 52% of them, for three days of internal testing, and fixed nothing. The cause was in the data: four labels for one staged send. We fixed the labels and deleted both calls, about 1,100 net lines.

Cut text on code points. A helper in the old engine could cut a string between the two halves of an emoji, and a strict provider parser rejects the whole request. All truncation now goes through one safe helper.

Green is not effect. Two tool-description edits went green and changed nothing, because V8 reads its own tool text. Deployed means the deploy log names the function; fixed means the production rows changed.

Measure the trap before the number. A latency stamp read negative on every turn because it was written before the turn started, and 36 of 38 “user reactions” were our own tapbacks echoed back. A zero can be the instrument.

If separating judgment from authority sounds like the right way to build an agent people can trust with their inbox, we’re hiring.

Try alfred_

Try alfred_ free

AI-powered leverage for people who bill for their time. Triage email, manage your calendar, and stay on top of everything.

Try now