tokenbud Architecture board
Read only

drag to pan  ·  scroll to move  ·  ctrl+scroll to zoom

tokenbud · architecture · read-only

A guest's prompt has to reach a model on a laptop that accepts no inbound connections and must never give out its keys.

00 — Cover

tokenbud lends your AI without lending your keys. A host shares the model on their computer, or their own API account, behind a 9-digit ID and a password; no key changes hands and no account is shared. Anyone with both can chat with it from a browser tab, from the same desktop app, or from their own coding agent over MCP.

The constraint that shapes everything: a guest's prompt has to reach a model on someone's laptop, a machine that accepts no inbound connections and must never give out its keys. Both ends therefore dial out to a relay, and the relay keeps as little as it can.

Built with Rust and Tauri 2 for the desktop app, React 19 for the one UI shared by the app and the web client, and a Cloudflare Worker with one SQLite-backed Durable Object per host for the relay. Models come from Ollama or LM Studio, or from providers called with the host's own key.

01 — The constraint

The host is a laptop on home Wi-Fi, behind a router, often asleep. The guest may be a phone browser. Neither side can accept an incoming connection, and the host's API key is exactly the thing that must not travel.

The obvious designs fail on one of those. Port-forwarding a local model server means router setup, a changing IP address and a model server facing the open internet. Pasting the API key into a friend's app gives it away: taking access back means rotating the key everywhere it went, and there is no way to cap usage or pause sharing while you need the model yourself. Both make every guest install or configure something before their first prompt.

tokenbud has both ends dial out. The host opens a WebSocket to a relay and keeps it open; guests use ordinary HTTPS, so a browser tab is a complete client. Keys stay in the host's OS keychain and only answers leave the machine. An ID and password grant a seat that the host can take back at any moment.

The price is explicit: prompts and answers pass through the relay in transit, and finished ones are deleted after ten minutes. Anyone who minds can deploy their own relay and point the app at it in Settings.

02 — The map

There are three places and one wire between each pair. The host runs the tokenbud desktop app in share mode: a single tokio service that owns the relay connection, the list of connected people, the reserve, and the engine that answers. Its ID, password and keys live in the OS keychain.

At the edge, a Cloudflare Worker is the router and the gate. It applies per-IP rate limits and a 1.5 MB body cap, serves the web client at the root path, and routes the API to one Durable Object per host ID, found with getByName. That object is the host's room: a private SQLite database with four tables, the host's WebSocket (hibernating while idle), and any guest requests parked while they wait for an answer.

The guest can be anything that speaks HTTPS: the web client, which the relay serves itself; the same desktop app in connect mode, which keeps chats and memory in JSON files; or tokenbud mcp, which exposes a single ask tool to the guest's own coding agent.

The engine is a local model (Ollama on port 11434 or LM Studio on 1234) or a provider called with the host's own key: Anthropic, OpenAI, Google Gemini, Mistral, Groq, DeepSeek, xAI, OpenRouter or any OpenAI-compatible server. Claude Code exists only in development builds, for reasons chapter 08 explains. No connection is ever made to the laptop; it makes all of them.

03 — The core path

One prompt touches five participants: the guest, the Worker, the host's Durable Object, the host app and the model. Two of them, the Durable Object and the host's socket, are asleep most of the time.

First the guest takes a seat: POST /v1/hosts/:id/sessions with the password and a display name. The Worker rate-limits by IP and hands the request to the host's Durable Object, which checks the password against its PBKDF2 hash, checks that a seat is free, and returns 201 with a 256-bit session token prefixed tbs_.

Then the guest asks: POST /ask with the prompt, the chat so far, and their memory as context. Each seat may have one running job; a second gets 409 busy. The Durable Object writes the job row and sends it down the WebSocket the host opened. If that send fails, the job is marked as an error and the guest gets 503 instead of waiting for an answer that cannot come. Otherwise the guest gets 202 and a job ID.

The guest then long-polls GET /jobs/:id?wait=25. The relay parks the request in an in-memory set of waiters for that job, so waiting costs neither a polling loop nor database reads. On the host, the engine runs with a ten-minute cap; any <think> section a reasoning model emits is stripped, and the host sends a result message back on its socket.

The relay stores the text, wakes the parked request, and the guest's chat updates. The single most important edge is the job travelling down the host's own WebSocket: it is why no port is ever opened and why a laptop behind any router can serve.

04 — The hard case

A laptop is not a server. It sleeps, changes networks and closes its lid in the middle of an answer, and every timer in the system exists because one of those happens.

Between host and relay, the host pings every 30 seconds. The Durable Object registers a WebSocket auto-response, so the runtime answers pong without waking the hibernating object. If the connection drops, the host reconnects with a backoff that starts at one second and doubles up to 30.

Guests ping every 15 seconds; 45 seconds of silence ends the seat and fails its running job. last_seen is written at most every ten seconds, so most pings cost no storage write. Answers are fetched by long-polling with wait=25, which the relay caps at 50 seconds; clients allow 25 seconds plus 35 of slack, and give up on an answer only after five transient failures in a row, two seconds apart.

Whether to retry or stop is decided by the relay's error code, never by its message: session_ended, not_sharing or a bare 401 stop; an unreachable relay or a 5xx with no code is retried. Matching message text used to turn a Cloudflare 502 into a logout.

When the host's socket closes, every seat ends with the same message, running jobs fail, and any pause is cleared. A job the host never answers fails after an hour, finished jobs are deleted after ten minutes, and an alarm sweeps every 30 seconds while anything is pending, then stops. A guest who refreshes the page resumes the same seat with the token their tab kept; the password itself is never stored.

05 — Control

Sharing an agent means lending something that costs money and attention, so the host can end it at any moment, for one person or for everyone. Seats set how many people can be connected at once, from 1 to 50; the relay turns the next person away with 409 seats_full. A kick message on the socket ends one person's session, and their next request gets session_ended. A new password ends every session, which the app confirms first. Stopping sharing closes the socket and does the same for everyone.

The reserve is what makes lending tolerable. The hourly limit allows at most N prompts in a rolling hour, between 1 and 1000. At the limit, the host sends a pause of kind limit with the time it ends, and resumes by itself as old prompts age out of the window. Pause while busy checks every five seconds whether the host is using Claude Code themselves, recognised by executable path and command line, ignoring tokenbud's own child processes and the Claude desktop app, whose process has the same name on Windows.

Guests see a reason rather than an error: asking while paused gets 503 paused, and each ping carries the kind and end time so the interface can say when sharing is back. A pause whose end time has passed counts as over even if the host never lifted it, because the host may have gone to sleep first.

06 — Memory

The host's agent remembers nothing between prompts, and nothing about a guest is stored on the host. What the agent should know about a guest lives on the guest's device: up to 50 facts of at most 300 characters, and up to 100 chats titled from the first line of their first prompt. The browser keeps them in IndexedDB, the desktop app in JSON files.

Each prompt carries the facts as context, at most 10 KB, which the host turns into a system prompt: what you know about the person asking, the facts, and one instruction, to suggest a lasting fact inside a remember tag. The relay deletes the job, context included, ten minutes after it finishes.

Suggestions are never saved automatically. The tag is cut out of the answer and shown as a question; Remember adds the fact, and Not now is remembered as well (the last 200), so the same suggestion isn't offered again.

The rule that holds this together: the logic exists twice, in Rust for the desktop app and in TypeScript for the web client. A shared fixture, tests/fixtures/memory.json, holds the expected chat titles and context text, and both test suites read it, so changing one side without the other fails CI.

07 — The data

Each host ID is its own Durable Object with its own SQLite database, so there is no global table to scan, lock or leak, and one busy host cannot slow down another.

meta is a key-value table: the SHA-256 of the host secret, the password as pbkdf2$salt$hash, a flag that is set only once the connected host has sent its password, the seat count, the host's name, any pause, and when the host last connected, which drives the 30-day cleanup.

sessions holds one row per seat: an ID, the guest's name, the SHA-256 of their token, and timestamps. last_seen is written at most every ten seconds, and a seat silent for 45 seconds is deleted and its running job failed.

jobs is the only place a prompt is ever stored, together with its history, context and result. It is indexed by session and status, for the one-job-per-seat check, and by status and update time, for the sweep that deletes finished jobs after ten minutes. failed_logins records wrong passwords by IP address: ten in a minute from one address gets that address, and only that address, a 429.

Everything else stays on a device. The host keeps host.json (an ID per relay, the engine, seats and reserve) and the OS keychain (the password, host secrets and one API key per provider). Guests keep their chats and memory, and a browser tab keeps its session token. Probing a random ID writes nothing, because the schema is created only when POST /v1/hosts claims that ID.

08 — Trust

The relay can read prompts and answers in transit; that is the honest cost of a design in which neither end accepts connections. What it keeps at rest is a hash, a limit, or something deleted within ten minutes.

Passwords are hashed with PBKDF2-SHA256 at 100,000 iterations and a 16-byte salt per host, stored as pbkdf2$salt$hash. Hashes from the first version, plain SHA-256, still verify and are replaced the next time the host connects and sends its config. Host secrets and session tokens are 256-bit random values, stored as SHA-256 and compared in constant time with crypto.subtle.timingSafeEqual.

Guessing is limited per caller rather than per host: ten wrong passwords in a minute lock out that IP address only, so one person guessing cannot lock out everyone else. An unknown ID and a wrong password get the same 401, so IDs cannot be probed, and the Worker adds per-IP limits on registering hosts and taking seats.

The host's API keys never leave the OS keychain (Windows Credential Manager, macOS Keychain or the Linux keyring); providers are called from the host and only text comes back. Input from the relay is treated as untrusted: job IDs must be 1 to 64 letters, digits, dashes or underscores before they are used as a folder name, because a hostile custom relay could otherwise point a delete at any directory. The app and the web client both set a Content Security Policy, so a model answer cannot load a tracking image, and a browser tab keeps only its session token, never the password.

One line the code will not cross: Anthropic's consumer terms cover individual use, so the Claude Code engine is compiled only into debug builds. Sharing Claude works through an Anthropic API key instead.

09 — Where it runs

There are two deploys and no servers to keep alive. The relay is a single wrangler deploy: it builds the web client into desktop/dist/web and ships it as static assets at the root, with the Worker running first on /v1/* and /health. A _headers file adds the Content Security Policy, nosniff, no-referrer and frame denial, and two rate-limit bindings cap registrations at 10 and seat requests at 30 per minute per IP.

The desktop app is released by pushing a version tag. A first job checks that the tag equals the Cargo version and is plain MAJOR.MINOR.PATCH, since a Windows MSI cannot carry a pre-release suffix, and creates one draft release. A four-way matrix (Windows, macOS on Apple Silicon and Intel, Linux) then attaches seven installers: msi, exe, two dmg, deb, rpm and AppImage. They are unsigned, so SmartScreen and Gatekeeper warn on first launch.

CI runs on every pull request and push to main with the same commands CONTRIBUTING.md asks contributors to run: rustfmt, clippy with warnings as errors and the Rust tests (after building the frontend the app embeds); type checks, Vitest and both builds for the frontend; type checks and Miniflare-backed tests for the relay. Running your own relay is one deploy, then pasting its URL into the app's settings.

10 — What it buys

Four things follow from having both ends dial out. Guests need no setup: a link and a password are enough, because the web client is the relay's own page. Access can be taken back: the host can disconnect one seat, change the password or stop sharing, and no key was ever copied anywhere.

Costs fall to zero when nobody is using it: each host is one Durable Object that hibernates while idle and is deleted after 30 days without a connection. And any model the host has will do: a local model costs nothing, a provider key never moves, and guests never need to know which one answered.

What it does not buy yet is privacy from the relay operator. End-to-end encryption between guest and host is the deferred next step, after which the relay could no longer read prompts either.

Drawn from the tokenbud source (github.com/EagleMind/tokenbud, MIT). Every number is read from the code.