A First Agent That Reads One Inbox Over MCP

Connect a Nexo agent to Apple Mail over MCP and triage an inbox read-only by construction, where a fail-closed allow-list makes send and delete impossible.

In the last post I set up Nexo and ran a five-line agent on a local model. It could reason about text I handed it, but it could not reach anything real. In this post I connect it to my Apple Mail inbox and have it produce a first triage.

The agent will be able to read and search the inbox, and it will be unable to send, delete, or move any message. That restriction comes from the permission gate, not from the prompt. This post covers how to attach a mail source over MCP and how the allow-list enforces read-only access.

How an agent reaches a service

A model cannot call your APIs on its own. Something has to hand your inbox to it as a set of tools it can invoke. The clean way to do that, without writing code tied to one vendor, is the Model Context Protocol, or MCP. A small server advertises a list of tools like search, get_email, and list_mailboxes, and the model calls them by name.

What I like about MCP is that it lives at the protocol level and never becomes a vendor SDK. The Apple-Mail-specific code lives entirely in the server; my agent just speaks MCP. If I swap the mail backend later, the agent code barely moves.

Nexo composes ruby_llm-mcp so you attach a server with one macro. For Apple Mail I use apple-mail-mcp, a local server that reads Apple Mail’s on-disk database:

pipx install apple-mail-mcp
# Grant your terminal Full Disk Access in System Settings, Privacy and Security
apple-mail-mcp init            # writes ~/.apple-mail-mcp/config.toml
apple-mail-mcp index --verbose # build the search index

It runs over stdio, which means Nexo launches it as a subprocess and talks to it over standard in and out. No network, no port to manage.

The agent

One file, dependencies and all, using bundler/inline so there is nothing to set up beyond Ollama itself. Attaching an MCP server pulls in ruby_llm-mcp alongside the harness, and the require: on nexo_ai matters: the gem name and the namespace it loads are not the same word.

One correction from the last post: pointing ollama_api_base at a local server is not enough on its own. ruby_llm still expects ollama_api_key to hold something, even though Ollama itself never checks the value, so it gets set from an env var rather than left nil.

#!/usr/bin/env ruby
require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "nexo_ai", "~> 0.8", require: "nexo"
  gem "ruby_llm-mcp", "~> 1.0"
end

RubyLLM.configure do |config|
  config.ollama_api_base = "http://127.0.0.1:11434/v1"
  config.ollama_api_key  = ENV["AI_API_KEY"] # required by ruby_llm, ignored by Ollama
end

Nexo.configure do |config|
  config.default_model       = ENV["NEXO_MODEL"] # provider neutral, no default
  config.default_sandbox     = :virtual          # in memory, zero host access
  config.default_permissions = :read_only        # cannot write, shell, or fetch
  config.skills_path         = "skills"          # where SKILL.md packages live
  config.concurrency         = :threaded         # :threaded or :async, later
end

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

  # Apple Mail, reached over MCP. A local stdio server, not a Mail SDK.
  mcp :mail, transport: :stdio, command: "apple-mail-mcp"

  # Read tools only. The server also exposes writes. They are simply not listed.
  mcp_allow %w[
    list_accounts list_mailboxes get_emails get_email
    search get_email_links get_email_attachment
  ]

  instructions <<~TXT
    You triage one Apple Mail inbox. Read recent messages with the mail tools,
    sort each into Action, FYI, or Noise, and write a short markdown digest of the
    ones that matter. Read only. Never send, delete, flag, or move mail.
  TXT
end

agent = AppleMailSource.new
begin
  digest = agent.prompt("Triage my inbox from the last day and give me a digest.")
  puts digest.content
ensure
  agent.close   # tears down the stdio subprocess, more on this below
end

provider and assume_model_exists are the same escalation from part 0, applied here because the local tag isn’t a registered model id. Both are explicit, both are opt-in, and both would come out if this agent pointed at a hosted model instead.

Save that as apple_mail_source.rb and run it:

NEXO_MODEL=gemma-4-26b-a4b-it-4bit AI_API_KEY=secret ruby apple_mail_source.rb

That is a working inbox agent. Run against a real inbox, it prints something like:

Here is a digest of your inbox from the last 24 hours.

### ⚡️ Action Items
*   **Complete Your Application:** You have a deadline approaching for your application. The cutoff is **July 31st**.

### ℹ️ FYI
*   **New Manning MEAP:** A new publication, *Architecting Accessibility*, is available for 50% off until August 7th.

### 🗑️ Noise
*   **GitHub Partnership Update:** An update from Resend regarding Claude, Codex, and GitHub.

The mcp and mcp_allow lines are worth reading closely, because together they define what the agent can and cannot do.

How the allow-list enforces read-only access

The mcp :mail line attaches the server. On its own that would expose every tool the server offers, and depending on the server that can include sending and deleting.

The mcp_allow line is the fence. It is an exact-match, fail-closed allow-list. Under :read_only, an MCP tool can be invoked only if its name appears in that list. Everything else is denied, and the denial comes back as an error the model can see and adapt to, without raising or crashing the loop. This is a separate axis from the filesystem sandbox we will meet later: MCP tools are gated by name here, file tools by the sandbox and permission mode.

One important default: mcp_allow starts as an empty list. Attach a server under :read_only and forget the allow-list, and the agent can call none of its tools. Tools are opted in by name, one at a time, rather than dangerous ones being opted out.

So this agent can search and get_email. Ask it for more and the gate does its job:

agent.prompt("Delete every newsletter in my inbox.")

The model reaches for a delete tool, the call is denied because it’s not in mcp_allow, and it reports back on its own:

I am unable to delete emails from your inbox. My capabilities are limited to reading, searching, and summarizing emails. I cannot perform destructive actions like deleting, moving, or flagging messages.

Nothing was deleted, and there is no “do not delete” rule anywhere in the code. The allow-list is the enforcement, which is why an agent built this way is safe to run against a real account.

What the harness provides

Note what the code does not contain. There is no MCP client, no JSON-RPC, no tool-call parsing, no retry loop, and no permission checks scattered through it. I declared a model, a permission mode, one server, and an allow-list.

Nexo also attaches its four built-in tools: ReadFile, WriteFile, Glob, and, only when the sandbox can run commands, Shell. The default :virtual sandbox cannot run shell, so no Shell tool is ever offered to the model. We only read mail here, so I do not lean on those yet. In a later post the agent gets a real workspace to write its digest into.

Progress is observable too. #prompt takes a block that streams events as the agent works, every tool call and result, which is exactly what I wire into a live status display later on:

agent.prompt("Triage my inbox.") do |type, payload|
  case type
  when :tool_call   then warn "calling #{payload}"
  when :tool_result then warn "got #{payload.to_s[0, 80]}"
  end
end

Where this leaves us

There is one agent, reading one real inbox, read only by construction, on a free local model. But the triage policy, what counts as Action versus Noise and which senders always matter, is buried in a Ruby heredoc. If I change my mind about triage I am editing code and redeploying. And a small local model needs more than three sentences of guidance to classify well.

That is the problem skills solve, and it is where I go next.

What will trip you up

A few things caught me here, and none of them are hard once you have seen them.

The empty allow-list is the big one. If the agent seems unable to find any tools, you attached a server but allow-listed nothing. Add the exact read-tool names. Matching is exact, tool name against tool name, nothing more forgiving than that.

The gate governs whether the model may call a tool. What that tool does once called is between the model and the MCP server, running outside Nexo’s sandbox entirely. So allow-list deliberately. Prefer :read_only with a tight list, and do not allow-list a tool whose side effects you would not want.

Close the agent. The stdio server is a subprocess memoized on the agent instance and reused across prompts. Call agent.close, ideally in an ensure, or you will leak processes.

ollama_api_base alone will not connect you to anything. ruby_llm still wants ollama_api_key set to a non-nil value before it builds the client, even though Ollama itself never checks it. Any string works; an env var you never share publicly is the tidy way to supply one.

And keep an eye on small models and system messages. Local models are shakier at tool calls than hosted ones, so it helps to bake concrete examples into your guidance, which is exactly what the skill in the next post does. Nexo also stores the agent instructions, the sandbox description, and each skill as separate system messages. Most chat templates accept that. A strict llama.cpp template that wants a single system message at the very beginning won’t accept that layout, and the fix belongs in the model server’s chat template, not in your app.

Next in the series: Part 2, moving the triage rules into a skill.