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.