Merging the Sources Into One Digest
Why the merge, dedupe and narration of three email extractions lives in a Nexo skill rather than in Ruby, and the seam for fixing model arithmetic.
After
Part 6
the workflow produces three JSON files, one per source, each a list of classified
items. That is not a briefing. The same newsletter can arrive in two accounts, the
payments are scattered across the files, the meetings are not in order, and there
is no narrative tying anything together. I want one digest.json that is the whole
picture, and a short inbox-digest.md for the terminal.
The obvious way to build that is a pile of Ruby: parse the three files, dedupe, group, sum the money, sort the schedule, write the markdown. I did not do it that way, and this post is about why. The merging and narration is judgment, and in this design judgment belongs to an agent and a skill, not to the library. The Ruby stays thin.
As in the earlier posts, the whole project is on GitHub at mariochavez/nexo-mail.
One more agent, no mail tools
The synthesis step is another agent. It reads the per-source files the source agents wrote, and it writes the two outputs. It reaches no mail, so it attaches no mail tools, and it uses a different set of skills than the extraction agents:
class Synthesize < SourceAgent
# Reset the extraction skills inherited from SourceAgent; this agent narrates.
@skills = %i[inbox_synthesis financial_summary interest_radar]
instructions <<~TXT
You build the digest from the per-source extraction files in the workspace.
Read the source JSON files, merge and de-duplicate the items, roll up the money
per currency, order the schedule, and narrate stories, people, and topic
briefings. Write two files: digest.json (the canonical data) and
inbox-digest.md (a short terminal digest). You do not read mail.
TXT
end
This is where the inheritance detail from
Part 2
matters in practice. Synthesize inherits from SourceAgent, so it would inherit
the extraction skills, and those would fight its job. Because the skills macro
accumulates rather than replaces, resetting means assigning the class variable
directly with @skills = .... Now the synthesis agent carries only the skills
about synthesizing.
Everything else it inherits: the model, the :local sandbox, and the
read-only plus write permissions from Part 6.
It reads the source files and writes two new ones, all inside the same fenced
workspace.
The judgment lives in the skill
The interesting content is not in the Ruby above. It is in the inbox_synthesis
skill, which is where the actual rules live. That skill specifies how to merge and
dedupe, and it does dedupe on two axes.
The first is cross-source. The same message can land in two accounts, so identical sender and subject across files collapse to one item that records both sources. The second is cross-group. A single message should appear in only its strongest place, so a message that belongs to a narrative story is not also listed on its own, and a newsletter folded into a topic briefing is not repeated in the general list. The skill defines the precedence and the model applies it.
The skill also defines the money roll-up, the schedule ordering, and the shape of
the stories, the per-person notes, and the topic briefings. And it defines the
exact JSON contract for digest.json, down to the field names, because everything
downstream, including the dashboard in the next post, is built from those fields.
None of that is Ruby. If I want to change how dedupe works, or add a category, or change what counts as a story, I edit the skill. The library does not move.
Wiring it into the workflow
The workflow gains one more stage after the sources. It hands the synthesis agent the names of the files that were produced and a timestamp to stamp into the output. That timestamp comes from the payload if the caller supplied one, or from the current time if not:
def call(payload)
stamp = (payload || {})[:generated_at] || Time.now.utc
available, skipped = partition_sources
skipped.each { |name, reason| emit(:source_skipped, source: name, reason: reason) }
produced = fan_out_sources(available)
return {sources: [], skipped: skipped} if produced.empty?
drive_agent(Agents::Synthesize, "synthesis", synthesis_prompt(produced, stamp))
{sources: produced.keys, skipped: skipped}
end
drive_agent is the small helper from
Part 4 that
instantiates an agent, prompts it, forwards its events into the run log, and closes
it. fan_out_sources is the
bounded concurrent fan-out from Part 5.
synthesis_prompt builds the actual prompt text from produced and stamp,
calling stamp.iso8601 to give the agent an unambiguous timestamp to write into
the digest, which is why that line at the top of call matters: pass the raw
payload through instead of the resolved stamp, and that call breaks the moment
payload is a hash without an iso8601 method of its own. The workflow does not
read the files, does not parse JSON, and does not decide anything about the
content. It sequences the stages and records what happened.
Why keep the Ruby this thin
This is a deliberate design choice, and it is worth stating plainly because it runs against the instinct to do the “real work” in code. The rule I settled on is that Ruby provides tools and orchestration, and the agents with their skills do the work. Every piece of judgment, from what counts as Action to how to dedupe to how to phrase a story, lives in a skill.
The payoff is that behavior is tuned by editing Markdown, by a person who does not
need to touch Ruby, without a redeploy. The cost is honesty about one thing: the
money totals are summed by the model, not by Ruby. On a small local model,
arithmetic can drift. 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, so the summing is deterministic while the decision to call it stays with the
agent. In nexo_mail I left the arithmetic in the model and documented the
trade-off; the point is that the seam for fixing it is a tool, not a rewrite.
A word on structured output
The synthesis agent writes digest.json by following the JSON shape the skill
describes. Downstream, the dashboard render script reads that file with a plain
JSON.parse, no rescue, no tolerance for a malformed entry. A broken digest fails
that step loudly, on purpose: better to see a crash and know the digest is bad than
to render a dashboard quietly missing a section because Ruby swallowed a parse
error. That is the opposite instinct from most of this series, where a single
failing source gets caught and the run carries on, and the difference is where each
failure sits. A source failing mid-extraction is expected and recoverable; a
malformed digest.json after synthesis means the one contract everything
downstream depends on is already broken, and there is nothing safe left to render.
If you want the output validated before it gets this far, Nexo does not provide
that itself: structured output lives in ruby_llm-schema, and you would apply it
to the chat Nexo built with chat.with_schema. Either way the contract is written
once, in the skill, and both the synthesis output and the dashboard depend on it.
Where this leaves us
There is now a single digest.json that is the whole briefing, plus a markdown
version for the terminal, produced by an agent following a skill, with the library
doing nothing but orchestration. The last output missing is the one a person
actually wants to look at: a dashboard. That is the next post, and it introduces a
new capability for a skill, shipping its own template and a script, which forces a
careful, narrow decision about the read-only guarantee.
What will trip you up
Reset skills with the class variable, not the macro. A synthesis agent that
inherits an extraction base will carry the extraction skills unless you assign
@skills directly, because the macro only accumulates.
Pass the resolved value, not the raw payload. synthesis_prompt needs a Time it
can call .iso8601 on. Compute stamp before calling drive_agent, and pass
stamp, not payload.
Keep the JSON contract in one place. The skill defines the field names, and both the synthesis output and everything downstream depend on them. If you rename a field, do it in the skill and update the consumers, or the dashboard silently loses a section.
Be honest about model arithmetic. If exact numbers matter, do not sum them in the model. Give the agent a deterministic tool to call. This keeps the thin-Ruby design while making the totals trustworthy.
The digest is only as good as the extraction. Synthesis cannot recover a field a source agent never captured. When a section looks thin, the fix is usually in the extraction skills, not the synthesis one.
Next in the series: Part 8, a skill that ships a template and a script.