Learning How opencode Works by Hosting It for an Audience of One
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.