interlock.sh docs

Reference #

Raw markdown: /reference.md

For humans #

Use this to look up a type, method or signature whose name you already have. Do not read it end to end. A guide page will teach you more, then send you back here.

Jump to #

What will bite you #

For robots #

Flat lookup for the whole SDK: the surface a unit can touch, and the surface a host embeds. Every signature here was read off the SDK source, not off a design document. If a page elsewhere on this site disagrees with this one, this one is right.

Package: sh.interlock.sdk unless noted. Result is sh.interlock.sdk.runtime.Result. The host-side types are split across two packages; see Packages at a glance for the import line of every type on this page.

Unit-side:

Host-side:

Request #

sh.interlock.sdk.Request. The read surface for a request, uniform across Java and JS. The implementation described below is sh.interlock.sdk.runtime.RequestImpl.

SignatureReturnsReads
String str(String name, String def)the value, or defquery param, then JSON body
String str(String name)the value, or nullquery param, then JSON body
int integer(String name, int def)parsed int, or defquery param, then JSON body
double number(String name, double def)parsed double, or defquery param, then JSON body
boolean bool(String name, boolean def)parsed boolean, or defquery param, then JSON body
List<String> list(String name)never null, empty when absentquery params only
boolean has(String name)whether the name is presentquery params only
String header(String name)header value, or nullheaders, case-insensitive
String cookie(String name)cookie value, or nullthe cookie header
String path()the request path
String method()the HTTP method
Map<String, Object> body()never null, {} when absentthe parsed JSON body
String principal()host-defined principal, or null
void mark(String label)

The body rule #

str, integer, number and bool read the query parameter first and then the JSON body. A value in either place is found; on a tie the query wins, because it is the more specific address (it is in the URL the caller typed) and because the reverse precedence would let a body silently override an explicit ?env= on a shared endpoint.

has and list do not consult the body. They read the query parameter map only. Guarding a body-carried field with req.has(name) therefore fails; test req.str(name) != null instead, or read req.body() directly for a body-carried collection. See req.has and req.list do not read the body.

Parsing details #

ReaderBehaviour
integer / numberthe value is trimmed and parsed; a parse failure returns def, it does not throw
64-bit valuesthere is no long reader. number returns a double, so casting it loses the low bits above 2^53. Read str(name, null) and Long.parseLong it
booltrue for true, 1, yes, on (trimmed, case-insensitive); any other present value is false; absent returns def
lista single value containing commas is split on , and trimmed, with empties dropped; repeated params are returned as an unmodifiable list
headernames are matched lower-cased, so req.header("Content-Type") and req.header("content-type") are the same lookup
cookieparses header("cookie"), splitting on ; then on the first =, with an exact name match
markdeclared as a timing and diagnostics checkpoint; the runtime implementation is currently a no-op

Requests made by il.call #

il.call(id, params) builds a request from the params map. Each entry is placed in both the query map (stringified) and the body, so every reader above finds it. On such a request path() is /call and method() is CALL.

Interlock (il) #

sh.interlock.sdk.Interlock. The capability gateway handed to every unit: the one controlled surface to reach anything external.

SignatureNotes
Store store()this unit's persistent namespace
Store store(String codeId)another unit's namespace, same env
String secret(String name)host secret resolved at runtime, null if unset; never in source
Object call(String id, Map<String, Object> params)invoke another unit, return its raw value
String url(String id, Map<String, Object> params)build a stable URL for a unit by id
void log(Object... args)one line, streamed to any watching client
Ai ai()generative AI; chat(model, prompt) and chat(prompt) are the core
Geo geo()defaulted; a host that wires nothing returns Geo.Location.UNKNOWN
HostContext context()never null, empty when the host provides none
Object context(String key)one context value, or null
<T> T context(Class<T> type)the typed door, and the one a Java unit should use
<T> T session(Class<T> type)the per-run value of this type, or null
String unitId()id of the unit currently executing; defaults to null
String unitEnv()env of this run; defaults to null
int unitVersion()stored version of this unit, 0 when unknown

context versus session #

context(Class) is seeded once at init and is the same for every run. session(Class) is handed in per invocation. They are deliberately separate objects: a unit that asks for a session must not silently receive a process-wide object, and a value that changes every request must not be reachable through the accessor for values that never do.

context(Class) throws IllegalArgumentException when the host seeded more than one object assignable to the type. That is a host bug, raised rather than resolved by picking one arbitrarily. See more than one context of that type.

HostContext #

sh.interlock.sdk.HostContext. What il.context() returns. Read-only: the host seeds a plain Map at init, and a unit gets this accessor rather than the map, with Supplier values resolved lazily on the key actually read.

SignatureNotes
Object get(String key)Supplier-resolved; null if absent
boolean has(String key)true when the host provided the key, even if its value is null
Iterable<String> keys()names only, no values materialized
boolean isEmpty()true when the host provided no context at all

From JS it reads as an object (il.context().db); from Java as il.context().get("db"), or better, il.context(Db.class).

Store #

sh.interlock.sdk.Store. A per-unit key-value namespace. Reads return the value directly. There are no wrappers, and the storage implementation never leaks.

A host implements only the five fundamentals; the typed getters and bulk put are interface defaults derived from them. A host may override a default when the backing store can do it cheaper.

SignatureKindReturns
Object get(String key)fundamentalthe value, or null
Map<String, Object> all()fundamentalthe whole namespace
void put(String key, Object value)fundamental
void remove(String key)fundamental
void clear()fundamental
<T> T get(String key, Class<T> type)defaultan unchecked cast of get(key); no conversion
String getString(String key)defaultString.valueOf(value), or null
Map<String, Object> getMap(String key)defaultthe value when it is a Map, else null
List<Object> getList(String key)defaulta copy of the value when it is a List, else null
void putAll(Map<String, Object> values)defaultnull-tolerant; puts each entry

Java #

var store = il.store();
store.put("counts", Map.of("a", 1, "b", 2));
Map<String, Object> counts = store.getMap("counts");   // the value, directly
List<Object> names = store.getList("names");           // a copy, safe to mutate
Long total = store.get("total", Long.class);
Map<String, Object> all = new HashMap<>(store.all());
store.putAll(all);

// another unit's namespace, by id
Object hits = il.store("lib/Counter").get("hits");

JavaScript #

The same object, reached across the polyglot boundary, so the methods and their names are identical. get(key, Class) is the one entry with no natural JS form.

const store = il.store();
store.putAll({ variant: 'B', at: Date.now() });
const variant = store.get('variant');        // 'B'
const all = store.all();
store.remove('at');

const hits = il.store('lib/Counter').get('hits');

Cross-namespace access is deliberate coordination, not a back door. Do not scribble where you do not own the namespace.

Result #

sh.interlock.sdk.runtime.Result. What a unit produced, plus how to serve it. Units usually do not construct one: the engine converts whatever the handler returned (see the conversion table below).

MemberTypeNotes
kindResult.KindJSON, HTML or TEXT
valueObjectset for JSON
textStringset for HTML and TEXT
contentTypeString
statusintdefaults to 200
unitId, unitEnv, unitVersionString, String, intprovenance, set by the engine
Result withStatus(int status)Resultreturns this for chaining
String provenance()Stringid@vN (env), or null when there is no provenance
static Result json(Object value)Resultcontent type application/json
static Result html(String markup)Resultcontent type text/html; charset=utf-8
static Result text(String body, String contentType)Result

status is advisory, not authoritative. The host maps it, is free to clamp or ignore it, and should ignore anything outside a sane range, because this is a hot-deployable value on the response path.

How a returned value becomes a Result #

The handler returnedBecomesContent type
an HtmlResult.html(markup)text/html; charset=utf-8
a bare StringResult.text(...)text/plain; charset=utf-8
anything else (Map, List, POJO, number)Result.json(value)application/json

A bare String is plain text, not a JSON document: it serves without JSON quoting. Structured data stays JSON.

Static-language units are served from their source: .html as text/html; charset=utf-8, .css as text/css; charset=utf-8, .md as text/markdown; charset=utf-8, anything else as text/plain; charset=utf-8.

Refusal #

sh.interlock.sdk.Refusal extends RuntimeException. A unit saying no, with the status a host should serve it as.

SignatureNotes
Refusal(int status, String reason, String message)
Refusal(String reason, String message)status defaults to 409
int status()
String reason()a stable machine-readable token a UI can branch on, never a sentence
Map<String, Object> body()the served body, described below

body() is a LinkedHashMap in this order:

{"ok": false, "reason": "cooldown", "error": "you just got in touch"}

error is the exception's message. reason is the constructor's reason.

if (msSinceLast < COOLDOWN_MS) {
    throw new Refusal(409, "cooldown", "you just got in touch");
}

Suggested statuses, from the constructor's own documentation: 409 for "the world says no", 404 for "not yours or not there", 400 for "that request does not make sense".

Two more the javadoc does not list, and the first is common enough to state: 401 when the unit needs a signed-in caller and il.session(...) gave it an anonymous one, which is the normal shape in a passthrough that also serves public pages; 403 when the caller is known and still not allowed.

Thrown, not returned, so it cannot be forgotten in the middle of a method the way a status field can.

Returning a map with ok:false remains correct when "no" is an ordinary outcome. Use Refusal when a client that ignores the body must still fail. See a refusal is served as 200.

Java handler contract #

sh.interlock.sdk.InterlockHandler<R> is a @FunctionalInterface with one method:

R handle(Request req, Interlock il);

A unit is one file with one public class implementing it. The returned value is the response.

package caller;

import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;

import java.util.Map;

public class UseIt implements InterlockHandler<Object> {
  public Object handle(Request req, Interlock il) {
    il.log("running");
    return Map.of("ok", true, "name", req.str("name", "world"));
  }
}

Prefer a typed R over Object or Map. The interface between a unit and the JVM should be a dumb POJO.

Rules the compiler enforces #

RuleWhat happens when you break it
Directory is package. code/caller/UseIt.java declares package caller;, or declares nothing. Enforced only when the unit declares a package, so package-less units keep working.declared package does not match the directory
Reserved packages. A unit may not declare a class into java, javax, jdk, sun, com.sun, sh.interlock, io.quarkus, jakarta, or any host package (auto-reserved from the top two segments of each compile anchor). Importing from them is fine. Refused at compile and at load.package is reserved
Handler discovery is by attribution. Only a class compiled from this unit's own source can be its handler: the first non-interface, non-abstract one assignable to InterlockHandler. A dependency's handler classes land in the same loader, so an interface scan would be a lottery.the wrong class would run
No handler means a library unit. It compiles and it is importable, and running it is refused with a sentence.unit is a library and cannot run
Generated units are never importable. They stay behind il.call, where reduced trust holds.a sibling import does not resolve

Helpers can be package-private classes in the same file. Java units are project-trusted code and are not hard-capped by the run watchdog.

To reach a Java unit's class reflectively (the testing door, and the only way to touch a library unit): Engine.unitClass(id, env).

JS/JSX handler contract #

.js #

The default export is the handler, and it must be executable.

export default (req, il) => {
  il.log('running');
  return { ok: true, name: req.str('name', 'world') };   // object or array becomes JSON
};

.jsx #

The default export is the root React component; the runtime transpiles server-side, renders, and serves a full HTML page.

import React, { useState, useEffect } from 'react';
import { Total } from 'counter/Total';   // a sibling unit, imported by id
import 'counter/styles.css';             // CSS by id

export default function App() {
  const [count, setCount] = useState(null);
  useEffect(() => { il.call('counter/api', { by: 1 }).then(setCount); }, []);
  if (count === null) return <p>Loading…</p>;
  return <Total count={count} />;
}

.html #

Served as-is.

Languages and ids #

Known languages: js, jsx, java, html, css, md, txt.

Ids are paths. code/world/WorldApi.java has id world/WorldApi. Text ids drop the extension; assets keep it (assets/logo.png stays assets/logo.png), because otherwise logo.png and logo.webp would collide into one unit. camelCase ids also resolve kebab-case in URLs, so world/WorldApi and world/world-api address the same unit.

Envs are dev, staging and prod, moved by promotion and never by re-syncing.

Host-side API #

Everything above is what a unit sees. Everything below is what a host writes: the types you import into your own application to stand the engine up and serve units over HTTP. Two audiences, two type sets, deliberately different. Interlock is handed to a unit; InterlockSDK is called by a host. A host never implements Interlock, and a unit never sees Engine.

Request and Result are the two types both sides touch: the host builds or adapts a Request, the engine hands a Result back. Both are documented above.

Packages at a glance #

Every type on this page, with the package to import it from. Nothing here is guessed; each row was read off the file named in the last column.

TypePackageKindSource file
Interlocksh.interlock.sdkinterfaceInterlock.java
RequestImplsh.interlock.sdk.runtimefinal classruntime/RequestImpl.java
InterlockHandler<R>sh.interlock.sdk@FunctionalInterfaceInterlockHandler.java
InterlockSDKsh.interlock.sdkfinal class, static factoriesInterlockSDK.java
Requestsh.interlock.sdkinterfaceRequest.java
Storesh.interlock.sdkinterfaceStore.java
HostContextsh.interlock.sdkinterfaceHostContext.java
Aish.interlock.sdkinterfaceAi.java
AiRunsh.interlock.sdkfinal durable handleAiRun.java
Geosh.interlock.sdkinterfaceGeo.java
Challengessh.interlock.sdkfinal class of nested contractsChallenges.java
Mediash.interlock.sdkfinal class of nested typesMedia.java
Modelssh.interlock.sdkfinal class of String constantsModels.java
Jsonsh.interlock.sdkfinal class, static onlyJson.java
Htmlsh.interlock.sdkfinal class, static factoryHtml.java
Refusalsh.interlock.sdkextends RuntimeExceptionRefusal.java
Timersh.interlock.sdkclassTimer.java
TaskProducersh.interlock.sdk.tasksinterfacetasks/TaskProducer.java
HttpTaskProducersh.interlock.sdk.tasksfinal classtasks/HttpTaskProducer.java
TaskWorkersh.interlock.sdk.tasksfinal class, AutoCloseabletasks/TaskWorker.java
TaskWorkerBuildersh.interlock.sdk.tasksfinal buildertasks/TaskWorkerBuilder.java
TaskTransportsh.interlock.sdk.tasksinterfacetasks/TaskTransport.java
HttpTaskTransportsh.interlock.sdk.tasksfinal class, AutoCloseabletasks/HttpTaskTransport.java
TaskHandlersh.interlock.sdk.tasks@FunctionalInterfacetasks/TaskHandler.java
TaskContextsh.interlock.sdk.tasksinterfacetasks/TaskContext.java
Wiresh.interlock.sdk.tasksfinal class of protocol recordstasks/Wire.java
Enginesh.interlock.sdk.runtimefinal classruntime/Engine.java
Resultsh.interlock.sdk.runtimefinal classruntime/Result.java
CodeSourcesh.interlock.sdk.runtime@FunctionalInterfaceruntime/CodeSource.java
CodeUnitsh.interlock.sdk.runtimerecordruntime/CodeUnit.java
DirectoryCodeSourcesh.interlock.sdk.runtimefinal class, implements CodeSourceruntime/DirectoryCodeSource.java
StoreFactorysh.interlock.sdk.runtime@FunctionalInterfaceruntime/StoreFactory.java
SecretResolversh.interlock.sdk.runtime@FunctionalInterfaceruntime/SecretResolver.java
ContextProvidersh.interlock.sdk.runtime@FunctionalInterfaceruntime/ContextProvider.java
CodeInvokersh.interlock.sdk.runtimeinterfaceruntime/CodeInvoker.java
EngineExceptionsh.interlock.sdk.runtimeextends RuntimeExceptionruntime/EngineException.java
CodeNotFoundExceptionsh.interlock.sdk.runtimeextends EngineExceptionruntime/CodeNotFoundException.java
UnitBuildsh.interlock.sdk.runtimefinal class (UnitBuild.Check)runtime/UnitBuild.java
InterlockClientsh.interlock.sdk.clientfinal class, implements CodeSourceclient/InterlockClient.java
HostIdentitysh.interlock.sdk.clientrecordclient/HostIdentity.java
RunExecutorsh.interlock.sdk.clientinterfaceclient/RunExecutor.java

The three packages, in one line each: sh.interlock.sdk is what a unit sees plus the host bootstrap, sh.interlock.sdk.runtime is the engine and its wiring seams, sh.interlock.sdk.client is the wire to the Interlock service.

InterlockSDK #

sh.interlock.sdk.InterlockSDK. A final class: the host-facing bootstrap. Static factories start it, fluent setters override defaults, build() returns an Engine. There is no public constructor.

SignatureNotes
static InterlockSDK init(String sdkKey, Map<String, Object> context)seed context map; a null map is tolerated
static InterlockSDK init(String sdkKey)no context; add keys with context(k, v)
static InterlockSDK init(String sdkKey, ContextProvider context)per-request/per-unit provider; a null provider becomes ContextProvider.EMPTY
static Engine engine(String sdkKey, Map<String, Object> context)the no-override shortcut, equal to init(key, context).build()
static final String VERSIONthe SDK's own version, currently "1"

Overload note: init(String, Map) and init(String, ContextProvider) are distinct overloads, so a null second argument is ambiguous to javac. Use init(key) when you have no context.

Fluent setters, each returning this:

SignatureDefault when you do not call it
InterlockSDK codeSource(CodeSource src)an InterlockClient built from serviceUrl plus the key
InterlockSDK stores(StoreFactory f)a private per-unit in-memory store factory
InterlockSDK secrets(SecretResolver r)env vars, with foo-bar read as FOO_BAR
InterlockSDK ai(Ai a)brokered through Interlock when the code source is an InterlockClient, otherwise an honest no-op reporting live() == false
InterlockSDK geo(Geo g)brokered through Interlock when the code source is an InterlockClient, otherwise Geo.Location.UNKNOWN
InterlockSDK context(String key, Object value)nothing seeded
InterlockSDK contextForGenerated(String key, Object value)nothing exposed to generated code (deny by default)
InterlockSDK serviceUrl(String url)INTERLOCK_URL, falling back to https://api.interlock.sh
InterlockSDK warmUp(boolean w)true, so the React SSR runtime is warmed at build
TerminalNotes
Engine build()auto-anchors the seeded context classes for javac, constructs the Engine, warms SSR unless warmUp(false)
Engine start()alias for build()

context(String, Object) and contextForGenerated(String, Object) throw IllegalStateException when the bootstrap was started with init(key, ContextProvider): a dynamic provider has nothing to add to. contextForGenerated also seeds the ordinary context, so an entry added there is visible to authored units as well as generated ones.

build() calls Engine.addCompileAnchor for each statically-known seed value whose class is the host's own (JDK, javax, jakarta, sun and jdk classes are skipped, and so is any value that is a Supplier). A host that constructs new Engine(...) by hand gets none of that and must anchor by hand.

The host's presence is announced from the environment: INTERLOCK_HOST_ID and INTERLOCK_HOST_NAME (both defaulting to the machine hostname), INTERLOCK_ENV (default dev), INTERLOCK_REMOTE_RUN (default false) and INTERLOCK_REMOTE_RUN_ALLOW_PROD (default false).

import sh.interlock.sdk.InterlockSDK;
import sh.interlock.sdk.runtime.Engine;

Engine engine = InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY"))
        .context("app", myContext)
        .stores(myDbStoreFactory)
        .build();

Brokered challenge solving #

The rule. A Java host sends a provider-neutral Challenges.Request through InterlockClient.challenges().solve(request). The host never receives or selects the upstream solver, never supplies its credential, and never controls its task protocol. The request carries only a kind, the public site parameters, an idempotency key and—when relevant—a worker language.

Why it exists. One Java control plane must own routing, settlement and billing. A retry with the same idempotency key is the same paid transaction, and the API charges that transaction from the provider-reported cost instead of guessing from a flat CAPTCHA price. Keeping those decisions out of the crawler also keeps the downloaded Node package provider-neutral.

Challenges exposes CANONICAL_KINDS, VARIANTS, their combined KINDS, and explicit DISABLED_KINDS / UNAVAILABLE_KINDS. Request.managed(...) asks Interlock to use its managed solver; Request.supplied(...) selects a customer-supplied route when the server allows one. Its ChallengeParameters types the inputs browser challenges share (pageUrl, siteKey, action, userAgent); anything else a technology needs goes in with with(name, value), spelled as the technology names it. Result returns the canonical kind, its output shape, and a ChallengeSolution: token, text, userAgent and browser-ready cookies when the family has them, and the whole answer for positional or structured families. It never returns an upstream task id, price or provider metadata.

The failure it prevents. Invalid work fails before money can be spent, with literal validation messages such as:

an idempotency key of 8 to 200 characters is required

Server, transport, disabled and unavailable failures cross the SDK only as Challenges.Unavailable; its public message is always:

Interlock challenge service unavailable

Branch on Unavailable.code(), not its message. Do not retry with a new idempotency key unless it is a genuinely new logical solve.

Minimal example.

import sh.interlock.sdk.ChallengeParameters;
import sh.interlock.sdk.Challenges;
import sh.interlock.sdk.client.InterlockClient;

var request = Challenges.Request.managed(
        "turnstile",
        ChallengeParameters.page(pageUrl, siteKey),
        crawlAttemptId);
Challenges.Result solved = client.challenges().solve(request);
String token = solved.solution().token();

Verify it.

./gradlew :interlock-java-sdk:test --tests sh.interlock.sdk.ChallengesTest

Tasks clients #

sh.interlock.sdk.tasks is role-separated. HttpTaskProducer takes a producer key and exposes submit, get, cancel, watch, and await. HttpTaskTransport takes a worker key and is passed to TaskWorker.builder(transport); the builder registers typed handlers and returns a worker whose start, drain, stats, join, and close methods own its lifecycle.

SignatureNotes
new HttpTaskProducer(String interlockUrl, String producerKey)producer authority only
Wire.TaskView submit(Wire.SubmitRequest request)durable admission; caller idempotency key is optional but recommended
Wire.TaskView get(String taskId)canonical state/result
Wire.TaskView cancel(String taskId, String reason)cooperative when already running
AutoCloseable watch(String taskId, Consumer<Map<String,Object>> onEvent)SSE notification; journal remains truth
Wire.TaskView await(String taskId, Duration timeout, Duration poll)watch plus polling fallback; for jobs/CLIs, not request threads
new HttpTaskTransport(String interlockUrl, String workerKey)worker authority only
TaskWorker.builder(TaskTransport)configure identity, pool, concurrency, spool and handlers
handle(String type, int version, EffectSafety safety, TaskHandler handler)one versioned handler; undeclared safety is never inferred
TaskWorker start() / void drain() / void close()claim work; stop claiming but renew; then graceful shutdown

TaskContext exposes stable task/attempt/effect ids, correlations, buffered progress and log, cooperative cancellation, and an optional durable cursor. Full delivery semantics, Java and Node examples, error meanings, and verification commands are on Durable tasks and workers.

Engine #

sh.interlock.sdk.runtime.Engine. A final class declared public final class Engine implements CodeInvoker, AutoCloseable. It is AutoCloseable: close() shuts the JS and JSX runners down and closes the code source when that source is itself AutoCloseable, which stops the change-feed thread and the refresh executor.

Constructors (both public, so a host can bypass InterlockSDK for tests):

Signature
Engine(CodeSource code, StoreFactory stores, SecretResolver secrets, Ai ai)
Engine(CodeSource code, StoreFactory stores, SecretResolver secrets, Ai ai, ContextProvider context)

The four-argument form uses ContextProvider.EMPTY. A null context in the five-argument form is also treated as EMPTY.

Running:

SignatureNotes
Result run(String id, String env, Request req)load the unit and run it
Result run(String id, String env, Request req, Map<String, Object> local)as above, plus a context scoped to this one execution; local overlays the host's declared context and is discarded on return
Result runClosed(String id, String env, Request req, Map<String, Object> only)the unit sees exactly only and nothing the host declared globally; a null only becomes Map.of()
Result runSource(String id, String language, String source, Request req)run source directly, with no id to register and no fetch; the ephemeral unit gets env gen and version 0, and nothing is stored
Object invoke(String id, String env, Map<String, Object> params)the CodeInvoker method, which is how il.call(...) re-enters the engine; returns the unit's raw value
Class<?> unitClass(String id, String env)the compiled class behind a Java unit, for reflection; throws EngineException when the unit is not Java
void close()from AutoCloseable

Both runSource and runClosed exist. runSource is the generative primitive (source in, Result out) and runClosed is the trust boundary (context in, nothing else visible). They are independent: runClosed runs a stored unit, runSource runs a string.

Statics:

SignatureNotes
static void addCompileAnchor(Class<?> hostClass)lets Java units compile against that class's jar, and reserves its top two package segments
static void exportEntity(Class<?> entity)re-permits ONE persistence entity to units (denied by default — entity statics act on whole tables); prefer read-views
static void exportAllEntities()the blunt opt-out for a first-party host: every entity unit-reachable, pre-boundary behaviour
static void setRunTimeoutMs(long ms)wall-clock cap for one JS/JSX execution; a non-positive value resets to the default
static long getRunTimeoutMs()the current cap, default 30000
static final Set<String> KNOWN_LANGUAGESjs, jsx, java, html, css, md, txt

addCompileAnchor gates javac's visibility, not runtime reachability. A Java unit is full-trust code in the host JVM and is not wall-clock capped; only JS and JSX are.

Operations and integration acts:

SignatureNotes
void warmUp()pre-build the React SSR runtime; safe to call more than once, never throws
Map<String, Object> status()source-cache and feed state, per-runner cache stats, SSR pool occupancy, current run timeout
boolean publish(String id, String env, String language, String source)write one unit to Interlock
int publishAll(Path dir, String env)publish every file under a directory whose extension is a known language; returns how many were written
String sourceOf(String id, String env)the stored source, or null; for showing code to a human
List<String> listUnits(String env)the unit ids in this project
String weaverPrompt(String variant, String env)the resolved weaver prompt for a variant
UnitBuild.Check validate(String id, String language, String source)does this source build; UnitBuild.Check is a record of (boolean ok, String errors)
String jsxArtifact(String id, String source)the transpiled artifact for a jsx source, for a saver to store under JsxRunner.artifactKey(source); throws on broken JSX

Everything in that table except warmUp, status, validate and jsxArtifact needs an InterlockClient behind the engine. With any other CodeSource they throw EngineException.

Brokered capability, host-side twins of the il calls:

SignatureNotes
String ai(String system, String prompt)a completion on the default rung
String ai(String system, String prompt, String model)model is a Models tier or family alias, null for the default rung
Engine geo(Geo g)wire the location provider; returns this
Geo.Location geo(String ip)never throws, never null; an unplaceable address comes back as Geo.Location.UNKNOWN
Media.Result media(Media.Kind kind, String model, String prompt, Media.Options opts)the general form
Media.Result image(String model, String prompt, Media.Options opts)
Media.Result image(String prompt)on the Models.MEDIUM rung
Media.Result video(String model, String prompt, Media.Options opts)
Media.Result transcribe(String model, String audioUrl)
Media.SpeechSession speech(String model, String language)language is BCP-47, or null to let the transcriber detect it
Media.SpeechSession speech()default rung, language auto-detected

Note the two geo methods are an overload pair with different meanings: geo(Geo) is a setter that returns the engine, geo(String) is a lookup that returns a Geo.Location.

Generation:

SignatureNotes
InterlockClient.Generated generate(String intent)Interlock derives the unit id and returns it
InterlockClient.Generated generate(String intent, String reuseKey)reuseKey is a host-owned identity: what counts as "the same unit"
InterlockClient.Generated generate(String intent, String reuseKey, String variant)variant names a generator the project declares in its weaver index
Result generateAndRun(String intent, Request req)generate and run with no host capabilities
Result generateAndRun(String intent, Request req, Map<String, Object> local)
Result generateAndRun(String intent, String reuseKey, Request req, Map<String, Object> local)throws EngineException with the diagnostics when the generated unit does not build

InterlockClient.Generated is a record: (String unitId, String language, boolean compiles, boolean published, String errors, String source).

CodeSource and its implementations #

sh.interlock.sdk.runtime.CodeSource is a @FunctionalInterface: where code comes from. Return null when the (id, env) pair is absent.

SignatureNotes
CodeUnit fetch(String id, String env)the one abstract method
default List<String> list(String env)every unit id in that env; defaults to an empty list

list is what makes a Java sibling import resolvable, because javac resolves a package by listing it. A source that only answers fetch still runs units; sibling imports are simply off.

sh.interlock.sdk.runtime.CodeUnit is the record it returns:

MemberNotes
CodeUnit(String id, String env, String language, String source, int version, String origin, String artifact, String artifactKey)the canonical constructor
CodeUnit(String id, String env, String language, String source, int version, String origin)artifact fields default to null
CodeUnit(String id, String env, String language, String source, int version)origin defaults to CodeUnit.AUTHORED, artifact fields to null
static final String AUTHORED"authored"
static final String GENERATED"generated"
boolean generated()true when origin is GENERATED, meaning reduced capability

artifact is an optional precompiled form of the source (for jsx: the server's Babel output, computed at save so no process pays the transpiler for a saved unit). It is a cache with a key, never an authority: artifactKey binds it to the exact source and transpiler version, and a runner that finds the key stale or mismatched silently rebuilds from source. A source that returns nulls here — every source predating the field, every non-jsx unit — runs exactly as before.

Two implementations ship with the SDK.

sh.interlock.sdk.client.InterlockClient is the default, used when the host does not call codeSource(...). It is declared public final class InterlockClient implements CodeSource, AutoCloseable and fetches from the Interlock service, caches, and subscribes to the change feed.

ConstructorNotes
InterlockClient(String baseUrl, String token)announces HostIdentity.anonymous("dev"), so it receives code updates and is never a run target
InterlockClient(String baseUrl, String token, HostIdentity identity)a null identity falls back to HostIdentity.anonymous("dev")

A trailing slash on baseUrl is stripped. A null or blank token logs a warning at boot and skips the feed, because the feed endpoint is authenticated.

Two hooks a host rarely sets by hand (the Engine constructor registers both when the code source is an InterlockClient): void onRefresh(BiConsumer<CodeUnit, CodeUnit> hook), called with (previous, fresh) before the fresh unit is stored, and void onRun(RunExecutor executor), which is a no-op unless the host opted in to remote run.

sh.interlock.sdk.client.HostIdentity is a record: (String hostId, String name, String env, String sdkVersion, List<String> contextKeys, boolean remoteRunEnabled, boolean allowProd), with static HostIdentity anonymous(String env) and boolean announces().

sh.interlock.sdk.runtime.DirectoryCodeSource reads units off a disk tree, with no server, key or sync. One constructor:

ConstructorNotes
DirectoryCodeSource(Path root)root is the code/ directory, the one whose children are the environments

So new DirectoryCodeSource(Path.of("code")).fetch("notes/NoteApi", "dev") reads code/notes/NoteApi.java. A null env is read as dev. An id containing .. returns null, because an id is a unit name and never a path. Everything it returns is AUTHORED, so do not point it at a directory where machine-generated units land. Text units only: binary assets keep their extension and live behind the blob store, which needs a server.

StoreFactory, SecretResolver, Ai #

The three seams a host implements. The first two are single-method @FunctionalInterface types, so a lambda is the whole implementation.

TypePackageAbstract method
StoreFactorysh.interlock.sdk.runtimeStore store(String codeId, String env)
SecretResolversh.interlock.sdk.runtimeString resolve(String name)
InterlockSDK.init(key)
        .stores((codeId, env) -> new MyDbStore(codeId, env))
        .secrets(name -> vault.lookup(name))
        .build();

Ai is sh.interlock.sdk.Ai, and it is not a @FunctionalInterface: it has two abstract methods, so it cannot be a lambda.

SignatureKindNotes
String chat(String model, String prompt)abstractname the model with a Models constant, not a vendor id
boolean live()abstractwhether a real provider is configured, as opposed to an offline stub
default String chat(String prompt)defaultdelegates to the Models.MEDIUM rung
default String chatWithOptions(ChatOptions options, String prompt)defaultchooses managed-only, self-hosted-only, or explicit self-hosted-then-managed routing
default AiRun submit(ChatOptions options, String prompt)defaultsubmits a durable run when the host is a brokered Interlock client; other providers refuse rather than pretending to be durable
record CachePolicy(String key, long ttlSeconds)nested typeexplicit exact completed-result reuse; exact(key) defaults to 24 hours
ChatOptions cache(String key, long ttlSeconds)builderreturns options with exact completed-result reuse enabled
ChatOptions cache(String key)buildersame, with the 24-hour default TTL
default Media.Result media(Media.Kind kind, String model, String prompt, Media.Options opts)defaultreturns Media.Result.empty(kind); the one method a media-capable implementation must override, since every typed media helper routes through it
default boolean supports(Media.Kind kind)defaultfalse
default Media.Request request(Media.Kind kind)defaultthe step-by-step builder, run with Media.Request.run()

The typed media helpers are all defaults over media, and each fixes one Media.Kind: image(model, prompt, opts), image(model, prompt), image(prompt), imageEdit(model, prompt, sources), upscaleImage(model, source), removeBackground(model, source), video(model, prompt, opts), video(model, prompt), videoFromImage(model, prompt, source), upscaleVideo(model, source), avatarVideo(model, prompt, opts), music(model, prompt, opts), music(model, prompt), speech(model, text, opts), speech(model, text), soundEffect(model, prompt), transcribe(model, audioUrl), isolateVoice(model, audioUrl).

A minimal host Ai is therefore two methods:

InterlockSDK.init(key).ai(new Ai() {
    @Override public String chat(String model, String prompt) { return myProvider.complete(model, prompt); }
    @Override public boolean live() { return true; }
}).build();

Model names are sh.interlock.sdk.Models constants: price rungs MOST_EXPENSIVE, EXPENSIVE, MEDIUM, CHEAP, CHEAPEST, plus family aliases, plus the self-hosted model Models.QWEN_3_6_35B_A3B ("qwen3-6-35b-a3b") — Qwen3.6-35B-A3B Q8_0 run by a worker your own project operates (interlock-task-client); naming it routes there and never to a managed vendor, and with no online worker the call is refused (NO_ELIGIBLE_INFERENCE_WORKER, 409) unbilled. They are String constants, so a raw vendor id still compiles; it is just the one call that can age badly.

Placement is explicit through Ai.ChatOptions. selfHosted(model) refuses with NO_ELIGIBLE_INFERENCE_WORKER when the project has no eligible worker; it never spends with a managed provider behind the caller's back. withManagedFallback(model) is the opt-in policy that allows that fallback.

Completed-result caching is also explicit through ChatOptions.cache; ordinary chat is uncached. The caller-owned key is a namespace/version, while Interlock privately fingerprints it together with the project and the complete effective request. This prevents a convenient key such as "summary" from reusing the wrong model, prompt, schema, or tenant's answer. Only terminal successful text/structured responses without images, tools, or tool results are eligible. The first exact request invokes and bills the model; a hit creates a request receipt but no model invocation, model tokens, or model charge. Provider prompt-cache tokens remain a separate measurement.

Ai.ChatOptions options = new Ai.ChatOptions(Models.CHEAP, Ai.RoutePolicy.MANAGED_ONLY)
        .cache("product-summary:v2", 3_600);
String summary = il.ai().chatWithOptions(options, prompt);

Keys must be 1–160 characters and TTLs 60–2,592,000 seconds. Invalid values fail before model work with AI cache key must be between 1 and 160 characters or AI cache ttlSeconds must be between 60 and 2592000. A production host must set INTERLOCK_AI_CACHE_SECRET; otherwise an opted-in request fails closed with AI result caching is not configured on this Interlock rather than storing an unprotected result.

Verify. The database test proves exact hit, zero-charge reuse, expiry, tenant isolation, ineligible rounds, and concurrent single-flight behavior:

./gradlew :interlock-java-api:test --tests sh.interlock.api.ai.AiResultCacheDbTest -Dinterlock.test.db=true

For a call that may outlive an HTTP connection, submit a durable AiRun instead of holding one request open:

AiRun run = il.ai().submit(
        Ai.ChatOptions.selfHosted(Models.QWEN_3_6_35B_A3B),
        "Summarise the release evidence");
AiRun.Snapshot result = run.await(java.time.Duration.ofMinutes(10));

AiRun.id() is the recovery key. latest() returns the last known snapshot, refresh() reads the canonical durable state, await(Duration) polls until a terminal state, and cancel(reason) asks the canonical task to stop. A stream or caller disconnect does not cancel the run and is not a reason to submit it again. The snapshot carries the task id, state, route policy, answer or error, and the single usage-event id once metered.

The durable HTTP and Node submit options also accept bounded maxTokens (1–16,384) and timeoutMs. Use them for genuinely long generations; keeping an already-terminal stream open is not a long inference and should never be presented as edge-timeout evidence.

ContextProvider #

sh.interlock.sdk.runtime.ContextProvider. A @FunctionalInterface, and the parameter type of InterlockSDK.init(String, ContextProvider). What a unit reaches through il.context().

SignatureNotes
Map<String, Object> contextFor(String codeId, String env)the one abstract method; never null
default Map<String, Object> contextFor(String codeId, String env, boolean generated)returns an empty map for generated code unless overridden, which is the deny-by-default rule
static final ContextProvider EMPTYno host context

A value that is a java.util.function.Supplier is invoked per request; anything else is passed through as is. That is what lets a host hand out a request-scoped transaction rather than a process-global singleton. InterlockSDK overrides the three-argument form with the entries the host opened through contextForGenerated.

Json, Html and the host-side errors #

TypePackageSurface
Jsonsh.interlock.sdkstatic String toJson(Object value), static <T> T fromJson(String json, Class<T> type), static Map<String, Object> toMap(String json)
Htmlsh.interlock.sdkstatic Html of(String markup), String markup(), String toString()
EngineExceptionsh.interlock.sdk.runtimeEngineException(String message), EngineException(String message, Throwable cause)
CodeNotFoundExceptionsh.interlock.sdk.runtimeCodeNotFoundException(String id, String env)
Refusalsh.interlock.sdksee Refusal above

Json is a final class with a private constructor: static methods only, a thin Jackson wrapper. Both toJson and fromJson wrap any failure in a plain RuntimeException whose message names the method that failed.

Html is final with a private constructor, so Html.of(markup) is the only way to make one. A null markup becomes the empty string. A handler returning an Html serves text/html; charset=utf-8.

EngineException extends RuntimeException and is the clean, unit-facing failure. The engine throws it for a language it cannot execute, for unitClass on a unit that is not Java, for a generated unit that does not build, and for any client-backed call (publish, sourceOf, generate and the rest) on an engine whose code source is not an InterlockClient.

CodeNotFoundException extends EngineException, so catching EngineException catches it too. The engine throws it when CodeSource.fetch returns null, and its message is no code '<id>' in env '<env>'. Hosts map it to HTTP 404.

Refusal is sh.interlock.sdk.Refusal, on the unit side of the fence but relevant to a host for one reason: on the HTTP path the engine already catches it and turns it into Result.json(body()).withStatus(status()), so a host serves it by honouring Result.status and does not need a catch of its own. Through il.call it stays an exception, because Engine.invoke does not catch it.

RequestImpl #

The concrete Request a host builds when it serves a unit itself. You need it for the /app passthrough on Embedding the SDK, which is the one piece of host code the docs tell you to copy verbatim.

Package: sh.interlock.sdk.runtime.

public RequestImpl(Map<String, List<String>> params,
                   Map<String, String> headers,
                   Map<String, Object> body,
                   String path,
                   String method,
                   String principal)
argnotes
paramsquery parameters, each name to its values
headerslower-cased names. Do not pass cookie or authorization: a unit has no business seeing a caller's credential, and req.cookie(name) parses the raw header
bodythe parsed JSON body, or Map.of(). This is what makes req.str find a value that arrived in the body
pathwhat req.path() returns
method"GET", "POST", …
principalwho the host decided the caller is, or null. The only sanctioned way a unit learns that; it is a fact the host asserts, never one the unit can claim

The trailing null in the passthrough example is principal. Pass a real value once your host has resolved a session, and pass null while it has not.

RequestImpl.fromParams(Map<String, Object>) builds one from a bare parameter map, which is what il.call uses.