All articles

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:

  1. Tim taps Invite in Settings. The store records the contact as invited, and an invite op — carrying the Homebase's name, device model, and current model list — fans out to the contact's devices.
  2. 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 the accept op is only ever sent from there, after Jim has read what joining means (more on that copy in §3).
  3. The 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 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_request carries 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.

Next: Mechanism: MCP — Letting Other Agents In.