1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
|
.Dd November 3, 2026
.Dt HANS 1
.Os
.Sh NAME
.Nm hans
.Nd a REPL coding agent whose conversations are trees
.Sh SYNOPSIS
.Nm
.Op Fl model Ns = Ns Ar name
.Op Fl effort Ns = Ns Ar level
.Op Fl interactions
.Op Fl session Ns = Ns Ar name
.Op Fl db Ns = Ns Ar path
.Op Fl mcp
.Op Fl v
.Nm
.Fl serve Ns = Ns Ar address
.Op Fl db Ns = Ns Ar path
.Op Fl model Ns = Ns Ar name
.Sh DESCRIPTION
.Nm
is a coding agent that talks directly to Anthropic's Messages API and Google's
Gemini API, with no SDK between its agent loop and either wire format.
It runs as a terminal REPL, and with
.Fl serve
also as an HTTP service that other language models can read and drive.
.Pp
Its conversations are stored as trees rather than transcripts.
Each turn is a node whose parent is the turn it followed, so a conversation can
be continued from any point in it and not only from its end \(en and, since
every node records the model that answered it, with a different model than the
one before.
.Pp
Claude is reached with the Claude subscription that Claude Code is logged in
with, not with an API key, and every request
.Nm
sends to it is a copy of the request OpenCode would send in its place; see
.Sx CLAUDE .
.Ss Options
.Bl -tag -width "-interactions"
.It Fl model Ns = Ns Ar name
The model to use, which also chooses the provider: a
.Li claude-*
model is Anthropic's, a
.Li gemini-*
model Google's.
The default is
.Li claude-opus-5-5 .
.It Fl effort Ns = Ns Ar level
For Claude models,
.Li output_config.effort :
one of
.Li low , medium , high , xhigh , max .
Unset, the model's own default applies, which is also what OpenCode sends.
.It Fl interactions
For Gemini models, use the Interactions API, which keeps conversation state on
the server and is the Gemini loop whose sessions are stored.
Without it, a Gemini model runs in the unstored loop; see
.Sx Agent loops .
Claude models have only the stored loop and ignore the flag.
.It Fl session Ns = Ns Ar name
Resume a session by name or id, instead of starting a new one.
A name that matches nothing is an error rather than a silent new session: the
flag exists to continue a particular conversation, and a typo that quietly
started a fresh one would look like the conversation had been lost.
.It Fl db Ns = Ns Ar path
The session database.
Defaults to
.Pa $XDG_DATA_HOME/hans/hans.db .
.It Fl serve Ns = Ns Ar address
Serve stored sessions over HTTP at
.Ar address
instead of running a REPL.
Bind to loopback; see
.Sx CAVEATS .
.It Fl mcp
Run
.Xr gopls 1
as an MCP server and offer its
.Li go_*
tools \(en semantic search, symbol references, diagnostics.
Always on for Claude models and for
.Fl serve ,
because OpenCode runs the gopls server its
.Pa opencode.json
names and a Claude request must declare the same tools.
Withheld with a warning if gopls cannot load the workspace, since a gopls that
answers
.Dq No symbols found
to every query is worse than none: the model reads that as proof the code does
not exist.
.It Fl v
Print per-event timing diagnostics while streaming.
.El
.Sh THE TREE
A session is a tree of turns.
A turn's parent is the turn it followed; a turn with no children is a
.Em leaf ,
and a prompt at a leaf extends the conversation.
A prompt at a node that already has children
.Em branches :
the existing turns are untouched and a new line of enquiry starts beside them.
.Pp
Three things follow, and they are the reason for the whole design.
.Bl -bullet
.It
An alternative can be explored without destroying what it is an alternative to.
The interesting question is usually
.Dq what if I had asked something else three turns ago ,
and a transcript can only answer it by losing the turns that came after.
.It
Two clients prompting the same node get siblings, not a conflict.
The REPL and the HTTP service therefore share one database with no locking:
the worst a second client can do is start a branch nobody reads.
.It
Nothing is ever mutated, so there is no lost update.
A failed or interrupted turn is kept, with its error recorded, because the
attempt happened \(en it may have spent tokens and run tools \(en and a client
whose connection dropped has to be able to find out that it failed.
A turn the model ended early \(en out of tokens, or refused \(en is kept the
same way, with the stop reason and, for a refusal, the policy category as its
error.
.El
.Pp
Every call to the model is its own node, so a prompt that triggers three tool
calls is a chain of four nodes rather than one node with steps hidden inside
it.
Each is separately addressable, which means a branch can be taken from the
point
.Em before
a tool ran.
.Pp
A node's steps are stored as the bytes that went over the wire and came back,
never re-encoded.
Both providers validate opaque thinking signatures on what is sent back to
them, and Anthropic caches by byte prefix, so a stored step that had been
tidied would cost the cache and the model's earlier reasoning.
.Ss Changing models
Each node records the model that answered it, and a path may be answered by
several.
A backend sends the nodes of its own provider verbatim and translates the
others into prompts, answers, tool calls and tool results, which every
provider has.
What the translation drops is reasoning: a thinking block is one model
family's encrypted state, and is meaningless to another.
The model then continues from what that reasoning produced, without the
reasoning itself.
.Pp
Within Anthropic's models nothing is translated: a block the current model
cannot read is dropped by the API, without an error.
.Ss Agent loops
Claude models have one loop, and it is stored: the Messages API keeps no
conversation on its side, so every call sends the whole path from the root,
which is exactly what the tree holds.
.Pp
Gemini models have two.
The
.Fl interactions
loop is stored: it sends only the new steps and the parent's
.Li previous_interaction_id ,
and the whole path only when Google has forgotten the parent or another
provider answered it.
The default Gemini loop speaks
.Li streamGenerateContent ,
whose history is a list of Gemini
.Li contents
rather than interaction steps \(en a different representation that the node
tree does not hold.
It is kept, unstored, as the path that still works when the Interactions API is
unavailable or a model does not support it.
.Sh CLAUDE
A Claude subscription's token is accepted from Claude Code and from clients
that look like it.
A request that differed from OpenCode's in a dozen small ways \(en hans' own
one-line instructions, one tool, Go's HTTP/2 \(en was refused under
Anthropic's usage policy where OpenCode's identical question went through, so
.Nm
does not try to look like Claude Code in its own way.
It copies a recording of what OpenCode 1.14.28 with the opencode-claude-auth
plugin sends, as closely as a different program can:
.Bl -bullet
.It
The headers, by name, spelling and order, over HTTP/1.1, including Claude
Code's user agent and betas and an
.Li x-session-affinity
id made as OpenCode makes its session ids.
.It
The instructions: OpenCode's Claude prompt, its environment block (model,
directories, git, date), its skills note, and every instruction file OpenCode
would find \(en the first of
.Pa AGENTS.md ,
.Pa CLAUDE.md
or
.Pa CONTEXT.md
from the working directory up to the repository root, and a global one from
.Pa ~/.config/opencode/AGENTS.md
or
.Pa ~/.claude/CLAUDE.md .
.It
The tools: OpenCode's ten, by their recorded definitions, then gopls' as
OpenCode's MCP client declares them.
.It
The plugin's edits: the system prompt reduced to a billing line and Claude
Code's identity sentence, the instructions moved into the first user message,
the prompt JSON-quoted, and the billing checksum computed over it.
.It
The body's key order, JSON as JavaScript writes it, and cache breakpoints
where OpenCode places them.
.El
.Pp
The recorded requests are kept as golden tests, which rebuild them byte for
byte; a request drifting from OpenCode's fails there rather than as a refusal.
.Pp
Of OpenCode's tools,
.Nm
runs
.Li read ,
with OpenCode's output format, and gopls'.
The others are declared, because the request must match, and answer that they
are not available.
.Sh MACHINE INTERFACE
.Fl serve
exposes the stored sessions over HTTP.
Requesting any route with
.Li Accept: text/llm ,
or appending
.Ar ?view=llm
to its URL, returns plain text framed for a machine reader; there are no HTML
views, so the header decides only the content type.
.Pp
The rendering documents itself: every response ends with the routes reachable
from it, so a client can navigate the whole interface starting from
.Pa / .
.Bl -tag -width "POST /session/{name}/prompt" -compact
.It Pa /
sessions, most recently active first;
.Ar ?limit= , ?offset=
.It Pa /session/{name}
one session as a tree, oldest turn first
.It Pa /session/{name}/head
redirects to the newest leaf's path
.It Pa /node/{id}
one turn: what was asked, answered and run, with tool output in full
.It Pa /node/{id}/path
root to that turn, as a linear conversation
.It Pa POST /session
create a session;
.Ar name
optional
.It Pa POST /session/{name}/prompt
run a turn at the session's newest leaf;
.Ar text
required
.It Pa POST /node/{id}/prompt
run a turn following that node, branching if it has children;
.Ar text
required
.El
.Pp
Turns run over HTTP are answered by the service's
.Fl model ,
whatever model the session was started with.
.Ss Naming the encoding
Both prompt routes take
.Li application/x-www-form-urlencoded
and not JSON, and every rendering says so where it names them.
This is not pedantry, but the way Go's form parsing fails: a JSON body \(en the
obvious guess for a machine interface \(en parses without error and yields no
fields at all.
A prompt sent as JSON would therefore run empty, and the client would be told
it had succeeded.
The rendering states the encoding in the same breath as the address, because a
client cannot avoid this by being careful.
.Ss Blocking
Both prompt routes block until the turn finishes: typically seconds, but a
minute or more when tools run in a loop.
The alternative \(en answering immediately with a URL to poll \(en was rejected
because it makes every client write a polling loop for what is one request.
.Pp
What makes blocking survivable is that the turn is not tied to the connection.
Nodes are committed as they are made, so a client that disconnects loses the
rendering and nothing else, and finds the turn afterwards under
.Pa /session/{name} .
The server sets no write timeout for the same reason: one would cut the
response mid-turn and hand back a truncated document.
.Ss The trust boundary
A stored turn holds text the model wrote, text a person wrote, and the output
of tools \(en which is to say the contents of files on the machine
.Nm
runs on.
All of it is then served to another language model, in a rendering that also
advertises URLs which run turns and spend money.
.Pp
So each response draws 64 random bits and frames itself with markers derived
from them:
.Bd -literal -offset indent
--- hans:PLTMe4uCBd0 tool output ---
\&...
--- hans:PLTMe4uCBd0 end tool output ---
.Ed
.Pp
Nothing written before the response existed can carry the token: it does not
exist until the response is generated and differs on every request.
Forging a marker means guessing 64 bits rather than reading the source and
typing three hyphens \(en which matters, because
.Nm
reads source files for a living and its own source contains lines of that
shape.
Content is quoted verbatim inside the markers; escaping remains as a second
layer, for the one case that is not guesswork, namely content that is itself an
older rendering and carries a token that was real when it was made.
.Sh ENVIRONMENT
.Bl -tag -width XDG_CONFIG_HOME
.It Ev GEMINI_API_KEY
The Gemini API key.
When unset, it is read from
.Xr pass 1
at
.Pa internet/ai.google.dev/gemini/api-keys/interactive ,
but only for a Gemini model or
.Fl serve :
.Xr pass 1
may ask for a passphrase, which a Claude session should not have to answer.
.It Ev HTTPS_PROXY
Honoured for Anthropic requests, which is how a reference request is recorded.
.It Ev SSL_CERT_FILE
The certificates trusted for Anthropic requests, for the same purpose.
.It Ev XDG_DATA_HOME
Where the default database lives.
.It Ev XDG_CONFIG_HOME
Where the global OpenCode instruction file is looked for.
.El
.Sh FILES
.Bl -tag -width "$XDG_DATA_HOME/hans/hans.db"
.It Pa ~/.claude/.credentials.json
Claude Code's login, whose
.Li claudeAiOauth
token
.Nm
uses for Claude.
Run
.Xr claude 1
once to create it.
An expiring token is refreshed and written back, keeping every other field.
.It Pa ~/.claude/.credentials.json.lock
Held with
.Xr flock 2
while a token is refreshed; see
.Sx CAVEATS .
.It Pa $XDG_DATA_HOME/hans/hans.db
Sessions and turns.
Shared by the REPL and the service on purpose, so a conversation begun at the
keyboard can be continued over HTTP and the other way round.
.It Pa hans.service
A systemd user unit for the service.
.El
.Sh EXAMPLES
Start a session with Claude:
.Bd -literal -offset indent
$ hans
.Ed
.Pp
With Gemini, stored:
.Bd -literal -offset indent
$ hans -model=gemini-3.8-flash -interactions -mcp
.Ed
.Pp
Resume a session later:
.Bd -literal -offset indent
$ hans -session=amber-heron
.Ed
.Pp
Read your sessions from the command line, which is what
.Li text/llm
is for:
.Bd -literal -offset indent
$ curl -s -H 'Accept: text/llm' localhost:8779/
$ curl -s -H 'Accept: text/llm' localhost:8779/session/amber-heron
$ curl -s 'localhost:8779/node/12?view=llm'
.Ed
.Pp
Ask a question and wait for the answer:
.Bd -literal -offset indent
$ curl -sL -H 'Accept: text/llm' \e
--data-urlencode 'text=what does store.go do?' \e
localhost:8779/session/amber-heron/prompt
.Ed
.Pp
Branch from an earlier turn, leaving what followed it in place:
.Bd -literal -offset indent
$ curl -sL -H 'Accept: text/llm' \e
--data-urlencode 'text=what if we used JSON files instead?' \e
localhost:8779/node/7/prompt
.Ed
.Pp
Note
.Fl L :
a prompt answers with a redirect to the turn it made.
.Sh SEE ALSO
.Xr claude 1 ,
.Xr gopls 1 ,
.Xr opencode 1 ,
.Xr pass 1 ,
.Xr systemd.service 5
.Pp
.Pa DESIGN.md ,
next to the source, for the reasoning behind the design and how to record a
reference request.
.Sh AUTHORS
.An Profpatsch
.Sh CAVEATS
.Ss Claude by impersonation
.Nm
reaches Claude by presenting itself as Claude Code, with a subscription that is
meant for Anthropic's own clients.
That is outside what Anthropic permits third-party clients to do, and it can
stop working at any time: every check Anthropic adds is a request that is
suddenly refused.
When that happens, record what a working OpenCode sends now, compare, and
update; the golden tests show where.
.Ss Refresh tokens rotate
Claude's refresh token is replaced on every use.
.Nm ,
Claude Code and the opencode-claude-auth plugin share one credential file, so
two of them refreshing at once leaves one holding a dead token and the user
logging in again.
.Nm
processes take turns under the lock file and re-read the file before
refreshing; Claude Code does not take the lock, and a race with it remains
possible.
.Ss OpenCode's tools are declared, not all run
Claude is told about OpenCode's
.Li bash ,
.Li edit ,
.Li write
and the rest, because OpenCode's request does; a call to one that
.Nm
does not run is answered with an error, and the model works with what is left.
.Ss No thinking on older Claude models
OpenCode sends no thinking configuration, so neither does
.Nm .
Claude 5 models think regardless, with the reasoning hidden in its signature;
Claude 4.5 and earlier do not think at all.
.Ss No authentication
.Nm Fl serve
has no authentication, no authorisation and no CSRF protection whatsoever.
Whoever can reach the listen address can read every stored conversation \(en
including whatever file contents its tools have pulled into them \(en and can
run turns, which execute tools as the user running
.Nm
and spend the user's quota and money.
.Pp
Bind it to loopback.
This is only defensible at all because anything that can reach a loopback port
on this machine can generally already run programs as this user; it is not a
security boundary and must not be treated as one.
.Ss Tools are not sandboxed
A turn runs
.Nm Ns 's
tools with no restriction on what they may touch beyond the working directory
being where relative paths resolve.
There is no allowlist and no chroot.
A session records the directory its turns ran in, and the service refuses to
continue a session belonging to a different one \(en but that is a check against
.Em confusion ,
not against a hostile prompt, and it is worth being clear about which.
.Ss A branch is not free
With Gemini, branching costs a full replay of the path from the root when the
server has evicted the interaction being branched from, and always when the
parent was answered by another provider.
With Claude every call sends the whole path, but a branch shares the cached
prefix of the request its parent made, for as long as that cache entry lives.
After a change of model there is no cached prefix, and the first call pays for
all of it.
.Ss One directory per service
A serving
.Nm
can only run turns for sessions belonging to its own working directory.
Serving a second project means a second unit, with its own port and its own
database.
|