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 computing power, nothing else): BigBrain, the phone-to-Homebase remote-inference wire of the previous mechanism, pointed at someone else's Homebase. This article is the machinery under that sentence. It covers the wire the invitation travels on, the grant that authorizes it, and the reason Jim's prompts must arrive readable at Tim's machine. It covers 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.
pixie_homebase_opThe 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 (message) on it is sealed-sender end-to-end encrypted: the relay server in the middle shuttles ciphertext (scrambled bytes) that it can neither read nor link to an account. Every frame is retried from a durable outbox (a to-send list saved on disk, so retries survive restarts) until the far end confirms receipt, and duplicates are dropped 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 marker the app watches for: a data 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 chat 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 duplicate-dropping 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 (a fingerprint of the sending account), meaning
"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 (a message authentication code, a short tag computed under the pairwise channel key that only a holder of that shared key could have produced), so opening it at all proves the frame came from the account it claims. That account is 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: a plain check of "is this account on my list?". No bearer token to mint (a pass that admits whoever holds it), 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 (when in doubt, reject) 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.
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 (no access exists before an explicit accept):
invited, and an
invite op (carrying the Homebase's name, device model, and current model list)
fans out to the contact's devices.JoinedHomebaseStore) as pending.
Nothing else happens: the consent copy in Settings owns the decision, and the accept
op is only ever sent from there, after Jim has read what joining means (more on that
copy in §3).accept arrives, and grantOnAccept promotes the record to granted, but only
if it was invited. An unsolicited accept from anyone else changes nothing. The host
replies with a welcome advert fanned 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 public key (the shareable half of a cryptographic identity) of the specific Mac that serves. Tim's grant store, though, keys on the guest's canonical account pubkey: the one key that names Jim's whole account rather than any single device. 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 (Ollama is the local model server the Homebase runs) changes its model set (a new model downloaded, a removal, a re-verification), the new list fans
out to every grantee, and the guest's model picker updates.
There is no heartbeat (no periodic "still here" ping); adverts are sent only when something changes. 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.
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 (matching local copies) 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 standing instructions the model always sees), the full turn list (reusing
InferWire.Turn, so the two wires cannot drift on the prompt shape), and the sampling
parameters (settings like temperature that shape how the model generates), 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 (readable, unencrypted text) 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:
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.infer_done(model_unavailable) rather than an accept that stalls.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 matches the inbound stream to the waiting call under
the same watchdog ladder as the sibling wire: 10 seconds to
accept, 90 to first output, 30 per stall. 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 on-device fallback model the provider was handed at creation. 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.
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 (the operating system's protected vault for secrets), 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: enforced by the code's shape, not by developer carefulness. 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. Sequence numbers start at 0 and run gaplessly upward; seq 0
re-seeds: on receiving it, the host first wipes whatever the conversation held. 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 it
holds the covered turn count, a prefix hash (one fingerprint of those covered turns, in order), the
next seq to send, and the key. The prefix hash is a length-delimited
CBOR encoding of each turn's role and body, hashed; CBOR is a compact binary relative of
JSON, and the length delimiters keep two fields from ever smearing into one another.
The key lives in the Keychain, everything else in ordinary defaults (the app's plain key-value settings store), and the persisted entry deliberately has no key field, so a settings dump can't leak one.
Now the delta flow: sending only what changed. 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.
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. It sends that 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 with the full prompt inlined, 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 converges back to a correct request: a lost append, a host-side wipe, a Keychain that refuses to store a key (the plan then degrades to the legacy full-inline 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.
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 (a token is the word-sized unit models measure text in) 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 (one that inspects the record type's fields programmatically) walks the record 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, not a running total but
a level that rises and falls: current resident conversation-cache bytes. It is set from
exact file sizes, so the dashboard number equals a
du (disk-usage tally) 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.
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. One easily-missed step: it also 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 out cache_cleared so the guest drops its
sequence 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.
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.