above; hardcoding them here emitted a second, conflicting tag. %}

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.