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.