Moving Triage Rules Out of Ruby and Into a Skill
Move an email agent's triage policy from a Ruby heredoc into a Markdown skill, and why a skill adds instructions only and never widens what the agent can do.
At the end of the previous post the agent could read an Apple Mail inbox and produce a digest, but the triage policy lived in a Ruby heredoc. That has two practical problems. Changing the policy, for example treating anything from my accountant as Action, means editing code and redeploying. And three sentences of instruction are not enough for a small local model to classify consistently.
Nexo separates these concerns. What tools exist and what the agent may do stay in Ruby, gated by the harness. How the work should be done, meaning the taxonomy and the house style, moves into a skill: a Markdown file the model reads as guidance.
What a skill is
A skill is a SKILL.md package. It has YAML frontmatter with a name and a
description, followed by Markdown instructions. Nexo composes
ruby_llm-skills, so you
attach one with a single class macro and no loader setup.
The layout is a directory per skill under your skills path. For this demo that’s
the plain skills/ folder Nexo.configure already pointed at in
part 1, so there is nothing new to set up here. A Rails
app would use app/skills/ instead, and you would not set skills_path at all:
skills/
└── email_triage/
├── SKILL.md # frontmatter and the triage instructions
└── references/ # optional supporting docs the skill can cite
Here is a first email_triage/SKILL.md:
---
name: email_triage
description: Sort recent messages into Action, FYI, or Noise and summarize the ones that matter. Read-only classification.
---
# Email Triage
Sort every message into exactly one bucket:
| Bucket | Meaning |
|--------|---------|
| Action | The reader must reply, decide, or do something. |
| FYI | Worth knowing; no response needed. |
| Noise | Newsletters, receipts, notifications. Left out of the digest. |
Classify from the metadata you already have: sender, subject, snippet. Open a full
message only to settle a genuinely ambiguous case.
## Always surface
Treat any message from these as important, Action if it asks for anything and FYI
otherwise, and always include it:
- Anything mentioning FOTOSETIEMBRE.
## Your deliverable
Write this markdown, most important first:
# Inbox Digest, <date>
## Needs action (<n>)
- <sender>, <subject>: <the one thing that matters>
## FYI (<n>)
- <sender>, <subject>: <why it matters>
<n> noise messages skipped.
You attach it with the skills macro and remove the policy from the Ruby. The
provider and assume_model_exists lines from part 1 stay: the local Ollama tag
still isn’t a registered model id, and dropping them here would bring back the
exact error part 1 was written to avoid. The MCP allow-list also gets narrower,
since a skill working from metadata alone has no use for the link and attachment
tools the first post carried:
class AppleMailSource < Nexo::Agent
model ENV.fetch("NEXO_MODEL")
provider :ollama # NEXO_MODEL is a local tag, not in ruby_llm's registry
assume_model_exists true # skip the models.json lookup that tag would fail
permissions :read_only
mcp :mail, transport: :stdio, command: "apple-mail-mcp"
mcp_allow %w[list_accounts list_mailboxes get_emails get_email search]
skills :email_triage # the policy now lives in Markdown
instructions <<~TXT
You triage one Apple Mail inbox using the attached email_triage skill.
Read recent messages and produce the digest it describes. Read only.
TXT
end
The agent behaves the same, but the policy is now a file a non-Ruby collaborator can edit. Change the taxonomy, add a sender, restyle the digest, all without touching Ruby or redeploying.
A skill contributes instructions only
This is the property that keeps skills safe, and it is worth being precise about. A loaded skill contributes instructions only. It ships no tools of its own, and attaching one never widens what the agent can do.
A skill can describe using the search tool, but mcp_allow is still what
decides whether that call goes through. Nexo also leaves out the
progressive-disclosure tool from ruby_llm-skills, the one that would let the
model read files outside the sandbox. The model reaches a skill’s references/
directory only through Nexo’s own gated tools. So a skill is guidance layered on
top of the harness, and it cannot become a hole in it. That matters in a later
post, where a skill ships an actual script.
Skills accumulate, and a subclass detail
The skills macro accumulates. Two lines add up, deduplicated, which lets you
compose a base policy with a per-agent addition:
skills :email_triage
skills :formatting # adds to :email_triage, does not replace it
Accumulation interacts with inheritance in a way that is easy to miss. When you
build a base agent and subclass it, which is how the next post shares config
across Apple Mail, Gmail, and HEY, Nexo copies the parent’s skill list onto the
child. If a subclass then needs a different set, for example a synthesis agent
that should not inherit the extraction skills, calling skills on it adds to the
inherited list rather than replacing it. To reset, assign the class variable
directly:
class Synthesize < SourceAgent
# The `skills` macro only accumulates, so override the inherited class ivar
# directly: this agent builds the digest, it doesn't read inboxes.
@skills = %i[inbox_synthesis financial_summary interest_radar]
end
instructions replaces, skills accumulates. The asymmetry is deliberate, but it
is the source of a confusing “why does my synthesis agent still have the triage
skill” moment if you do not know it.
About structured output
The skill above asks for markdown, which is fine for reading in a terminal and
useless for building anything on top of, such as a dashboard, totals, or a
schedule. As the tool grows we want structured data, and the skill is where the
contract is specified. A later version of email_triage asks for a JSON array of
items with fields like bucket, category, sender, and payment, and
downstream steps build the digest and dashboard from those fields.
Structured output lives in ruby_llm-schema, one of the gems Nexo composes,
rather than in a Nexo macro. If you want the response validated against a schema,
you call chat.with_schema directly on the chat Nexo built for you. In
nexo_mail the pipeline takes a different shape: each source agent writes its own
JSON file into the workspace, and a separate synthesis agent reads all of them and
does the merging, deduping, and rollup itself, as a model task rather than as
Ruby-side validation. The schema path is there when you want stricter enforcement
instead. Either way, the contract for what a source agent emits is described in
the skill.
Shipping default skills with a gem
One deployment note that pays off later. When you ship a tool as a gem, you want
sensible default skills and the ability for a user to change them. The pattern
nexo_mail uses is to ship the skills inside the gem and, on first run, copy them
into the user’s config directory only if they are absent. The packaged copy is the
default; editing the copy in the config directory is the override.
The consequence to remember: because seeding is copy-if-absent, editing the packaged skill in the gem does not reach a user who already ran the tool. Their copy wins. When you iterate on a shipped skill you have to sync it into their config directory, or delete their copy so it re-seeds. This is correct behavior, since you never overwrite a user’s edits, but it is surprising the first time.
What will trip you up
A skill grants no authority. If the model follows the skill and still cannot do
something, the skill described a tool that is not allow-listed. Capabilities come
from permissions and mcp_allow alone.
The skills macro accumulates across inheritance. A subclass that needs a fresh
set has to reset @skills directly.
ruby_llm-skills is a soft dependency. require "nexo" works without it, and the
first time you touch a skill you get a MissingDependencyError if it is not
installed. A missing SKILL.md raises Nexo::Error naming the path.
Next in the series: Part 3, adding Gmail and HEY with IMAP and CLI tools, so the agent meets Nexo’s other tool shape.