Jolt runs on Scheme, not the JVM, so there are no real Java classes behind interop forms. Instead the runtime ships shims for the slice of the JVM standard library that portable Clojure code reaches for, so libraries written against clojure.core and common java.* classes run unchanged. The Clojure interop syntax works against these shims:
(Math/sqrt 2) ; static call
Math/PI ; static field
(StringBuilder.) ; constructor
(.append sb "x") ; instance method
(instance? String "hi") ; class token
A class token (String, java.util.UUID, …) evaluates to a java.lang.Class object, and (class x) returns one for the scalar and collection types Clojure programs compare against. The modeled class hierarchy answers a minimal reflective surface: .getSuperclass (nil for Object and for interfaces, as on the JVM), .getInterfaces, .isAssignableFrom, .isInterface, .isInstance, plus the naming trio .getName/.getSimpleName/.getCanonicalName. isa?, supers, ancestors and bases derive from the same graph, and a class a library registers (jolt.host/register-class-supers!) reflects like a built-in. There is no method/field enumeration and no clojure.reflect: the hierarchy is modeled, the members are not.
What's shimmed
This is the surface today, not the whole JVM. Methods not listed generally aren't implemented; a few are accepted but no-ops.
Numbers and language
java.lang.Math:sqrtcbrtpowexploglog10floorceilroundabsmaxminsincostanasinacosatansignumrandom;PI,E. (clojure.mathmirrors these as functions.)Long/Integer:parseLong/parseInt/valueOf(optional radix),MAX_VALUE,MIN_VALUE.Double/Float:parseDouble,valueOf,toString,isNaN,isInfinite, the value/infinity/NaN fields.Boolean:parseBoolean,TRUE,FALSE.Character:isUpperCaseisLowerCaseisDigitisWhitespace(ASCII).- Boxed-number methods: every number answers
.intValue.longValue.doubleValue.byteValue.shortValue.toString.hashCode. java.lang.System:currentTimeMillisnanoTimeexitgetPropertysetPropertygetPropertiesgetenv. The built-in property keys:os.name("Mac OS X"/"Linux"/"Windows"),os.archin the JVM's spelling (aarch64/amd64),os.version(macOS product version / kernel release),user.name,user.dir,user.home,java.io.tmpdir,java.class.path,line.separator,file.separator,path.separator, andjolt.version.java.versionis nil deliberately: jolt has no JDK to report, and claiming one would flip JVM-only code paths in libraries that parse it. Branch onjolt.version(or a:joltreader conditional) instead.java.lang.Thread:sleep(real),yield/interrupted(no-ops),currentThread.
Strings, collections, I/O
Stringstatics:valueOf,format(theclojure.core/formatengine).StringBuilder:appendtoStringlengthcharAtsetLength.java.util.ArrayList/HashMap: the mutableadd/get/put/size/remove/keySet/… surface.java.util.regex.Pattern:compile(withPattern/MULTILINE),quote,.split,.pattern.java.io.File: construction plusgetPathgetNamegetAbsolutePathexistsisDirectorylistFiles…StringReader/StringWriter/PushbackReader: the reader/with-out-strsurface.
Time, net, encoding
java.util.Date: the#instreader literal andjava.util.Date/java.sql.Date/Calendar, plusjava.text.SimpleDateFormat/NumberFormat. The basejava.timevalue types (Instant,LocalDate/LocalTime/LocalDateTime,Duration,Period,Year/YearMonth, theMonth/DayOfWeek/Chrono*enums) are in core too, autoloaded on first use with no dependency (RFC 0008). What formats or names a zone (DateTimeFormatter,ZoneOffset/ZoneId,ZonedDateTime/OffsetDateTime, localized formatting,java.util.Locale) lives in the time library.java.net.URL/URI: construction and component accessors.java.net.Socket/ServerSocket/InetSocketAddress/InetAddress/NetworkInterface: blocking IPv4 TCP over POSIX sockets. Touching any of these classes loadsjolt.socketon demand, so no require is needed ((require 'jolt.socket)still works and is what an AOT build should declare). The usual surface works like Java: streams via.getInputStream/.getOutputStream(a write to a peer-closed socket throwsIOException),ServerSocketbinds the wildcard address (or abindAddr), port 0 gets a kernel-assigned port that.getLocalPortreports, accepted sockets know their peer, andInetAddress/getByNameresolves. Host identity is there too:InetAddress/getLocalHost,getAllByName(an array, as on the JVM),.getCanonicalHostName,.getAddress, andNetworkInterface'sgetNetworkInterfaces/getByName/getByInetAddress/.getInetAddresses/.getHardwareAddress, read fromgetifaddrs(3). Deliberate gaps (available()answers 0, a recv error reads as EOF, connect timeouts are ignored) are tracked inknown-divergences.edn.java.util.UUID:randomUUID,fromString.java.util.Properties: a string-keyedHashtablewith adefaultschain:getProperty/setProperty/stringPropertyNames/propertyNames, plus theMapreads (get,count,seq,into).System/getPropertiesreturns one that shares the override store, so asetPropertythrough it is whatSystem/getPropertythen reports, and values Jolt computes on read (user.dir,java.class.path) stay current rather than being frozen at the call.java.util.Base64,java.nio.charset.Charset.- Exceptions:
ThrowableExceptionRuntimeExceptionIllegalArgumentExceptionIllegalStateExceptionIOExceptionNumberFormatExceptionArithmeticExceptionNullPointerExceptionand friends, each with the(E.)/(E. msg)/(E. msg cause)constructors.
Every exception class inherits the java.lang.Throwable methods, as it does on the JVM: .getMessage .getLocalizedMessage .getCause .toString .printStackTrace (both overloads) .getStackTrace .getSuppressed .fillInStackTrace. This holds equally for an ex-info, a constructed (Exception. "…"), and an error the host itself raised; the value a catch binds answers the same set whichever it is. .printStackTrace prints a real backtrace (see Stack traces). .getStackTrace is empty: Jolt reifies no StackTraceElement array, because tail-call optimization means there is no faithful per-frame array to hand back.
What's deliberately absent: member-level reflection (method/field enumeration and clojure.reflect; the hierarchy half is modeled, see above), gen-class/proxy of Java classes, and BigDecimal. (STM (ref/dosync/alter/commute/ref-set/ensure) is present, with commit-log transactions.)
Using a JVM library that needs a class Jolt doesn't ship
Most Clojure libraries run on Jolt unchanged. When one reaches for a java.* class outside the built-in set above, you don't have to wait for a Jolt release: you can register the shim yourself, at load time, from ordinary Jolt code. No rebuild, no host edits. Put the registration calls at the top level of a namespace your app requires before the library is used (a small myapp.shims namespace is a good home).
The built-in shims are written the same way, just in the runtime instead of your project, so anything Jolt does for AtomicReference, ByteBuffer, or URI, you can do for a class it's missing.
The workflow: let the error tell you what to add
Run the library and read the exception. Each shape of "missing host" error maps to one registration function:
| The error you hit | What to register |
|---|---|
Unknown class java.util.StringJoiner (a static/field ref, or the class is wholly unknown) | __register-class-statics! / __register-class-ctor! |
No dependency provides java.time.ZoneOffset (a JDK class core does not implement) | the library that provides it — time v0.0.8+, crypto v0.0.5+ — or your own shim, declared under :jolt/provides |
No constructor for class java.util.StringJoiner | __register-class-ctor! |
No method add on host … (a (.method obj …) call) | __register-class-methods! |
(instance? SomeClass x) returns false when it shouldn't | __register-instance-check! |
isa? / ancestors / instance?-through-a-parent is wrong | jolt.host/register-class-supers! |
No matching method x found taking N args for class java.io.File (Jolt ships the class, not that method) | jolt.host/extend-class! |
Add the one it names, re-run, repeat until the library is happy. **Method, static, and class names are strings that match the literal name in the interop form**: "add" shims (.add x …), "java.util.StringJoiner" shims (java.util.StringJoiner. …).
A worked example: java.util.StringJoiner
Say a library builds strings with java.util.StringJoiner, which Jolt doesn't ship. A stateful object is a tagged table: jolt.host/tagged-table creates one carrying a :jolt/type tag, and ref-put! / ref-get set and read its fields. Instance methods are keyed by that tag.
(ns myapp.shims
(:require [jolt.host :as host]
[clojure.string :as str]))
(defn- joined [self]
(str/join (host/ref-get self :delim) (host/ref-get self :parts)))
;; (StringJoiner. delim) -> a tagged value holding the delimiter and the parts
(__register-class-ctor! "java.util.StringJoiner"
(fn [delim] (-> (host/tagged-table :string-joiner)
(host/ref-put! :delim delim)
(host/ref-put! :parts []))))
;; instance methods: (.add sj s), (.toString sj), (.length sj)
(__register-class-methods! :string-joiner
{"add" (fn [self s] (host/ref-put! self :parts
(conj (host/ref-get self :parts) (str s)))
self) ; .add returns the joiner
"toString" (fn [self] (joined self))
"length" (fn [self] (count (joined self)))})
;; (instance? StringJoiner x)
(__register-instance-check!
(fn [class-name v]
(when (= class-name "java.util.StringJoiner")
(and (host/table? v) (= :string-joiner (host/ref-get v :jolt/type))))))
(let [sj (-> (java.util.StringJoiner. ", ") (.add "a") (.add "b") (.add "c"))]
(.toString sj)) ;=> "a, b, c"
(.length (-> (java.util.StringJoiner. ", ") (.add "a") (.add "b"))) ;=> 4
(instance? java.util.StringJoiner (java.util.StringJoiner. ",")) ;=> true
One subtlety: (str a-tagged-value) and pr-str show its raw wrapper form, not your toString shim; compute a length or a display string from the fields (as joined does above) rather than (count (str self)).
Static-only classes
A utility class with no instances (java.lang.Math, java.util.Base64) needs only statics: fields and static methods, again keyed by string name:
(__register-class-statics! "java.util.Base64"
{"getEncoder" (fn [] my-encoder)}) ; Base64/getEncoder
;; then Name/FIELD reads a field, (Name/method …) calls a static method
Fitting a class into the hierarchy
instance? on an exact class works from the instance-check above. To make isa?, ancestors, and instance?-through-a-supertype hold, declare the class's supers (its superclass and interfaces) with the jolt.host seam:
(host/register-class-supers! "java.util.StringJoiner" ["java.lang.CharSequence"
"java.lang.Object"])
;; now (isa? java.util.StringJoiner java.lang.CharSequence) => true, and a
;; protocol/multimethod that dispatches on CharSequence sees a StringJoiner.
Your own deftype/defrecord classes join the same graph automatically at definition; you only need register-class-supers! for a shim of an outside class. A record's ancestry carries the record interfaces (clojure.lang.IRecord, IPersistentMap, Associative, …), a bare deftype carries clojure.lang.IType, and every protocol the type implements inline appears as an implemented interface, so (ancestors MyRecord), (isa? MyRecord clojure.lang.IPersistentMap), and hierarchy relationships derived on a class's supers all answer like the JVM.
A deftype implementing a clojure.lang collection interface drives the core functions through its methods, like the JVM: Indexed → nth, Counted → count, ILookup → get/keyword lookup, Associative → assoc, ISeq/Seqable → seq/first/rest, IPersistentCollection → conj, Reversible → rseq, Sorted → subseq/rsubseq, IDeref → deref/@, IFn → the value is callable, IReduceInit → reduce. Methods can be arity-overloaded across interfaces (seq [this] and seq [this ascending]), and a marker protocol with no methods still answers satisfies?/instance?.
Extending a class Jolt does ship — adding a method its shim doesn't cover — is a different job with its own seam; see Extending a class Jolt already shims below.
Instance checks compose
An instance-check predicate returns true/false to decide, or nil to defer to the next registered check and the built-ins, so several libraries can register checks without clobbering each other. This is the mechanism Jolt's HTTP client library uses to emulate java.net.URL and HttpURLConnection so clj-http-lite runs unchanged.
Extending a class Jolt already shims
The registries above add a class Jolt doesn't have. The other half of the problem is a class Jolt does ship but only part of — java.io.File answers about thirty methods, not the whole JVM surface — and a library reaches for one of the rest. The error names a class you already have:
No matching method setWritable found taking 1 args for class java.io.File
jolt.host/extend-class! adds that one method and leaves the shim answering everything else exactly as before:
(ns myapp.shims
(:require [jolt.host :as host]
[babashka.fs :as fs]))
(host/extend-class! "java.io.File"
{:methods {"setWritable"
(fn [self writable?]
(fs/set-posix-file-permissions
(.getPath self)
(fs/str->posix (if writable? "rw-r--r--" "r--r--r--")))
true)}})
(.setWritable (java.io.File. "notes.txt") false) ;=> true
(.getName (java.io.File. "notes.txt")) ;=> "notes.txt" — untouched
One spec can describe a whole class, not only its methods:
(host/extend-class! "java.util.zip.Deflater"
{:methods {"deflate" (fn [self buf] …)} ; (.deflate d buf)
:statics {"BEST_COMPRESSION" 9} ; java.util.zip.Deflater/BEST_COMPRESSION
:ctor (fn [] …)}) ; (java.util.zip.Deflater.)
:statics and :ctor are the same registries as __register-class-statics! and __register-class-ctor!, so they keep those semantics: statics merge into whatever the class already has, and a :ctor replaces the constructor process-wide. They are here so one spec can describe a class Jolt doesn't ship at all; the gap-filling guarantee below is about :methods.
A method name with a leading dash answers the field spelling: "-length" shims (.-length x).
An addition can't change what Jolt already answers. By default a registration is consulted only where the call would otherwise fail, so extending a class is safe even when another library extends the same one — the two collide only if they claim the same missing method. That is the opposite of replacing the class with __register-class-ctor!, which every namespace in the process inherits whether it wanted your version or not.
To replace a method Jolt does implement, say so.
(host/extend-class! "java.io.File"
{:methods {"getCanonicalPath" (fn [self] …)}
:override true})
An override is process-wide: every (.getCanonicalPath f) in every namespace runs your method, including in code that never asked for it. Prefer the default tier, and reach for :override only when Jolt's answer is wrong for everyone rather than inconvenient for you. JOLT_DEBUG=1 reports each override as it registers, so surprising behaviour downstream is one environment variable away from its cause instead of a bisect.
Names resolve through the class hierarchy. "java.io.File" and "File" both match, and a registration on a supertype answers for its subtypes — extend java.io.Reader and a StringReader receiver gets the method. By the same rule a registration on java.lang.Object reaches every value, which is occasionally what you want and usually not.
One limit. Methods on String, Keyword and StringBuilder — and on anything they inherit from, such as CharSequence — cannot be overridden. Where the compiler can prove the receiver's type it compiles those calls straight to a primitive, so an override would take effect at some call sites and not others; extend-class! refuses the registration rather than let that happen. Adding a method those classes don't have is unaffected.
Your own shimmed class is extensible here too, once (class x) knows about it: (clojure.core/__register-class! pred class-fn tags-fn) makes its values report a class name (and dispatch protocols extended to that class), and extend-class! then addresses them by that name like any built-in.
The registration API at a glance
The __register-* functions live in clojure.core (no require); the tagged-table, hierarchy and class-extension seams live in jolt.host:
(__register-class-ctor! "pkg.Name" (fn [args…] …)):(pkg.Name. args…)(__register-class-statics! "pkg.Name" {"FIELD" v, "method" (fn […] …)}):pkg.Name/FIELD,(pkg.Name/method …)(__register-class-methods! :your-tag {"method" (fn [self args…] …)}):(.method obj args…)on a value tagged:your-tag(__register-instance-check! (fn [class-name-str v] true|false|nil)):(instance? pkg.Name v)(jolt.host/register-class-supers! "pkg.Name" ["pkg.Super" "pkg.Iface" …]): hierarchy forisa?/ancestors(jolt.host/extend-class! "pkg.Name" {:methods {…} :statics {…} :ctor f :override bool}): add to — or replace — a shim Jolt already hasjolt.host/tagged-table,jolt.host/ref-put!,jolt.host/ref-get,jolt.host/table?: build and read a stateful wrapper
If a shim would be useful to everyone, it's also a great contribution to the runtime itself; the built-in shims use exactly these registries. See Writing Libraries and Jolt's host/chez/java sources.
These registries add a class. If instead you want to supply data to something Jolt already implements (per-locale currency symbols, month names, number separators), that is an extension point: core declares the contract and answers for one root key, and your library registers the rest.
Running work on the main thread
Some native calls must run on the process's main (primordial) thread. A GUI toolkit is the usual case: on macOS a Cocoa/GTK-quartz call off the main thread aborts the process ("setting the main menu on a non-main thread"). Jolt has no JVM AWT thread, so it exposes a small marshalling API in jolt.host that a GUI library builds on. Most applications never call these directly; you use a UI library (e.g. glimmer) that does. If you bind a toolkit yourself, this is the seam.
The model is a main-thread pump: one thread becomes a queue-draining event loop, and other threads hand it thunks to run there.
(jolt.host/call-on-main-thread thunk): runthunkon the pump thread and block until it finishes, returning its value (or re-raising its error). If no pump is active it runsthunkinline on the calling thread, and a call already on the pump runs inline too (reentrant).(jolt.host/call-on-main-thread-async thunk): schedulethunkon the pump and return immediately (nil), without waiting. This is what lets a GUI library'srunstart the toolkit's event loop (which blocks the pump for the app's whole lifetime) while the call that started it returns, so an nREPL session stays live for reactive edits. With no pump active it runs inline. *(New in 0.4.14.)*(jolt.host/run-main-pump): turn the calling thread into the pump: drain and run queued jobs FIFO, blocking untilstop-main-pump. Call this on the main thread of a program whose main thread should service GUI work.(jolt.host/stop-main-pump): tell a running pump to finish draining and return.(jolt.host/park-until-interrupt): park the calling (main) thread until a keyboard interrupt (^C), running shutdown hooks and exiting when it arrives, and own the pump while parked. This is the variant a foreground server uses: the nREPL server parks the primordial thread here so a UI event loop evaluated from the REPL runs on the real main thread (not off-main, which would abort), while^Cstill shuts the server down cleanly. Unlikerun-main-pump's bare wait, it idles in an interrupt-checked poll so^Cis delivered.
Because both blocking and async calls fall back to running inline when no pump is active, code written against this API also works in a plain -M:run launch, where the main thread is already the caller; the library resolves the seam at call time and needs no separate non-GUI path.
Calling Scheme directly: jolt.scheme
The layers above (the Java-shaped shims, jolt.ffi for C, the jolt.host seams) are the portable interop story. jolt.scheme is the escape hatch under them: call a host Scheme procedure by name, or evaluate Scheme text, from any Jolt program.
(require '[jolt.scheme :as scheme])
(scheme/call "expt" 2 10) ;=> 1024
(scheme/eval-string "(let ((x 3)) (* x 14))") ;=> 42
(scheme/defsfn fx+ "fx+") ; def a named binding
(fx+ 1 2) ;=> 3
(let [v (scheme/call "vector" 1 2 3)] ; a host value, opaque to jolt
(scheme/call "vector-ref" v 0)) ;=> 1
call resolves the top-level procedure at run time and applies it; proc returns it as a value (usable with map, stored in a var by defsfn); eval-string evaluates Scheme text and returns the last form's value, with definitions persisting. An unbound name or a Scheme error surfaces as a catchable exception.
scheme is eval-string without the quoting: the body is written in Jolt and rendered to Scheme source at macroexpansion, so a Scheme expression reads like a do block. Multiple forms run in order as a (begin ...), the last value is returned, and a define splices into the interaction environment.
(scheme/scheme (let ((x 3)) (* x 14))) ;=> 42
(scheme/scheme (string-append "a" "b")) ;=> "ab"
(scheme/scheme (define counter 0) ; define persists, last value wins
(+ counter 1)) ;=> 1
The body is read with Jolt's reader, so Scheme spellings Jolt's reader rejects are written in Jolt spelling and rendered across: true and false become #t and #f, \A becomes #\A, and [1 2 3] becomes the datum vector #(1 2 3). That cuts both ways: a vector is data, not binding syntax, so a let needs Scheme-style ((x 3)) bindings, and keywords, maps and sets have no Scheme reading and are refused at macroexpansion. Reader sugar that expands to clojure.core calls (@x, syntax-quote) lands as unbound Scheme names, so write (unquote ...) longhand.
The contract is raw: numbers, strings, booleans and characters are the same representations on both sides and cross untouched; everything else crosses as whatever it is on the other side. A Scheme vector arriving in Jolt is an opaque host value; hand it back to Scheme to use it. A Jolt collection handed to Scheme is Jolt's representation, not a Scheme list. nil is Jolt's nil, not '() and not #f.
Two caveats. This is host-specific by design: code using jolt.scheme is tied to the Chez runtime and its primitives, unlike everything else on this page. And names resolve at run time, so a tree-shaken binary (--tree-shake) only finds what the kept runtime still carries; an exotic primitive a build shook out is an unbound-name error, not a silent nil.