Pixie's agent is not the only agent on your Mac. Claude Desktop is sitting right there. So is whatever local model you run in some other harness. Sooner or later you will want to type "ask Pixie who I've been neglecting" into a window that isn't Pixie's. The moment you want that, Pixie has to expose its most sensitive data to a program it did not write, does not update, and cannot vouch for. This article is the machinery that makes that safe, the mechanism we call Puppeteer: a server that only your own machine can reach, reads that structurally cannot touch the sealed parts of your life, and writes that stop mid-flight until a human approves the exact words.
This is a mechanism deep-dive under The Homebase, which explains why a Mac joins your account as a full peer at all. Everything here lives on that Mac: the whole feature is fenced to the Catalyst build (the Mac build of the same codebase), because a door for desktop agents living on the same machine only makes sense on the desktop.
If you have read Skills, Tools, and the @-Namespace, keep one contrast in mind throughout: none of the tools in this article are the tools Pixie's own agent uses. This is a second, separate tool surface, built for guests.
MCP (the Model Context Protocol) is the industry's standard answer to "how does an
AI app use another app's abilities?" An application runs an MCP server that publishes a
catalog of typed tools, each with a name, a description, and a JSON schema: a
machine-readable spec of the arguments it accepts. Any compliant client (Claude
Desktop, an IDE, a local-model harness) connects, calls tools/list to discover
the catalog, and then invokes tools with tools/call. The protocol itself is boring plumbing, and that is its virtue: implement
it once and every MCP-speaking agent, present and future, can drive your app.
That universality is exactly the danger. The client on the other end is a program you did not choose the weights for (the trained numbers that make a model behave the way it does), running prompts you did not see, possibly manipulated by a webpage it just read. Whatever you expose over MCP, you expose to anything that can speak it. So the design question is never "which clients do we trust?" (you cannot answer that) but "what shape of door can we leave open to a stranger?"
Pixie's answer has three layers, and each is structural, enforced by what the code can reach, not by what a handler remembers to check:
The rest of the article walks the layers in order, then closes with the two pieces of glue that make the whole thing legible: attribution and the audit log.
The server is off by default in the strongest sense: it is not merely idle, it is not
listening: no network socket (the operating-system object that accepts connections) exists
until you flip the toggle in Settings. Flipping it constructs the stack in
MCPServerService.start(). A foreign client like Claude Desktop speaks stdio (plain
text over the pipes local programs use to talk to each other) to mcp-remote, which
forwards each request as HTTP with a bearer token to 127.0.0.1:11939.
There MCPHTTPListener (the loopback bind and the token gate)
feeds request values to the swift-sdk's StatefulHTTPServerTransport, which drives
MCPServerService. From that point, read tools resolve against MCPReadModel's
projections (§4), and write tools enter the parse → gate → sheet pipeline (§5–6).
The bottom three layers are Pixie's. The MCP swift-sdk provides Server and
StatefulHTTPServerTransport, but the transport deliberately does not bind a network port: it
just processes HTTP request values. MCPHTTPListener is the thin adapter that does bind one.
How it binds is the first structural guarantee: the underlying NWListener is pinned to
the loopback interface, 127.0.0.1, port 11939.
Not "bound to all interfaces and filtered," but bound to loopback: the operating system will not deliver an off-machine packet to it. There is no hosted endpoint, no hole punched in any firewall, nothing for anyone who is not running code on your Mac.
Loopback alone is not enough, though, because "code on your Mac" includes every process on your
Mac. That is what the bearer token is for. When you enable the server, it generates 24 bytes
from the system's cryptographic random source, encodes them URL-safely, and requires every
single request to carry Authorization: Bearer <token>. The check happens in the
listener, before the transport or any tool code ever sees the request; anything else gets a
bare 401, the standard HTTP "not authorized" refusal.
The token is regenerated on every enable and destroyed on stop, so a token that leaked into some client's config file stops working the moment you cycle the switch.
One note on where the token lives, since it is the obvious question: nowhere. It is an in-memory value, minted when the server starts and dropped when it stops. It is never written to disk in any form, so there is nothing at rest for a Keychain (Apple's encrypted credential store) to protect. That also settles the threat model, the question of exactly which attacker you are defending against.
The token's job is to keep other local processes out of a loopback port. The network never sees it. And a local attacker positioned to read Pixie's memory has already won more than the token would give them. Storage should match the adversary, and this adversary is "another app on the same machine guessing a port."
Because the intended client is Claude Desktop through the mcp-remote shim (a small
adapter that bridges Claude Desktop's stdio transport to a local HTTP endpoint), the
Settings pane renders the exact configuration block to paste (server URL, header, and
current token), so enabling the door and connecting a client is a copy-paste, not a
scavenger hunt.
Last property of the door: it is single-occupancy. The transport is stateful and the server holds exactly one client slot, so there is one connected agent at a time, which turns out to matter for the next section.
Every MCP session opens with an initialize handshake (the protocol's opening
exchange) in which the client declares clientInfo: its name and version ("Claude",
"Ollama", …). Pixie hooks that handshake: the server passes an initializeHook
closure (a small callback function) to the swift-sdk Server, which fires on connect and
stamps the declared name into MCPServerService.connectedClientName. The name is
cleared on stop and on every fresh start, so a stale identity never outlives its connection.
And because the door is single-occupancy, "the connected client's name" is a
well-defined fact rather than a guess.
That name becomes the attribution on everything the guest does. A client that never introduced itself falls back to the generic label "MCP client":
send_message (Claude), not just
send_message.One boundary and one caveat, both load-bearing. The boundary: attribution is local-only. It is stored on your device and echoed to your sibling devices, your other devices on the same account, but it never rides the wire to the recipient. The peer receives a completely normal message, indistinguishable from one you typed.
Whether to disclose your co-author is your call, not a protocol side effect. The caveat: the name is self-declared. Any client can call itself "Claude," so attribution is honest labeling, not authentication. It exists to keep your own records legible, and the actual security never rests on it: a client named anything at all still faces the same projections and the same approval sheet.
The catalog's read tools:
| Tool | Returns |
|---|---|
list_conversations |
your threads: peer name, last message preview, activity time, count |
get_messages |
one conversation's messages, oldest → newest |
search_messages |
keyword search across message bodies |
list_contacts |
contact names + whether each is on Pixie |
search_notebook |
matches from the agent's Notebook: clear zone only |
list_posts |
the recent feed: post id, author, content, time |
The interesting design is not the list; it is the plumbing every item shares. A read
tool's handler does exactly one thing: dispatch, by name, into MCPReadModel, a
single facade struct (one front object every request passes through) that is the only
data surface MCP reads flow through. And the facade is a whitelist of projections,
a projection being a narrow copy of the data that holds only chosen fields. Each method builds
a purpose-shaped output struct (ConversationSummary, MessageOut, ContactOut, PostOut) containing exactly
the fields the tool advertises, and nothing else exists to return.
The security claim is worth stating precisely, because it is the article's central trick:
MCPReadModel has no code path to three things. Not to the Notebook's sealed zone (NotebookNote.sealedBody,
the agent-only notes encrypted under a key you deliberately don't surface; see Mechanism:
Memory & the Notebook). Not to RelationshipState.circle (the
agent's private closeness assessment of each contact). And not to p2pState (the raw
per-relationship bookkeeping).
This is enforcement by omission: the trust boundary is not a filter sprinkled through tool handlers ("remember to strip the circle field"), it is the absence of any function that reads those fields at all. A future contributor adding a seventh read tool composes existing projections and inherits the boundary. Leaking the sealed zone would require writing new code that reaches for it, the kind of change a reviewer sees.
You can watch the principle at work in the smallest projection in the file:
struct ContactOut: Codable, Sendable {
let name: String
let onPixie: Bool
// NB: no circle, no p2p_state — those never leave the owner's trust domain
}
The struct is the policy.
A few finer exclusions ride along. Deleted messages stay deleted: every message projection
filters rows you removed. @pixie agent-tag rows (the inline summons where you talk privately
to your own agent inside a thread) are excluded from every conversation read. That mirrors
the exclusion the peer-send gate enforces when a message actually leaves for a peer: your
side-channel with your agent is not thread content, for guests or anyone.
And search_notebook does not query notes and filter; it calls the Notebook store's
clear-only search entry point, so sealed rows are outside the query itself.
One more deliberate asymmetry, easy to miss: this catalog is not the tool registry Pixie's own agent uses. The on-device agent's @-namespace has no raw read-every-message tool and no message-send tool; those powers exist only here, on the consent-gated external surface. Two audiences, two doors. The more powerful door has a human standing in it, as the next two sections show.
(Amusingly, the two worlds collide even at the type level: the MCP protocol's call-a-tool
request is named CallTool, and so is Pixie's own L2 meta-tool
from Mechanism: CallTool, One Tool to Bind Them, so the server code
has to write MCP.CallTool to keep the compiler pointed at the right one.)
There are exactly three write tools (send_message, create_post,
comment_on_post), and their own catalog descriptions warn the calling model what it
is in for: "The user approves every send inside Pixie before anything goes out — this
exact text, this recipient, this once." Setting the client's expectations in the tool
description is not decoration; a model that knows a human gate exists writes drafts for
the human, not around them.
Every write call runs the same pipeline inside the server. First, parse and resolve into a typed request. Then the rate and size gate: both fail with an error and no sheet. Then the approval sheet that suspends the call, the sealed send stamped with attribution, and only then the ledger record that makes it count toward the caps.
Stage 1: parse first, always. Arguments are parsed into a typed
MCPWriteRequest (a small enum with one case per tool) and validated before anything
else happens. Bodies are trimmed and must be non-empty. A send_message key must be a
well-formed 1:1 conversation key: p: plus a public key written as 64 hexadecimal
characters. (Group threads, keyed g:, are explicitly unsupported over MCP in v1,
because the group send path needs member resolution this surface doesn't carry yet.) A
comment's post_id must decode to a real id, must name a post that exists, and must
target a contact's post, never your own. Post recipients are resolved from contact
names to actual keys, and one unknown name fails the whole call with the list of names it
couldn't find.
The ordering is the point. Parsing and resolution complete before the gate and long
before the sheet. That buys two properties at once. A malformed call fails cheaply,
without ever burning the approval surface. And, more importantly, the user only
ever sees a well-formed, fully-resolved draft. The sheet never shows "send <maybe this parses> to <someone, hopefully>." By the time a human is asked, the machine's
uncertainty is gone.
Stage 2: the gate. MCPWriteGate is a deliberately tiny, pure value type (no
side effects, no storage, and a clock handed in from outside), so tests can control time and
hammer it exhaustively. It enforces three caps: bodies at most 4,000 characters, at most
10 sends per rolling
hour (counted over the last sixty minutes, not per calendar hour; messages and
comments share this class), and at most 5 posts per rolling
day. Its history comes from MCPWriteLedger, a persisted list of timestamps of prior
approved writes. Persisted matters: the caps survive an app relaunch, so a client cannot
reset its quota by getting you to restart Pixie. Two subtleties encode the philosophy.
First, only writes that were approved and sent are recorded (stage 5), so a denied or
failed request never burns quota: the caps meter what actually went out, not what was
attempted. Second, notice what the rate limit actually protects: not the relay (the server
that carries Pixie's messages, which could shrug off far more) but you. An approval sheet is only meaningful if it is
rare enough to read. A runaway client that could raise sixty sheets an hour would train
you to tap Approve without looking; the gate caps the ask-rate so each ask stays a real
question. Fatigue is an attack surface.
Stages 3–5 are the approval ceremony itself, which deserves its own section.
Two availability notes complete the pipeline. If Pixie isn't fully onboarded on the Mac yet, write tools report themselves unavailable rather than half-working. And every write attempt (including ones that fail parsing or get denied) lands in the audit log the moment it arrives, before any other stage runs. The log records asks, not just outcomes.
Here is the mechanism the whole article has been building toward. When a write reaches
stage 3, the tool handler calls requestApproval and suspends. The server parks
the in-flight MCP call on a Swift continuation (a paused function held in memory along
with a handle that can resume it exactly once) and publishes an MCPPendingWrite: the
tool name, a human summary ("Claude wants to message Ada"), and the body, verbatim. The
MCP client sees nothing yet; from its side, the tool call is simply still running.
The pending write is observed from the app root. A view modifier installed at Pixie's root
view (via a small process-wide hub, MCPHub) presents the approval sheet the moment a pending
write exists, which means approval works wherever you are in the app, not just if you happen to
have Settings open. The sheet shows four things: the action (send_message), the request
summary with the asker's name, a plain-language footer ("An AI app connected over MCP wants
Pixie to send this.
Nothing goes out unless you approve — this exact text, this recipient, this once."), and a
Draft section containing the exact body that will go out. Not a summary of the draft, not a
truncated preview: the text itself, selectable, the very string that stage 4 will hand to the
send path. What you approve is what is sent, by construction: the approved MCPWriteRequest
value is the input to the send, with no re-parse and no rewrite between the sheet and the
wire.
Every way out of the sheet except the Approve button is a denial:
A denial, by any route, resumes the tool call with the string "The user declined this request." flagged as an error, a deliberate phrasing. The client's model learns that a human said no, which is exactly the context it needs to stop, rephrase, or ask its own user what to do differently.
On Approve, stage 4 hands the draft to ComposeService, the one door all of Pixie's outbound
writes leave through, MCP or not.
That single funnel means an MCP-driven message inherits the entire send machinery for free: it goes out as a sealed send (end-to-end encrypted, so only the recipient can read it, with the relay unable to link sender to recipient; the Relay series covers that sealing), and it echoes to your sibling devices, so the conversation your phone shows includes the message your Mac's guest just sent, labeled "You · Claude."
Only after the send succeeds does stage 5 record the ledger timestamp; a send that fails reports the failure to the client and costs no quota.
The last piece is the humblest: a rolling audit log (tool name and timestamp, newest first, capped at the last 200 calls) rendered in the same Settings pane that hosts the on/off toggle. Read calls log their name; write calls log their name plus the asking client's attribution, so the pane shows who drove each send, not merely that one happened.
It would be easy to dismiss this as UI garnish, but it closes a real gap. Reads never raise a sheet; that is by design, since a per-read ceremony would make the whole surface unusable, and reads were shaped safe in §4 rather than gated. The audit log is the compensating control: reads are invisible in the moment but visible in the record.
If a client you connected turns out to be chatty (polling your conversations every thirty seconds, searching for things you never asked it to care about), the log is where that behavior becomes legible, and the toggle above it is where it stops. Ephemeral, local, capped: not surveillance infrastructure, just enough memory to answer "what has this thing been doing?"
Step back and the design resolves into one sentence per layer:
| Layer | Mechanism | Guarantee |
|---|---|---|
| Reach | loopback bind + per-enable bearer token | only a program on your Mac, only while the switch is on |
| Identity | initialize handshake → attribution |
every act labeled with its driver, "You · Claude" |
| Reads | one whitelisted projection facade | sealed zone / circle / p2p state unreachable by omission |
| Writes | parse → gate → suspend on the sheet | the exact draft, approved per call, or nothing |
| Memory | rolling audit log | the invisible calls leave a visible record |
The recurring move, the one worth carrying to any system that lets a foreign agent in, is that consent here is a structural property, not a policy. The server does not promise to keep sealed notes from guests; there is no function that returns them. The write path does not promise a human reviewed the text; the tool call is physically suspended on a continuation that only the sheet can resume.
Policies are sentences in documentation that code can drift away from. Structure is what the code cannot do, and against a guest whose behavior you cannot predict (which is every MCP client, forever), what the code cannot do is the only guarantee that survives contact with a stranger.
tools/list, tools/call, and the initialize handshake that
carries clientInfo.Server and stateful HTTP transport the loopback listener adapts.mcp-remote. The stdio↔HTTP shim that
lets Claude Desktop reach a local HTTP MCP server with a bearer header.NWListener). The loopback-pinned socket under the door.