I use opencode on my laptop every day. At some point curiosity took over: what actually happens between my prompt and the model’s reply — the sessions, the auth, the key handling? Reading docs only goes so far. So I gave opencode a second home I control, with an audience of one: tui.nurazhar.com, reachable from my phone, gated so only I get in.

This post is what that build taught me. The code lives in tui; the diagrams below are archify IR rendered at build time.

1. The shape: hybrid, not a rewrite

My first instinct was to reimplement. My second, better instinct was to ask what upstream already does well: opencode web serves sessions, a browser UI, PTYs, and tool execution. Rebuilding that is months of work for a worse copy. What upstream doesn’t have is my front door — a passkey-style gate, single-user scoping, and key handling I can audit.

So the architecture split itself — a hybrid gateway, not a rewrite:

Clojure owns authentication and credential injection. Upstream owns everything it is already good at. The gateway never stores prompts; it gates and forwards. That one sentence is the whole design philosophy: own the trust boundary, rent the machinery.

2. Two credentials, two jobs

The trickiest thing I learned: there are two completely separate secrets, and confusing them is the vulnerability.

The phone authenticates with a short-lived JWT (8h, HS256) — worthless anywhere except my gateway. Zen authenticates with a server-side key the phone never sees. The gateway resolves it per request: the opencode auth login OAuth token first (expiry-aware), an env API key as fallback, and a clean 503 when neither exists. Expired tokens don’t error — they degrade, which is a different and better outcome.

One disclosure I owe you: muse-spark-1.3-contributor-free is free in exchange for training-use of prompts and completions. Fine for a personal learning project; bring your own key and the gateway doesn’t care. The free tier shaped the architecture more than any technical constraint did.

3. Gates, not vibes

No JWT → 401 before anything else runs. No credential → 503 before any upstream call. Both directions fail closed, and both paths are tested — including the tampered-token and wrong-secret cases, because a gate you haven’t attacked in tests is a suggestion, not a gate.

The redaction rule is my favorite line in the codebase: every log line carries ****last4 at most, and a test asserts the raw key appears nowhere in log output. Secrets discipline enforced by compiler, not by memory.

4. A determinism budget

LLM output is nondeterministic; everything around it doesn’t have to be. Context is budgeted to 32k tokens with a deliberately crude estimator (characters ÷ 4) — crude, predictable, and testable beats clever and surprising. Tool output is clipped, ANSI-stripped (so model output can never repaint my terminal), and assembled deterministically.

And the meta-gate: clojure -M:test must print TOTAL fail=0 error=0 or the push doesn’t land — an attestation gate, not a suggestion. 31 tests today, up from 18. If an interviewer asks what I’d demo, it’s the gateway suites: JWT roundtrip, the OAuth fallback matrix, proxy injection.

5. What I’d do differently

The credential lifecycle diagram took six validation rounds — mostly me fighting geometry I couldn’t see, learning that long skip-edges in a state diagram are a design smell. Short, adjacent, explicit: true for diagrams, true for code, true for careers.

Five more diagrams (namespace map, CI gate, prompt sequence, JWT chain, secrets flow) live in tui/docs/archify — all validated, zero warnings.


tui is MIT, dogfooded daily from my phone, and the only repo on my shelf with a pulse. I started curious about how opencode works. I ended up understanding auth boundaries, SSE streaming, and why the boring parts — gates, budgets, redaction — are the whole game.