Nexo Is Evolving Along With the Series

A pause between parts 7 and 8: four places where running nexo_mail against a real inbox forced the design past what the earlier posts described.

Eight posts of this series are published. Post 8 is drafted and sitting in my queue. Somewhere between writing post 1 and today, nexo_ai went from whatever it was in July to 0.11.0, and nexo_mail kept running against my actual inbox every day. Real mail breaks things a demo run never would. Before I publish post 8, I want to do something the series hasn’t done yet: go back and check what I already told you against what the code does now.

The interesting part is four places where the series described a design decision, and the decision turned out to need more than the post showed: one gap a real run exposed outright, three that were always going to need more scope than the first version covered. I’d rather write that down than quietly edit old posts as if I’d always known better.

As in the earlier posts, the whole project is on GitHub at mariochavez/nexo-mail.

The model was doing the arithmetic. It was wrong.

Part 7 named this trade-off directly: “the money totals are summed by the model, not by Ruby… If you want the totals guaranteed correct, the clean move that stays within this design is a small compute_totals tool the synthesis agent calls.” I wrote that sentence, then shipped without the tool.

I built it after a run’s reported total for a currency didn’t match what its own list of charges actually added up to, more than once, and not by a rounding error. Small local models are decent at extracting numbers from text and unreliable at adding a column of them, which is exactly the gap the post predicted and exactly what I should have closed before shipping.

Tools::SumPayments is the fix: BigDecimal addition, grouped per currency, and it makes no judgments of its own. It doesn’t know the date, doesn’t decide what belongs in the briefing, and refuses to guess a payment’s direction rather than silently filing an ambiguous one as a charge:

class SumPayments < RubyLLM::Tool
  description <<~DESC.strip
    Add up payments, separated by currency. Deterministic. Use this instead of
    doing the arithmetic yourself, and copy its answer into the digest rather
    than re-deriving it.
  DESC
  # ...
end

The shape is as much the fix as the addition is. Every currency comes back as one self-contained block holding its own charge list and that list’s own totals, computed from exactly that list. Copy the block into the digest and the two numbers can’t disagree, because there’s no second place for either of them to come from. That’s a structural fix as much as an arithmetic one: the old design had the model’s stated total and the model’s own list as two independent claims that happened to usually agree. Now there’s only one claim.

A companion tool, Tools::Today, exists for the same reason. A model has no notion of the current date, and it showed: a run published a schedule of appointments that were all already past, and reported old receipts as current charges. Today is a clock: today’s date, the current month’s bounds, the previous month. It makes no judgment about what counts as recent. That judgment is a skill’s policy, the same way the arithmetic used to be the model’s job and is now a tool’s.

One message per tool call became one call per batch

Part 3 showed a Read tool that took a single uid: and made one full IMAP round trip, or one full MCP call, per message. That was honest about what existed in July. It’s also expensive: for Gmail specifically, one message meant one whole TCP, TLS, login, select, and logout cycle, repeated for every message worth a second look.

Both Gmail and HEY read in batches now. GmailImap::Read takes uids: [], not uid:. HeyThread takes thread_ids: []. And the listing tools carry a snippet field, so most messages get classified from the list view alone, without a second fetch at all. Gmail builds those snippets from one grouped partial BODY.PEEK fetch across the whole batch, then decodes the MIME transfer encoding, base64 or quoted-printable, and strips HTML down to text, in Ruby, before the model ever sees it. The old version handed the model raw MIME inside its 4000-character budget and expected it to classify from that. Batching turned out to be a legibility fix as much as a speed one.

The HEY side needed a fix the series had no way to anticipate, because it only showed up against a real, working HEY account with real box contents. HEY actually offers more boxes than the tool reads from. The post scoped triage to three on purpose: imbox, feed, paper trail. I’ve since added a fourth, Set Aside, mail someone already parked rather than sorted. HeyBox takes a boxes: array now and can return all four in one call.

The CLI’s box names aren’t the readable ones: hey box feedbox, not hey box feed. Tools::Hey::BOXES maps the canonical name the model uses to the kind the CLI actually wants:

BOXES = {
  "imbox" => "imbox",
  "feed" => "feedbox",
  "papertrail" => "trailbox",
  "setaside" => "asidebox"
}.freeze

An earlier version of this mapping disagreed with itself in one place, so an unrecognized box name quietly fell back to the Imbox rather than raising. That’s how The Feed and Paper Trail went untriaged for a while: a box silently returning Imbox contents instead of its own, with nothing in the output to flag it. The fix was making an unknown name raise an explicit error instead. A related trap: hey threads wants the topic id parsed out of a posting’s app_url, a different id from the posting’s own. Reading by posting id 404s every time, cleanly, which is at least honest about failing.

Running a skill’s own scripts without breaking the sandbox

A skill package can carry more than instructions: scripts/, assets/, references/. Part 8 ships one, dashboard_designer, with a render script and an HTML template. The moment a skill carries an executable script, a real question shows up: how does an agent run that script without the sandbox’s one guarantee, a single root nothing escapes, becoming a hole in it?

The failure mode to avoid is a plain filesystem copy. A skill’s own directory and an agent’s sandbox are two different roots. Something like FileUtils.cp between them only works by accident, when the sandbox happens to be the real local filesystem. It does nothing for a container, where the agent’s view of the filesystem is a namespace cp was never inside of, and either way it is an operation happening outside the sandbox, reaching in.

Nexo::Skills.materialize(skill, into: sandbox) solves it the simple way: it moves the files through the sandbox’s own #write instead of a bare copy. The sandbox already knows how to get bytes into its own root safely, on whatever backend it runs, so staging a skill’s assets through that same interface means the security boundary never has to be reasoned about twice, once for the sandbox and again for the skill. The same call works whether the agent is running on :local or :docker. The seam itself is the one Part 6 described; this just gives skill assets a way through it.

Part 8, the post about dashboard_designer, depends on this. I’m making sure it shows the real mechanism before I publish it.

Supporting durability outside Rails

The durable-workflow material is still unpublished. checkpoint, suspend!, and resume all depend on reading a run’s state back from a store, and out of the box that store was ActiveRecord under Rails, or an in-memory store otherwise, with no way to override it. I already knew the in-memory default wasn’t durable for a plain Ruby CLI: it lives in the same process, so a checkpoint written just before the process exits has nothing left to resume from once it does.

Nexo.config.run_store plus RunStore::Disk is what closes that gap, built specifically to bring durability to hosts outside Rails: a store backed by a directory instead of ActiveRecord, so checkpoint/suspend!/resume work the same way on a laptop CLI as they do inside a Rails app. RunStore.default only ever had two automatic choices, and a plain Ruby host needed a third.

What building a real app taught Nexo

Two of these four turned out to be about nexo_mail’s own tools: better arithmetic, better batching. Nexo already gave me everything I needed to build SumPayments, Today, and the batched Gmail and HEY tools, I just hadn’t needed them yet when the earlier posts were written.

The other two turned into Nexo itself. Skills.materialize and Nexo.config.run_store don’t exist because I sat down and designed a complete skills-execution or durability system in the abstract. They exist because running nexo_mail for real, every day, against my own inbox, kept surfacing the same shape of problem: something I wanted the gem to just handle, rather than hand-roll again inside the app. Every time that happened, the question was the same one Nexo asks of itself throughout this whole series: what is the simplest thing Nexo could do here, so the next person building on it does less work than I just did.

requires(commands:, locale:), Sandbox#environment, produces(*names), and a skill’s compatibility: field came from the same place. None of them existed when this series started. nexo_mail needed to state a precondition instead of discovering it as a failure, more than once, and each time the fix belonged in Nexo, rather than repeated by hand in every app that would eventually hit the same wall.

What I’m fixing before post 8

Post 8 gets the Skills.materialize mechanism this post walks through, plus the artifact(from:) vs. artifact(path:) distinction it predates: from: renders the file as ERB, which is why the series has been careful to call it code, not data, and path: is the verbatim mode for anything that isn’t a trusted developer template. I’d rather ship that correction now than publish something I’d have to fix again in six weeks.

Next in the series: Part 8, a skill that ships a template and a script, revised.