Agent
Mechanism: Sharing a Homebase
17 min read
Tim has a Mac Studio running a model his friends' phones could never load. Jim has a phone. The Homebase ended with Tim inviting Jim to use his models — read-only access to compute, nothing else — BigBrain pointed at someone else's Homebase. This article is the machinery under that sentence: the wire the invitation travels on, the grant that authorizes it, the reason Jim's prompts must cross in the open, the encrypted cache that keeps everything else sealed, the metrics that structurally cannot contain a word Jim wrote, and the undo buttons that work without anyone's permission.
This is a mechanism deep-dive under The Homebase, which tours the Mac that joins your account as a full peer. Read that first, and ideally Mechanism: Inference over the Relay — the same-account version of remote inference, which this mechanism deliberately mirrors and deliberately differs from. We will lean on the contrast throughout.
1. One channel, borrowed: pixie_homebase_op
The first design decision was to build no new transport at all.
Pixie already has exactly the channel this feature needs: the sealed 1:1 message pipeline between two accounts — the same one Tim and Jim's ordinary chat messages use. Every frame on it is sealed-sender end-to-end encrypted (the relay in the middle shuttles ciphertext it can neither read nor link to an account), retried from a durable outbox until acknowledged, and deduplicated by message id on arrival. Those are precisely the properties a control protocol wants, and they come for free.
So a Homebase operation is not a new wire format. It is an ordinary message whose body is
a JSON sentinel: a blob wrapped in the key pixie_homebase_op. The app's inbox
dispatcher (InboxService) checks for that sentinel before any chat code runs, so an
op is intercepted and routed to HomebaseService without ever rendering as a bubble or
filing a message record. The transport message's id is the op's id (opID, 16 random
bytes), which is how the outbox's retry and dedup machinery apply to ops unchanged. If
the pattern sounds familiar, it should: it is the same sentinel trick that carries shared
skills (see Mechanism: Skill Bundles & Sharing) — one
audited pipeline, several passengers.
One alternative was seriously considered and rejected, and the rejection is instructive.
Pixie's sibling channel — the sync_delta frames that keep your own devices convergent
— also rides the sealed relay, and remote inference between your own phone and your own
Mac already lives there. Why not reuse it? Because sync_delta is same-account by
construction: every frame is self-gated on the sender's userIDHash, i.e. "ignore this
unless it came from me." Cross-account traffic — Tim's Homebase serving Jim — is exactly
what the 1:1 pipeline already authenticates: every inbound frame carries a MAC under the
pairwise channel key, so opening it at all proves the frame came from the account it claims
— the one identity field derived from an authenticated session rather than supplied by the
sender. That account-level identity is the keystone of the whole mechanism, because it means
authorization can be a set-membership test. No
bearer token to mint, no permission record on any server, nothing for an operator to see
or seize. The channel is the credential.
The op vocabulary is small — eleven kinds, in three groups:
| Group | Kinds | Direction |
|---|---|---|
| Grant lifecycle | invite, accept, revoke, advert |
host ↔ guest, fanned to the whole device set |
| Inference | infer_request, infer_accept, infer_chunk, infer_done, infer_cancel |
guest device ↔ host device, exact devices only |
| Conversation cache | cache_append, cache_cleared |
guest → host / host → guest |
The fan-out split in the third column is deliberate. Lifecycle ops target whichever device the human is holding — an invite fans to every one of Jim's devices, because Jim might read it on his iPad. Inference ops target the one machine that has the state: THE serving Mac, THE requesting phone; their siblings hold nothing for that request.
Two hygiene rules keep the wire safe to grow. kind is a plain string, so a frame from a
newer build with an unknown kind degrades to "dropped," never a decode crash. And every
inbound op passes a fail-closed isWellFormed check before it reaches any service code:
the op must carry exactly the payload its kind demands (a frame smuggling a second
payload is malformed, not "extra data to ignore"), with every field bounded — at most 64
advertised models, 500 turns and 512,000 characters of prompt, 1 MiB per cache chunk, a
sequence ceiling of 4,096. A hostile sender's best move against the parser is to get
silently ignored.
2. The grant: invite, accept, revoke
The authorization state lives in two small stores, one per side, and neither ever touches a server.
On the host, HomebaseGrantStore is a device-local list of contacts in one of two
states: invited or granted. It is persisted only on the serving Mac and deliberately
never synced to Tim's other devices — a grant is a property of the machine that
serves, and Tim's phone, which cannot serve, has no business holding one. The lifecycle
is strictly accept-gated:
- Tim taps Invite in Settings. The store records the contact as
invited, and aninviteop — carrying the Homebase's name, device model, and current model list — fans out to the contact's devices. - Jim's app records the invite in its own store (
JoinedHomebaseStore) as pending. Nothing else happens: the consent copy in Settings owns the decision, and theacceptop is only ever sent from there, after Jim has read what joining means (more on that copy in §3). - The
acceptarrives, andgrantOnAcceptpromotes the record togranted— but only if it was invited. An unsolicitedacceptfrom anyone else changes nothing. The host replies with a welcomeadvertfanned to Jim's whole account, so every one of his devices has a fresh model list before the first request.
One asymmetry in the keys is worth pausing on. Jim's store remembers the host device pubkey — the specific Mac that serves. Tim's grant store, though, keys on the guest's canonical account pubkey: inbound senders are resolved to their account before the membership test, so a request from Jim's phone and a request from Jim's iPad hit the same grant. Grants are per-person; serving is per-machine.
After the handshake, advert ops keep the relationship current: whenever the host's
verified Ollama runtime changes its model set (a pull, a removal, a re-verification), the
new list fans to every grantee, and the guest's model picker updates. There is no
heartbeat — adverts are event-driven — so the guest applies a generous 24-hour
freshness window: a Homebase counts as available if it is joined, has at least one
model, and has been heard from within a day, with any inbound frame from the host
refreshing the clock. The window exists only so a Homebase that is clearly gone does not
make every send eat a ten-second wait before falling back; the on-device fallback (§3)
absorbs everything else.
And revocation? revoke(contactPubkey:) removes the grant record entirely — not a
flag flip, a deletion — so a post-revoke request fails the membership test exactly like a
request from a stranger who was never invited. We will come back to everything else
revoke does in §6; the ordering there matters.
3. No shared replica — and what that forces the consent copy to say
Here is where the cross-account wire earns its own article instead of being a footnote to Mechanism: Inference over the Relay.
The same-account wire (InferWire) has a luxury: both endpoints are your devices,
holding convergent replicas of the same conversation. So its requests do not ship the
conversation — they ship a conversation digest, a compact fingerprint of the
conversation as the phone sees it, and the Mac regenerates the prompt from its own synced
copy, refusing with divergent if the fingerprints disagree.
Between Tim and Jim, none of that exists. Jim has no replica of Tim's state; Tim has no
replica of Jim's conversation with his own agent — and must never acquire one by osmosis.
So the cross-account infer_request has no digest and no divergent status. It
carries the prompt inline: the system prompt, the full turn list (reusing
InferWire.Turn, so the two wires cannot drift on the prompt shape), and the sampling
parameters, all inside the sealed frame.
This is a bandwidth cost — §4 exists to claw it back — but it is first an honesty
constraint, and the code says so in as many words: the host sees the prompt in
plaintext at inference time, by necessity, because the model runs on its machine. There
is no cryptographic trick that lets a computer run a language model on text it cannot
read. Pixie's response to that fact is not to hide it but to write it into the consent
alert Jim reads before he accepts: prompts he sends will be processed on Tim's
computer. The provider type carries the same honesty in code — its isOnDevice flag is
false, with a comment that the route runs on another person's machine, never a cloud,
and that the consent copy at join time is what makes the route legitimate at all.
With the prompt in hand, serving is three gates and a stream, orchestrated by
HomebaseService on the Mac:
- Gate 1 — the grant check. Resolve the sender's verified pubkey to its account;
require membership in
HomebaseGrantStore. Refusal (infer_done(refused)) is terminal and deliberately cheap: no runtime probe, no provider spun up, no usage row written. A stranger — or a revoked guest — costs the host one sealed frame and nothing else. - Gate 2 — the runtime check. The same live-endpoint probe the same-account host
uses: the verified Ollama runtime must be up and the requested model still in its list,
or the answer is
infer_done(model_unavailable)rather than an accept that stalls. - Gate 3 — the cache (delta requests only; §4).
Then infer_accept, and the shared streaming loop: batched, sequence-numbered
infer_chunk frames that are advisory — they exist so Jim's UI feels live — followed
by infer_done(ok, full_text), whose full text is the only authoritative reply. On
Jim's side, CrossAccountInferenceBroker rendezvouses the stream with the waiting call
under the same watchdog ladder as the sibling wire — 10 seconds to accept, 90 to first
output, 30 per stall — and CrossAccountInferenceProvider wraps the whole round with the
contract every external arm of LLMRouter obeys (see
Mechanism: External Routing & Fallback): every
failure — refused, timeout, model unavailable, host error — lands in the injected
on-device fallback. Tim's Mac going quiet shows Jim a slightly smaller brain, never an
error dialog.
One boundary is checked before any of this, and it is absolute: tool-using calls never cross an account boundary. If the request carries tools, the provider does not even attempt the remote round — it goes straight to the on-device fallback. The agent loop (tool execution, memory, files — the harness of The L1–L2–L3 Harness) stays on Jim's device, partly because that is where the tools are, and partly because round-tripping tool calls through someone else's machine would widen what the host sees far beyond a prompt. Tim serves generation, never agency.
4. The conversation cache: bytes the host can hold but not read
Inlining the full prompt works, but conversations grow. By turn thirty, every request is re-shipping a history the host was just shown — pure bandwidth waste on a channel where frames are retried and sealed. The fix is a cache on the host. The problem is that a cache on the host is, naively, a transcript of Jim's conversation sitting on Tim's disk.
The crypto contract that resolves this is exact, and worth stating the way the code states it:
Cache blobs are AES-GCM ciphertext under a per-conversation key generated and held by the guest — in the guest's device Keychain, never in any host-persisted structure. Each
infer_requestcarries the key inside the already-sealed frame; the host decrypts in memory, for that one inference, feeds the model, and drops the key — persisting only ciphertext.
(AES-GCM is authenticated encryption: a 256-bit key, and a built-in integrity tag, so a tampered or wrongly-keyed blob doesn't decrypt to garbage — it fails, cleanly, to nothing.)
At rest, offline, or in a backup, Tim's Mac holds bytes it cannot read. At inference
time, it briefly holds plaintext — but that is the same plaintext the consent copy
already covers ("prompts are processed on the host's computer"); the cache adds no new
exposure, only persistence of the sealed form. And the code makes "the host never keeps
the key" structural rather than disciplinary: the host's store,
HomebaseHostCacheStore, has no API that takes a key — the key exists only as a
call-scoped parameter inside the one serving function, so nothing the host persists
(cache, grants, usage) has a slot that could contain it. A test locks the shape.
Mechanically, a conversation's cache is a run of chunk files:
Application Support/homebase-cache/<guest pubkey>/<conversation id>/0.bin
1.bin
2.bin …
Each .bin is one AES-GCM blob of a JSON array of turns, shipped by the guest in a
cache_append op after a successful inference. Seqs are contiguous and 0-based; seq 0
re-seeds — the host wipes whatever the conversation held first. A duplicate seq (an
outbox redelivery) is ignored; a gap is dropped, which the next request will surface and
repair. The guest packs turns into chunks with exact size accounting so every blob
respects the 1 MiB wire cap.
The guest's side of the bookkeeping lives in HomebaseGuestCacheStore: per conversation,
the covered turn count, a prefix hash of those covered turns (a length-delimited
CBOR encoding of each turn's role and body, hashed — length-delimited so two fields can
never smear into one another), the next seq to send, and the key — the key in the
Keychain, everything else in ordinary defaults, and the persisted entry deliberately has
no key field, so a settings dump can't leak one.
Now the delta flow. Before each request, the guest plans: among conversations known for
this host, find the longest one whose covered prefix hash-matches the head of the
messages about to be sent. A match means the host already holds that prefix — so the
request ships only {conversation_id, base_seq, key} plus the new turns:
Jim's phone Tim's Mac
│ ── infer_request {conv, base_seq=3, key, │ 3 chunks on disk? ✓
│ new turns} (sealed) ───────────────────▶│ decrypt in memory with key
│ │ prompt = cached ‖ new turns
│ ◀── infer_accept · chunks · done ─────────────│ …key dropped, reply sent
│ ── cache_append seq 3 (ciphertext) ──────────▶│ persisted: ciphertext only
The host checks that its disk holds exactly base_seq chunks, opens them all-or-
nothing (a half-decrypted prompt must never reach the model), prepends the recovered
turns, and serves. After the reply, the guest seals this round's uncovered turns and
ships the next cache_append — after the done, best-effort, because the repair path
makes losing one harmless.
That repair path is the status cache_miss. If the host cannot serve base_seq —
cleared, partial, unknown conversation, undecryptable — it answers
infer_done(cache_miss) and the guest retries exactly once, full-inline, resetting
its bookkeeping to zero so the follow-up appends re-seed from seq 0 (which wipes whatever
partial state the host held). A second miss on a re-seed can only mean a misbehaving
host, and it propagates like any other failure: silently, to the on-device model. Every
cache pathology — a lost append, a host-side wipe, a Keychain that refuses to store a key
(the plan degrades to legacy full-inline) — converges back to a correct request; the
cache can only ever cost bandwidth, never correctness.
What does Tim learn from hosting Jim's cache? One number: its size. Which brings us to the metrics.
5. Metrics that structurally cannot contain content
Tim's consent screen promised him usage visibility; Jim's promised him that visibility
means metrics, never content. HomebaseUsageStore is where that promise becomes a
type.
The store holds one aggregated record per user per UTC day:
struct Record: Codable {
let user: User // .you, or .guest(32-byte pubkey)
let day: Int // UTC days since epoch
var requests: Int
var tokensIn: Int
var tokensOut: Int
}
Notice what is absent: there is no String field. Not "we're careful not to log
prompts" — there is no slot a prompt or reply could land in, no matter what a future
call site does. The token figures are coarse estimates computed from text lengths
(about four UTF-8 bytes per token) at the moment a request completes, after which the
text is simply dropped; the store's API takes counts, not strings. The only Data in a
record is the guest's pubkey, clamped to exactly 32 bytes at the write boundary, so it
cannot smuggle bytes either. A reflection-based test walks the record type and fails the
build if anyone ever adds a text-shaped field. This is the same design move as the
key-less cache store API in §4, and it is the recurring trick of this whole mechanism:
make the promise a property of the types, so keeping it requires no vigilance.
Alongside the day-bucketed counters sits one gauge per user: current resident
conversation-cache bytes. It is set from exact file sizes — the dashboard number equals a
du of the guest's cache directory — and a cleared cache removes the row entirely rather
than lingering at zero. Counted tokens are measured over the reconstructed prompt
(cached prefix plus new turns), because that is what actually ran — the delta wire
doesn't let a guest's usage under-report.
Two scope notes complete the picture. The store also records .you — Tim's own
same-account requests land in the same dashboard, so "usage" means the machine's whole
serving life, not surveillance pointed only at guests. And the ledger is device-local to
the Mac, persisted next to the grants, never synced and never sent anywhere: usage lives
where the models ran.
6. Undo, unilaterally
The last property is the social one: nobody needs the other's cooperation to withdraw. Every exit in this mechanism is a local action first and a notification second.
The host revokes. revoke drops the grant record before sending anything — from
that instant, requests refuse, even if the revoke op itself is still in the outbox
(delivery is best-effort; authorization is not). Then it deletes the guest's entire
cache directory, zeroes the gauge, and fans the revoke op to the guest's devices. On
arrival, the guest drops its joined record, its cache bookkeeping and the Keychain keys
under it, and — one easily-missed step — clears the router selection if this Homebase
was the active external route. A dangling selection would silently mean "on-device
forever" while claiming to be external; instead the selection reverts explicitly, and the
picker tells the truth. A revoked guest leaves no bytes behind on the host, and the host
leaves no live route behind on the guest.
The guest leaves. Purely local: drop the record, drop the cache bookkeeping and keys,
clear the selection. No op is owed — from the host's perspective the requests simply
stop, and the pending grant is housekeeping. (Declining an invite is the same action
earlier: the host's invited row just never activates.)
The host clears a cache — without revoking. This is the gentlest undo and the reason
cache_cleared exists as its own op. Tim can decide he no longer wants to store Jim's
ciphertext — reclaiming disk, or just tidying — without touching Jim's access. The host
deletes the directory, zeroes the gauge, and fans cache_cleared so the guest drops its
seq bookkeeping and the next request re-seeds from scratch instead of stumbling into a
cache_miss round. And because the guest's own device still holds the conversation, the
clear is invisible in use: the next request inlines everything, the appends rebuild the
cache, and the chat continues mid-sentence. The host was never the system of record —
which is exactly why clearing it can be nobody's emergency.
There is also a quieter bound on the host's exposure while everything is working: a
guest's cache is capped at 8 MiB per conversation and 128 conversations, appends from
anyone outside the grant set are dropped without reply, and a redelivered
infer_request while the original is still generating is deduplicated rather than run
twice. Hosting a friend is bounded generosity, priced in advance.
7. What carries the weight
Strip the mechanism to its load-bearing choices and each one is a structural answer to a trust question:
| Question | Answer | Enforced by |
|---|---|---|
| Who may use the models? | The grant set | Verified sender identity on the sealed 1:1 channel; membership test on the host |
| What does the host see? | Prompts at inference time — said out loud | No shared replica → inline prompts; the consent copy written against the code |
| What does the host keep? | Ciphertext and counters | A cache store with no key parameter; a usage record with no string fields |
| What can the guest lose? | Nothing | The guest's device remains the system of record; cache loss self-repairs |
| Who can end it? | Either side, alone | Local-first revoke/leave/clear; fallback absorbs the rest |
The theme from The Homebase — every power traces to a switch a human flipped — gets its sharpest expression here, because this is the one Homebase job whose user is not the machine's owner. The switches exist on both sides, the promises between them are types rather than policies, and the worst case of every failure is the guest's own on-device model quietly taking the call.
One door into the Homebase remains: Mechanism: MCP — Letting Other Agents In — what happens when the outside party is not a friend's phone but another agent entirely.