Adding Gmail and HEY With Custom Ruby Tools
Read Gmail over IMAP and HEY through its CLI with custom RubyLLM tools, attached to a Nexo agent that stays read-only by construction, not by prompt.
Apple Mail was reachable over MCP, so Part 1 attached it
with the mcp macro and never wrote a line of integration code. Gmail and HEY are
different. Gmail I want to read over IMAP with an app password, without OAuth or a
Google SDK. HEY has a command line client and no public API. Neither speaks MCP.
For those, Nexo uses its other tool shape: a plain
RubyLLM::Tool. You write a small Ruby class that
does the work and returns a result, attach it to the agent, and the model calls it
the same way it calls an MCP tool. Everything
the harness
gives you, the sandbox and permission seams, still applies.
This post adds Gmail and HEY as custom tools, and introduces a base agent plus a single data-driven source, so a tool-based inbox is a descriptor you add to a table, not a class you write.
The code below is trimmed to the parts worth reading; the whole project lives at github.com/mariochavez/nexo-mail, so clone it if you want the full implementation, including the pieces this post only sketches.
A custom tool is a small Ruby class
A RubyLLM::Tool subclass declares a description, its parameters, and an execute
method. Both Gmail tools below share one connection helper, GmailImap.with_inbox,
which logs in, opens the mailbox read-only, yields the IMAP handle, and always
tears the connection down, whether the block succeeds or not:
module NexoMail
module Tools
module GmailImap
HOST = "imap.gmail.com"
module_function
def with_inbox
address = ENV.fetch("GMAIL_ADDRESS", "")
password = ENV.fetch("GMAIL_APP_PASSWORD", "").gsub(/\s+/, "")
if address.empty? || password.empty?
return {error: "Gmail is not configured: set GMAIL_ADDRESS and GMAIL_APP_PASSWORD"}
end
imap = Net::IMAP.new(HOST, ssl: true)
begin
imap.login(address, password)
imap.examine("INBOX") # read-only select: cannot set \Seen, move, or delete
yield imap
ensure
begin
imap.logout
rescue
nil
end
begin
imap.disconnect
rescue
nil
end
end
rescue Net::IMAP::NoResponseError => e
{error: "Gmail IMAP login failed: #{e.message}, check the App Password and that IMAP is enabled"}
rescue => e
{error: "Gmail IMAP error: #{e.class}: #{e.message}"}
end
def format_address(addr)
return nil unless addr
email = [addr.mailbox, addr.host].compact.join("@")
addr.name ? "#{addr.name} <#{email}>" : email
end
end
end
end
with_inbox is the one place a missing credential, a login failure, or a network
fault can happen, and it turns all three into an {error: ...} hash instead of
letting an exception escape, the same convention the CLI tools use later in this
post. examine("INBOX") is what makes read-only a property of the connection
itself rather than a promise made in the prompt.
Here is the tool that lists recent inbox messages:
module NexoMail
module Tools
module GmailImap
class List < RubyLLM::Tool
description "List recent Gmail INBOX messages (uid, from, subject, date) as JSON. Read-only."
param :only_unread, type: :boolean, required: false, desc: "Only unread messages (default true)"
param :limit, type: :integer, required: false, desc: "Max messages, newest first (default 20)"
param :since_days, type: :integer, required: false, desc: "Only messages newer than N days (optional)"
def execute(only_unread: true, limit: 20, since_days: nil)
GmailImap.with_inbox do |imap|
criteria = []
criteria << "UNSEEN" if only_unread
if since_days
# IMAP SINCE matches on the server's date; anchor to UTC so "last N
# days" is a deterministic boundary regardless of the local zone.
since = (Time.now.utc.to_date - since_days.to_i).strftime("%d-%b-%Y")
criteria += ["SINCE", since]
end
criteria = ["ALL"] if criteria.empty?
uids = imap.uid_search(criteria).last(limit.to_i)
next {messages: []} if uids.empty?
rows = imap.uid_fetch(uids, %w[ENVELOPE INTERNALDATE FLAGS]).map do |d|
env = d.attr["ENVELOPE"]
{
uid: d.attr["UID"],
from: GmailImap.format_address(env&.from&.first),
subject: env&.subject,
date: (env&.date || d.attr["INTERNALDATE"]).to_s,
unread: !Array(d.attr["FLAGS"]).include?(:Seen)
}
end
{messages: rows.reverse} # newest first
end
end
end
end
end
end
The description and the param lines are what the model sees; write them the way
you would write API docs, because that is exactly how they are used. execute
returns a Ruby hash, which ruby_llm serializes and hands back to the model as the
tool result.
Two details make this read only. with_inbox opens the mailbox with IMAP
EXAMINE, a read-only select, so the server itself refuses any change no matter
what a tool asks for. And the second Gmail tool, Read, fetches a message body
with BODY.PEEK, which reads while leaving the \Seen flag exactly as it found
it, so triaging your inbox preserves whatever was already read or unread. Both
guarantees live in how the IMAP session is opened and fetched, independent of
whatever the prompt happens to say. This is the IMAP equivalent of the MCP
allow-list from Part 1.
Here is Read, which fetches one message body by uid for the cases List’s
metadata alone cannot settle:
module NexoMail
module Tools
module GmailImap
class Read < RubyLLM::Tool
description "Read one Gmail message body by its UID (best-effort plain text) as JSON. Read-only."
param :uid, type: :integer, required: true, desc: "The UID from the list tool"
def execute(uid:)
GmailImap.with_inbox do |imap|
# BODY.PEEK[TEXT] fetches the body without setting the \Seen flag.
data = imap.uid_fetch(uid.to_i, ["BODY.PEEK[TEXT]", "ENVELOPE"])
next {error: "message uid #{uid} not found"} if Array(data).empty?
d = data.first
{
uid: uid,
subject: d.attr["ENVELOPE"]&.subject,
body: d.attr["BODY[TEXT]"].to_s.strip[0, 4000]
}
end
end
end
end
end
end
Same shape as List: one connection through with_inbox, one read-only fetch,
one Ruby hash back.
Wrapping a CLI safely
HEY has no API, but it has a hey command line client that can print JSON. The
temptation is to shell out with a string. Do not. A shell string is an injection
surface the moment any part of the command comes from model output or user input.
The tools run the CLI as an argv array through Open3, so every element reaches
the program as its own literal argument:
module NexoMail
module Tools
module CliReader
module_function
def json(*argv)
out, err, status = Open3.capture3(*argv) # argv array, never a shell string
unless status.success?
return {error: "#{argv.first} exited #{status.exitstatus}: #{err.strip[0, 300]}"}
end
begin
JSON.parse(out)
rescue JSON::ParserError
{raw: out.strip[0, 4000]}
end
rescue Errno::ENOENT
{error: "`#{argv.first}` not found on PATH, is it installed and authenticated?"}
end
end
end
end
Open3.capture3(*argv) passes each element as a separate argument to the program.
There is no shell in the path, which is what closes off quoting, globbing, and ;
chaining as attack surface. The subcommands are hardcoded in the tools; the model
picks a box name from a fixed set, and the command itself never leaves the tool.
Notice the return values. A non-zero exit becomes {error: ...}, and unparseable
output becomes {raw: ...}. The tool never raises. This is a convention worth
adopting for every tool you write: a tool that fails returns an error hash, which
the model receives as recoverable context and can adapt to. A tool that raises
crashes the whole loop. Nexo’s own built-in tools follow the same rule, and so do
denied permissions.
The HEY box tool builds on CliReader:
require "date"
module NexoMail
module Tools
class HeyBox < RubyLLM::Tool
description <<~DESC.strip
List postings from one HEY box as JSON (sender, subject, id). Read-only.
Returns at most the 40 most recent postings, and only those from the last
month; older mail is already discarded, so triage everything you get back.
Call once per box: "imbox" is people and things that matter, triage to
action or fyi. "feedbox" is newsletters and broadcasts, usually noise.
"trailbox" is receipts, orders, and confirmations, usually noise.
DESC
param :box, type: :string, required: false,
desc: "Which box: imbox (default), feedbox, or trailbox"
BOXES = %w[imbox feedbox trailbox].freeze
LIMIT = 40
def execute(box: "imbox")
name = BOXES.include?(box.to_s) ? box.to_s : "imbox"
data = CliReader.json("hey", "box", name, "--limit", LIMIT.to_s, "--json")
drop_stale(data)
end
private
# Keeps only postings active within the last calendar month. HEY does not
# expire mail out of these boxes on its own, so the tool enforces its own
# freshness bound instead of asking the model to judge what counts as old.
def drop_stale(data)
postings = data.is_a?(Hash) ? data.dig("data", "postings") : nil
return data unless postings.is_a?(Array)
cutoff = Date.today.prev_month
data["data"]["postings"] = postings.select { |p| recent?(p, cutoff) }
data
end
def recent?(posting, cutoff)
stamp = posting["active_at"] || posting["created_at"]
return true if stamp.nil? # undated: can't prove it's stale, so keep it
Date.parse(stamp.to_s) >= cutoff
rescue ArgumentError
true # unparseable date: keep rather than silently drop
end
end
end
end
HEY sorts mail into three boxes for the purposes of this tool, and each one means
something different for triage: imbox is people and things that matter, feedbox is
newsletters and broadcasts, trailbox is receipts and confirmations. HEY’s own CLI
has three more boxes, asidebox, laterbox, and bubblebox, but those hold mail
someone already deferred a decision on rather than mail waiting to be sorted, so
this tool leaves them out on purpose. The freshness bound is worth noticing too:
LIMIT caps what the CLI returns, and drop_stale throws out anything older than
a month before the agent ever sees it. Both are deterministic and live in the
tool, not in a prompt instruction asking a small local model to judge staleness on
its own.
There is a second HEY tool, for reading one full thread once triage has flagged something worth a closer look:
module NexoMail
module Tools
class HeyThread < RubyLLM::Tool
description "Read one HEY email thread by its numeric id, as JSON. Read-only."
param :id, type: :integer, required: true, desc: "The thread id from the Imbox listing"
def execute(id:)
CliReader.json("hey", "threads", id.to_i.to_s, "--json")
end
end
end
end
Same shape as HeyBox: a thin wrapper over CliReader.json, one hardcoded
subcommand, no room for the model to supply anything but the id.
Attaching custom tools
MCP servers attach with the mcp macro. Custom RubyLLM::Tool classes attach a
different way: you override the agent’s #chat method, call super to get the
chat Nexo built, and add your tools to it.
def chat(base: nil)
chat = super
chat.with_tools(Tools::GmailImap::List.new, Tools::GmailImap::Read.new)
chat
end
super gives you the fully wired chat, with the instructions, the skills, and the
built-in sandbox tools already applied. with_tools is additive, so your tools sit
alongside those. This is the seam Nexo leaves open for anything the macros do not
cover.
One base agent, and a source you configure with data
Two lists get reused across the classes below, so they are worth pulling out once
rather than repeating inline. SANDBOX_WRITE is the sandbox capability list every
source needs; MAIL_READ_TOOLS is the same MCP allow-list from Part 1, unchanged:
module NexoMail
SANDBOX_WRITE = %i[read glob write].freeze
MAIL_READ_TOOLS = %w[list_accounts list_mailboxes get_emails get_email search].freeze
end
Apple Mail, Gmail, and HEY differ only in how they reach mail. The model, the permission mode, the skills, and the instructions are identical. Repeating them in three classes would be a maintenance problem, so the shared configuration lives in a base class:
module NexoMail
module Agents
class SourceAgent < Nexo::Agent
sandbox :local
permissions Nexo::Permissions.new(mode: :read_only, allow: NexoMail::SANDBOX_WRITE)
skills :email_triage, :financial_summary, :interest_radar
instructions <<~TXT
You triage one email inbox using the attached skills and write the result as a
JSON array with the write tool. Read only: never send, delete, or modify mail.
TXT
# The two per-source inputs #chat wires. Instance readers, defaulting to
# class-level values, so a data-driven source can override them per instance.
def source_tools = self.class.source_tools
def prompt_key = self.class.prompt_key
def self.source_tools = []
def self.prompt_key = nil
# Preflight availability check every descriptor's `available?` calls into.
# Returns nil when the source can run, or a short reason string when it
# cannot (missing binary, missing credentials). This base implementation is
# always available; a subclass with a real precondition overrides it, the
# way `AppleMailSource` does below.
def self.availability = nil
# Pure-Ruby PATH lookup, no shell involved: true when `cmd` is an
# executable somewhere on PATH. This is what the Gmail and HEY
# descriptors' `availability` lambdas in the catalog below actually call.
def self.command?(cmd)
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
path = File.join(dir, cmd)
File.file?(path) && File.executable?(path)
end
end
# Sets model and provider on every triage class named, not just this one.
# Nexo's `inherited` hook (Part 2) copies macro state at the moment a
# subclass is defined, and by the time this runs, EmailSource and
# AppleMailSource already exist, defined before the model was known. So
# setting `model` here alone would never reach them; each class has to be
# named explicitly.
def self.configure_model!(model_name:, provider:, classes:)
classes.each do |klass|
klass.model model_name
klass.provider provider
klass.assume_model_exists true
end
end
def chat(base: nil)
chat = super
tools = source_tools.map(&:new)
chat.with_tools(*tools) unless tools.empty?
chat
end
end
end
end
Now look at what actually differs between Gmail and HEY: a list of tool classes
and a prompt key. That is data, not behavior. So instead of a class per source,
there is one EmailSource that reads those two things from a descriptor handed to
it at construction:
module NexoMail
module Agents
class EmailSource < SourceAgent
def initialize(descriptor:, cwd:)
@descriptor = descriptor
super(cwd: cwd)
end
def source_tools = @descriptor.tools
def prompt_key = @descriptor.prompt_key
end
end
end
The descriptors live in a small catalog. Each is a value object carrying
everything a tool-based source needs: its display name, the file it writes, its
tools, a prompt key, and an availability check. It also knows how to build its
agent. Data.define rather than Struct.new here, since a descriptor is built
once and never mutated, and Data makes that immutability real instead of just
implied:
module NexoMail
module Sources
Descriptor = Data.define(:name, :file, :prompt_key, :tools, :availability) do
def available? = availability.call
def build(cwd:) = Agents::EmailSource.new(descriptor: self, cwd: cwd)
end
def self.all
[
Descriptor.new(
name: "Gmail", file: "gmail.json", prompt_key: "gmail",
tools: [Tools::GmailImap::List, Tools::GmailImap::Read],
availability: -> { "Gmail not configured" if ENV["GMAIL_ADDRESS"].to_s.empty? }
),
Descriptor.new(
name: "HEY", file: "hey.json", prompt_key: "hey",
tools: [Tools::HeyBox, Tools::HeyThread],
availability: -> { "hey not on PATH" unless Agents::EmailSource.command?("hey") }
)
]
end
end
end
Adding another IMAP-or-CLI inbox later is a new row in this table, not a new class. (The availability lambdas are abbreviated here; the real ones also check that HEY is actually authenticated, not just installed.)
Apple Mail is the one exception, and it is an instructive one. It reaches mail
through the mcp macro, and that macro is class-level: Nexo stores it on the
class, fixed once, out of reach for a per-instance descriptor to vary. So Apple
Mail keeps its own small class:
module NexoMail
module Agents
class AppleMailSource < SourceAgent
permissions Nexo::Permissions.new(
mode: :read_only, allow: NexoMail::SANDBOX_WRITE, mcp_allow: NexoMail::MAIL_READ_TOOLS
)
mcp :mail, transport: :stdio, command: "apple-mail-mcp"
def self.prompt_key = "apple_mail"
# Overrides the inherited `nil` default: this source needs the MCP server
# binary on PATH, so `AppleDescriptor#available?` (in the catalog below)
# gets a real reason instead of always reporting available.
def self.availability
command?("apple-mail-mcp") ? nil : "apple-mail-mcp not found on PATH"
end
end
end
end
mcp_allow sits inside this same Permissions.new call, alongside the sandbox
allow: list, rather than as its own macro the way Part 1 wrote it. Nexo’s
Permissions constructor accepts it directly: same allow-list, same fail-closed
enforcement, just handed to the agent in one call instead of two. provider and
assume_model_exists reach this class the same way they reach every other source,
through configure_model!, called once every class below is defined:
NexoMail::Agents::SourceAgent.configure_model!(
model_name: ENV.fetch("NEXO_MODEL"),
provider: :ollama,
classes: [
NexoMail::Agents::SourceAgent,
NexoMail::Agents::EmailSource,
NexoMail::Agents::AppleMailSource
]
)
The local Ollama tag is still not in ruby_llm’s registry, so provider and
assume_model_exists are still both required, exactly as
Part 1 explained. What
changed is where they get set: not a fixed macro riding the inheritance chain, but
one explicit call naming every class, because that call has to happen after the
model is known, which is after every one of these classes already exists.
But I still want the catalog to list all three inboxes, so the workflow (next
post) can walk one uniform list. So the catalog carries a second, tiny descriptor
for Apple Mail that answers the same available?/build interface but builds its
own class:
module NexoMail
module Sources
AppleDescriptor = Data.define(:name, :file) do
def available? = Agents::AppleMailSource.availability
def build(cwd:) = Agents::AppleMailSource.new(cwd: cwd)
end
# ...and Sources.all lists all three: the Apple descriptor, plus the Gmail and
# HEY descriptors above.
end
end
Two shapes, then. Tool-based sources are data behind a single EmailSource, and
the one MCP source stays a class, but both are reached through the same descriptor
interface, so the catalog is one flat list. Both inherit SourceAgent too, for
everything except model and provider: Nexo’s inherited hook (from Part 2)
copies the parent’s macro configuration down, duplicating
arrays and hashes so each child gets its own copy to work with, and both share the
same triage behavior. Model and provider are the one exception, which is exactly
why they needed the explicit configure_model! list above instead of riding that
same inheritance.
I have granted :write here in addition to read and glob, because the agents will
soon write their output into a workspace instead of returning it as text. What that
:local sandbox is and how its writes are fenced is the subject of a later post;
for now, :write lets the agent call the built-in WriteFile tool.
Provider neutrality is not an accident
It is worth pointing out what this buys. Gmail is read over plain IMAP, not the Gmail API, so there is no OAuth dance and no Google client gem. HEY is read through its own CLI. Apple Mail is read through a local MCP server. None of the agent code is tied to a vendor. If Gmail changed its API tomorrow, the IMAP tool would stay exactly as it is. This is the same property MCP gave us in Part 1, applied to services that do not offer MCP at all: every source converges to the same abstraction, a tool the agent can call.
Where this leaves us
There are now three inboxes covered: Gmail and HEY through one data-driven
EmailSource, Apple Mail through its own class, each reading a real inbox read
only, all sharing one base. But they are still separate agents I would run by
hand, and there is no single result. Next I wrap them in a workflow, which walks
the source catalog, runs each one, records what happened in an inspectable event
log, and produces one digest, while staying resilient when a source is missing or
fails.
If you would rather read the finished thing than follow along post by post, the complete source is on GitHub at mariochavez/nexo-mail: the tools, the descriptor catalog, the skills from Part 2, and the workflow the next post builds.
What will trip you up
Write your tool descriptions and parameter docs carefully. They are the only thing the model knows about the tool. A vague description is the usual reason a model calls a tool wrong or not at all.
Return an error hash, never raise. {error: ...} is recoverable context the model
can work with. A raised exception ends the run. Wrap external calls and IO so a
missing binary or a bad response comes back as an error the model can see.
Run CLIs as argv arrays. Open3.capture3(*argv) with separate arguments has no
shell and no injection surface. Never interpolate model output into a command
string.
Attach custom tools by overriding #chat and calling super first: that is what
hands you the chat with the instructions, skills, and built-in tools Nexo already
assembled, ready to extend rather than rebuild.
configure_model! has to name every class explicitly. Setting model only on the
base after its subclasses already exist looks like it should work, and does not:
the inheritance copy already happened when those subclasses were defined.
Next in the series: Part 4, wrapping the sources in a workflow.