.cljc source tree on clojure.core alone, JVM-first, Go-hosted secondcljc. Nothing in
SPEC.md or the six existing ports has been changed by this document.clojure.test) and the Go-hosted dialects —
cljgo,
Glojure and
let-go. All four are verified below.clojure CLI
1.12.5, cljgo 0.1.0-dev, and Glojure and let-go built from source — not reasoned from
documentation. Reader-feature claims are cited to each implementation's source, not its
README. Measurements are reproduced in clojure/spikes/ and in the
koine conformance runner. Where a claim is
unverified it says so.The prime directive (CLAUDE.md) is byte-parity across ports against the shared
examples/ fixtures. A Clojure port that behaves differently on JVM and cljgo would
violate that directive within a single port — the worst possible outcome, and the exact
failure mode cljgo itself calls "unforgivable" (REPL-vs-binary divergence, cljgo ADR 0007).
So the port's design question is not "which Clojure do we target" but "what is the smallest surface on which the two hosts can be proven identical?"
.cljc tree runs on both hosts, unmodifiedA two-namespace spike (toolnexus.host + toolnexus.core) loaded via require on both
hosts from identical source produced identical output apart from the deliberate
platform probe:
JVM → platform: jvm sanitized: add_two execute: 5 sh: {:out "HELLO DUAL HOST", :exit 0}
cljgo → platform: cljgo sanitized: add_two execute: 5 sh: {:out "HELLO DUAL HOST", :exit 0}
This works because of two ratified cljgo decisions: reader features are exactly
#{:cljgo :default} and cljgo never answers :clj (cljgo ADR 0036 §A), and .cljc
is a resolvable extension for require (cljgo ADR 0068 §1). The JVM elides :cljgo
branches; cljgo elides :clj branches. Neither host ever reads the other's interop.
Eleven candidate libraries were resolved and their .clj/.cljc sources scanned for Java
interop (clojure/spikes/s03-dependency-purity/). Every one carries Java interop —
including libraries that sound pure:
| library | Java surface |
|---|---|
dev.weavejester/medley (utility fns) | java.util.ArrayList, java.util.UUID |
org.clojure/tools.cli | a static call |
org.clojure/data.json | java.io.Reader/Writer, Boolean, Byte, CharSequence, Double, 5 statics |
borkdude/edamame | java.io.Reader, Character |
org.babashka/http-client | 5 imports, java.io.* streams |
rewrite-clj | java.io.Writer, Character, String |
org.clojure/data.csv, core.match, cuerdas, lambdaisland/uri, tools.reader | all Java |
cljgo ADR 0054 predicted a "thin" pure subset. The measurement says it is zero. And cljgo ADR 0054 constraint 2 is categorical: "cljgo does not do Java at all. Java interop is not a supported host operation, in either direction."
Therefore: any third-party Clojure dependency makes the port JVM-only. Not slower to port — impossible to port, until cljgo grows a Java shim (see Consequences).
clojure.core itself is already at parity22 core vars were probed on both hosts. All 22 resolve on both, including the ones that decide whether the port is even expressible:
future future? deref promise deliver atom swap! slurp spit pmap
send agent read-string pr-str re-seq subs format with-out-str bytes
byte-array char string?
future/promise cover §8's parallel tool calls. slurp/spit cover skills and the
agent-home files. format/re-seq/subs cover parsing. This is the whole port.
Four capabilities genuinely fall outside clojure.core:
| # | need | JVM | cljgo | status |
|---|---|---|---|---|
| 1 | HTTP request | java.net.http | cljg.net.http | both present, verified |
| 2 | subprocess with streaming pipes | ProcessBuilder ✅ | missing | cljgo gap |
| 3 | directory listing | file-seq on java.io.File | file-seq on a string | divergent |
| 4 | environment variables | System/getenv | cljg.os | needs a branch |
Item 3 is the trap and the reason this ADR exists rather than a wiki page: file-seq
resolves on both hosts, so it reads as portable — but the argument types differ.
(file-seq "sk") returns ("sk" "sk/a" "sk/a/SKILL.md") on cljgo and throws
ClassCastException: String cannot be cast to java.io.File on the JVM. A "portable"
name is not a portable function.
Item 2 is a genuine cljgo gap: cljg.io/exec (core/cljg/io.cljg:155) is
run-to-completion — it takes :in as a string and returns {:out :err :exit}. MCP's
stdio transport needs a long-lived child exchanging line-delimited JSON-RPC. Go
interop is not an escape hatch either: (require-go '[os/exec :as ex]) binds nothing in
interpreted mode (verified — no such namespace: ex).
.cljc source tree, four dialects: :clj, :cljgo, :glj, :lgNo .clj/.cljg forks, no per-host copies. .cljc is chosen as the portable
extension.
ClojureScript, :cljr, :bb and jank are explicitly out of scope. ClojureScript
cannot spawn a subprocess, so stdio MCP servers — roughly half of what toolnexus does —
are impossible there. A port that silently drops half the spec is not a port. This is a
decision, not an omission; revisit only if :cljs gains a process model.
clojure.core + koine only — never on a third-party libraryGiven Measurement 2, any third-party Clojure dependency in the port itself makes the port
JVM-only. Given Measurement 3, clojure.core covers everything except the seam.
koine is exempt and is where host libraries live. It may use Java libraries on the
JVM branch and Go libraries on the cljgo branch freely — that is its whole purpose. The
port calls its portable API and never sees either.
koine's contract is byte-identical output, not "wraps a library" [Measurement 5]Wrapping two host libraries and calling it portable does not survive contact with the
prime directive. Six basic payloads were encoded through Go's encoding/json and JVM
clojure.data.json (clojure/spikes/s05-json/). Four of six diverge:
| payload | Go encoding/json | JVM data.json |
|---|---|---|
{b 1, a 2, c 3} | {"a":2,"b":1,"c":3} (sorted) | {"b":1,"a":2,"c":3} (insertion) |
"a<b>c&d" | "a<b>c&d" | "a<b>c&d" |
1.0 / 100.0 | 1 / 100 — fraction dropped | 1.0 / 100.0 |
"café ☃" | literal UTF-8 | "café ☃" |
| tab/newline escapes | agree | agree |
[1 2.0 "x" nil true] | [1,2,"x",null,true] | [1,2.0,"x",null,true] |
Two of these are semantic, not cosmetic:
{"temperature": 1.0} encoded as {"temperature": 1} changes the JSON
type. Tool schemas declaring "type":"number" and providers that coerce integers
behave differently.cache_control breakpoints deferred in ADR 0008). Sorted-vs-insertion
ordering silently destroys cache hits.Therefore koine MUST normalize every host library it wraps to one agreed output, and
a conformance test MUST assert the two hosts produce identical bytes.
Every one of the four divergences is an output-formatting choice. Parsing is unambiguous — both hosts turn a given document into the same Clojure data. So:
koine, ~80 lines, no host library.encoding/json by default). A core-only parser is smaller and more
portable, and it leaves koine with zero third-party dependencies.
Controlling key order, escaping and number formatting is the entire encoder;
normalizing someone else's encoder into agreement costs the same code while leaving us
debugging two libraries' escaping rules. The Elixir port made the identical call with
its in-house MCP client.The encoder MUST NOT use StringBuilder, Long/parseLong, Double/parseDouble or any
other Java interop — those are JVM-only and were the first thing this spike got wrong.
Build strings with apply str / clojure.string/join.
clojure.test is permitted for tests — it is clojure.*, present on both hosts (cljgo
reports it complete against the 1.12.5 oracle, 39 vars).
toolnexus.hostEvery other namespace is dialect-blind pure Clojure and MUST NOT contain #? or #?@.
The seam is the four items of Measurement 4 and nothing else — it is small enough to read
in one sitting, which is the entire point. A test asserts the invariant by scanning the
source tree for #? outside host.cljc.
Corollary from Measurement 4 item 3: a function that resolves on both hosts is not
thereby portable. Anything whose argument or return types differ across hosts goes
behind the seam even when the name is shared. file-seq is the first such case.
The four seam functions are not specific to toolnexus — every dual-host Clojure library needs the same four. The intent (owner, 2026-07-27) is therefore to extract them into a standalone portability library, published to Clojars, with a JVM-hosted and a Go-hosted implementation, so all future libraries build on it instead of re-solving the seam.
Shape when extracted: one artifact, not three. A single .cljc library carrying both
hosts' branches — so reader conditionals exist in exactly one place in the whole ecosystem
and consumers write (:require [koine.process :as proc]) with no conditional and no
implementation-selection step. The alternative (a pure api artifact of protocols plus
-jvm and -go implementation artifacts) buys pluggable third hosts, which Decision 1
has already ruled out of scope, at the cost of a second dependency for every consumer.
Sequencing: prove, then extract — not the reverse. The seam is built as
toolnexus.host and subjected to the full spike battery before it is lifted out.
Publishing an API to Clojars freezes it: changing it afterwards costs a breaking release.
S7 is already expected to reshape the process API (cljgo has no streaming child at all),
which is precisely the kind of finding that must land before the interface is public.
Extraction is mechanical once the shape has stopped moving.
The port is built and proven excellent on JVM Clojure first: full clojure.test
suite, green against the shared examples/ fixtures, parity-checked against the existing
six ports. Only then is the same .cljc pointed at cljgo and the gaps recorded.
This ordering is deliberate: it means a cljgo gap cannot block the port from shipping, and it puts the port where the users, the tooling and Clojars already are. cljgo support is a milestone, not a release gate.
Published to Clojars as a normal Clojure library (tools.build jar carrying the .cljc
source), consumed by deps.edn/Leiningen the ordinary way.
cljgo consumes the same source tree via a git coordinate, because cljgo cannot consume from Clojars today: ADR 0054 built the publish side only and explicitly defers consume-side interop; cljgo resolves Clojure deps as git source roots (ADR 0052). One source, two coordinates, until Clojars consumption lands in cljgo.
We do not use cljgo publish clojars — its validator rejects any Go interop, and it
emits a git coordinate rather than a Clojars artifact anyway (ADR 0054, deferred items).
:clj, :cljgo, :glj, :lgcljgo is not the only Clojure hosted on Go. Glojure and let-go are now verified
alongside it — built from source, one .cljc file, all four hosts:
jvm 9/9 pass
cljgo 9/9 pass
let-go 9/9 pass
glojure 9/9 pass
Reader features were read out of each implementation's source, not its README:
| host | feature | source |
|---|---|---|
| Clojure | :clj | — |
| cljgo | :cljgo + :default, never :clj | cljgo ADR 0036 §A |
| Glojure | :glj + :default | pkg/reader/reader.go:1403 |
| let-go | :lg + :default; opt-in :clj/:bb via set-read-clj! / LG_READ_CLJ, off by default | pkg/compiler/reader.go:1122-1145 |
The three Go dialects do not share an implementation, because their interop models
are unrelated: cljgo uses require-go + cljg.*; Glojure exposes Go's stdlib directly
with / munged to : (os:exec.Command, net:http), shipping ~26 packages by default;
let-go has its own os/io/http/json namespaces plus Java-shaped shims — its
System/getenv works. Adding a dialect is therefore a branch, not a fork, and
koine is structured for it:
:default branch that either delegates to a
dialect-agnostic implementation or throws a named, actionable error
("koine: no <capability> implementation for this host; add a branch in koine/<ns>.cljc") — never a silent nil or an obscure resolution failure;#?(:clj … :cljgo … :default …), extended in place;Honesty: :clj (1.12.5), :cljgo (0.1.0-dev), :glj and :lg are measured for
JSON and env only. http, process and fs have per-host branches but are verified
on the JVM alone. gloat and Joker are untested.
Writing one file for four hosts surfaced five traps, each now encoded as a rule:
(= key-fn keyword) throws on Glojure — "comparing uncomparable type
lang.ArityFn". Apply a function; never compare one.^:dynamic is not honoured on Glojure — "cannot dynamically bind non-dynamic
var". Thread the parameter; it is also less code.os.Getenv returns "" where the JVM returns null, and "" is truthy
in Clojure — so (or (getenv x) default) silently never falls back on Go-hosted
dialects.file-seq takes different argument types (Measurement 4, item 3).pr-str of a map is a
false failure waiting to happen. This bit our own conformance script.data.json — a real ergonomic win for a library, which is what
toolnexus is.koine — its
own public repo, per Decision 3b. S5's encoder contract passes byte-identically on both
hosts there (7/7 on cljgo, 38 assertions on the JVM).require-go reaches the Go standard library") admits stdlib import paths to the
existing zero-bindings AOT route, so exec.Cmd.StdinPipe/StdoutPipe are reachable
directly. Spike S57 built a 6.8 MB AOT binary driving a long-lived cat child over
two stdin→stdout round trips — stdio MCP is expressible on cljgo. The fix was
~15 lines in one file (pkg/eval/host.go), commit 4423d12; it also had to add the
missing HostUnlinkedTolerant guards to OpHostMethod/OpHostField, without which
every method on a host-returned value dies in the AOT discovery pass.os.Getenv in an AOT binary (spike S56). Unblocks ${ENV_VAR} expansion in MCP
headers, hence remote MCP auth. Correction to this ADR's original evidence:
(require-go '[os]) does not fail at the form — it returns cleanly with exit 0
and interns nothing, and the error surfaces later at the call site. Re-measured
2026-07-30.
2b. Streaming HTTP responses — a blocker this ADR never listed. cljgo's only HTTP
shim did io.ReadAll then closed the body, so streaming LLM responses were
impossible. Also closed by ADR 0104 (spike S58 reads resp.Body line-by-line
through bufio with the body still open).
2c. Caveat carried forward. cljgo's AOT discovery pass evaluates Clojure with nil
substituted for every host result, so a nil-intolerant pure function applied to a
host value fails at build time, not run time. koine's cljgo branch must keep host
results on a nil-tolerant path; this is the most likely source of future
"works in run, fails in build" reports.-json-decode is a private builtin, unreachable from
user code. No longer affects toolnexus — koine ships its own parser — but it is
a real hole in cljgo's stdlib.java.io/java.lang/java.util shim (large, strategic, NOT required by this
port). Measurement 2 showed the ecosystem's interop is concentrated in ~20 boring
classes with direct Go equivalents. Shimming them would unlock a wide slice of
Clojars for cljgo. Recorded here because the measurement is the evidence for it; it
belongs to cljgo and to its own ADR, not to this port.SPEC.md and inherits the prime directive: a
behavior change lands in all ports or it is not done. This ADR does not change SPEC.md
and does not alter any existing port.| # | spike | verifies | status |
|---|---|---|---|
| S1 | dual-host .cljc require + conditional selection | Decision 1 | ✅ passed |
| S2 | seam confined to one namespace | Decision 3 | ✅ passed |
| S3 | dependency purity scan | Decision 2 | ✅ passed (0 of 11 pure) |
| S4 | clojure.core parity probe (22 vars) | Decision 2 | ✅ passed (22/22) |
| S5 | JSON encode/decode identical on all hosts | Decision 2a/2b | ✅ passed (9/9 × 4 hosts) |
| S6 | HTTP POST both hosts against a local server | seam 1 | ☐ |
| S7 | streaming stdio subprocess, both hosts | seam 2 | ✅ passed on both — see S15 |
| S14 | Glojure + let-go run the same .cljc | Decision 6 | ✅ passed |
| S8 | skills discovery: **/SKILL.md glob + frontmatter | seam 3 | ✅ passed — see S15 |
| S9 | clojure.test suite runs identically on both hosts | Decision 4 | ☐ |
| S10 | parallel tool calls via future/promise | §8 loop | ☐ |
| S11 | real MCP handshake against examples/mcp.json | §2 | ✅ passed — see S15 |
| S12 | cljgo build AOT-compiles a program requiring the port | cljgo release path | ✅ passed — see S15 |
| S13 | Clojars publish dry-run + cljgo git-coord consumption | Decision 5 | ✅ moot — koine 0.4.1 is on Clojars and cljgo resolves Clojars (its ADR 0095); one coordinate, both hosts |
| S15 | SPEC §0 vertical slice, one .cljc, byte-identical on both hosts | Decisions 1–3, seams 2–3, §0.2–0.7 | ✅ passed 2026-07-31 (clojure/spikes/s15-spec0-slice) |
S5, S7 and S11 were the ones that could still kill or reshape this design. All three have
now passed. S7 did fail on cljgo as expected, that failure became the specification for
cljgo work item 1, the fix landed upstream (cljgo ADR 0104 → shipped as ADR 0109), and koine
0.4.1 ships it from Clojars.
S15 subsumes S7, S8, S11 and S12 in a single measurement and is the strongest evidence
this ADR has. One 295-line .cljc — zero reader conditionals, zero java.*, zero Go
interop — implements §0.2 sanitize/naming, §0.3 mcp.json parsing with ${ENV} header
expansion, §0.4 a live MCP stdio session (initialize → notifications/initialized →
tools/list → tools/call, skipping interleaved notifications), §0.5/§0.6 skill discovery
and the byte-exact skill output, and §0.7 all three schema adapters — against the shared
examples/ fixtures the other six ports run. Clojure (JVM), a cljgo build AOT binary and
cljgo run interpreted all emit the same 2730 bytes.
This settles Decision 3 more strongly than it was written: the port's own source needs no reader conditional, not merely few. It also retires the concern that JVM-first meant cljgo would be discovered late — both hosts are green from the first line of real code.
S15 found one cljgo trap worth carrying into CI: cljgo run <file> does not call -main.
It evaluates top-level forms and exits 0 having printed nothing, which is indistinguishable
from a program that ran successfully and had nothing to say. Together with the cljgo test
behaviour koine reported (zero .cljc tests collected, Ran 0 tests, exit 0), the rule for
this port is: on cljgo, assert on output, never on the exit code.
Note that Decision 4 (JVM-first) means none of the cljgo work was ever a release gate; it is recorded here because the measurements were the specification cljgo built against.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |