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.