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 #
- Request for what a unit reads out of a call.
- Store for the per-unit persistence surface.
- Result for what a handler may return.
- Host-side API for the types a host wires up.
- Packages at a glance for the import line of any type here.
What will bite you #
- The page has two halves. Unit-side and host-side types live in different packages. Taking one from the wrong half is the usual way a first afternoon goes sideways.
- This page breaks ties. Every signature was read off the SDK source. When a guide page disagrees, believe this one and treat the other as out of date.
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:
- Request
- Interlock (il)
- Store
- Result
- Refusal
- Java handler contract
- JS/JSX handler contract
- Languages and ids
Host-side:
- Host-side API
- Packages at a glance
- InterlockSDK
- Brokered challenge solving
- Tasks clients
- Engine
- CodeSource and its implementations
- StoreFactory, SecretResolver, Ai
- ContextProvider
- Json, Html and the host-side errors
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.
| Signature | Returns | Reads |
|---|---|---|
String str(String name, String def) | the value, or def | query param, then JSON body |
String str(String name) | the value, or null | query param, then JSON body |
int integer(String name, int def) | parsed int, or def | query param, then JSON body |
double number(String name, double def) | parsed double, or def | query param, then JSON body |
boolean bool(String name, boolean def) | parsed boolean, or def | query param, then JSON body |
List<String> list(String name) | never null, empty when absent | query params only |
boolean has(String name) | whether the name is present | query params only |
String header(String name) | header value, or null | headers, case-insensitive |
String cookie(String name) | cookie value, or null | the cookie header |
String path() | the request path | |
String method() | the HTTP method | |
Map<String, Object> body() | never null, {} when absent | the 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 #
| Reader | Behaviour |
|---|---|
integer / number | the value is trimmed and parsed; a parse failure returns def, it does not throw |
| 64-bit values | there 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 |
bool | true for true, 1, yes, on (trimmed, case-insensitive); any other present value is false; absent returns def |
list | a single value containing commas is split on , and trimmed, with empties dropped; repeated params are returned as an unmodifiable list |
header | names are matched lower-cased, so req.header("Content-Type") and req.header("content-type") are the same lookup |
cookie | parses header("cookie"), splitting on ; then on the first =, with an exact name match |
mark | declared 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.
| Signature | Notes |
|---|---|
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.
| Signature | Notes |
|---|---|
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.
| Signature | Kind | Returns |
|---|---|---|
Object get(String key) | fundamental | the value, or null |
Map<String, Object> all() | fundamental | the 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) | default | an unchecked cast of get(key); no conversion |
String getString(String key) | default | String.valueOf(value), or null |
Map<String, Object> getMap(String key) | default | the value when it is a Map, else null |
List<Object> getList(String key) | default | a copy of the value when it is a List, else null |
void putAll(Map<String, Object> values) | default | null-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).
| Member | Type | Notes |
|---|---|---|
kind | Result.Kind | JSON, HTML or TEXT |
value | Object | set for JSON |
text | String | set for HTML and TEXT |
contentType | String | |
status | int | defaults to 200 |
unitId, unitEnv, unitVersion | String, String, int | provenance, set by the engine |
Result withStatus(int status) | Result | returns this for chaining |
String provenance() | String | id@vN (env), or null when there is no provenance |
static Result json(Object value) | Result | content type application/json |
static Result html(String markup) | Result | content 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 returned | Becomes | Content type |
|---|---|---|
an Html | Result.html(markup) | text/html; charset=utf-8 |
a bare String | Result.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.
| Signature | Notes |
|---|---|
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.
- Over HTTP: the engine catches it on the single execution path, for every language and every entry point, and serves
Result.json(body()).withStatus(status())with the unit's provenance attached. Hosts do not have to remember to do this. - Through
il.call: the exception itself reaches the calling unit. That is the honest shape, because a refusal is not a value a caller should be able to mistake for data.
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 #
| Rule | What 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
};
- A global
html(markup)function is bound in every JS run and returns the sameHtmlthe Java side returns, so a unit can serve server-built markup. console.logis routed to the run's log stream and reaches any watching client.- Every run gets a fresh context and a wall-clock watchdog. The default cap is 30000 ms and the host can change it. See unit timed out.
- No default export, or a non-executable one, is refused.
.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} />;
}
- Imports resolve by id, in the same env, never by relative path. React itself is a real import provided by the runtime, not a CDN global.
- CSS is imported by id with its extension, because asset ids keep their extension.
- The import walk is bounded at 16 levels, which in practice catches cycles. See import graph is too deep.
- The same watchdog and timeout apply as for
.js.
.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.
| Type | Package | Kind | Source file |
|---|---|---|---|
Interlock | sh.interlock.sdk | interface | Interlock.java |
RequestImpl | sh.interlock.sdk.runtime | final class | runtime/RequestImpl.java |
InterlockHandler<R> | sh.interlock.sdk | @FunctionalInterface | InterlockHandler.java |
InterlockSDK | sh.interlock.sdk | final class, static factories | InterlockSDK.java |
Request | sh.interlock.sdk | interface | Request.java |
Store | sh.interlock.sdk | interface | Store.java |
HostContext | sh.interlock.sdk | interface | HostContext.java |
Ai | sh.interlock.sdk | interface | Ai.java |
AiRun | sh.interlock.sdk | final durable handle | AiRun.java |
Geo | sh.interlock.sdk | interface | Geo.java |
Challenges | sh.interlock.sdk | final class of nested contracts | Challenges.java |
Media | sh.interlock.sdk | final class of nested types | Media.java |
Models | sh.interlock.sdk | final class of String constants | Models.java |
Json | sh.interlock.sdk | final class, static only | Json.java |
Html | sh.interlock.sdk | final class, static factory | Html.java |
Refusal | sh.interlock.sdk | extends RuntimeException | Refusal.java |
Timer | sh.interlock.sdk | class | Timer.java |
TaskProducer | sh.interlock.sdk.tasks | interface | tasks/TaskProducer.java |
HttpTaskProducer | sh.interlock.sdk.tasks | final class | tasks/HttpTaskProducer.java |
TaskWorker | sh.interlock.sdk.tasks | final class, AutoCloseable | tasks/TaskWorker.java |
TaskWorkerBuilder | sh.interlock.sdk.tasks | final builder | tasks/TaskWorkerBuilder.java |
TaskTransport | sh.interlock.sdk.tasks | interface | tasks/TaskTransport.java |
HttpTaskTransport | sh.interlock.sdk.tasks | final class, AutoCloseable | tasks/HttpTaskTransport.java |
TaskHandler | sh.interlock.sdk.tasks | @FunctionalInterface | tasks/TaskHandler.java |
TaskContext | sh.interlock.sdk.tasks | interface | tasks/TaskContext.java |
Wire | sh.interlock.sdk.tasks | final class of protocol records | tasks/Wire.java |
Engine | sh.interlock.sdk.runtime | final class | runtime/Engine.java |
Result | sh.interlock.sdk.runtime | final class | runtime/Result.java |
CodeSource | sh.interlock.sdk.runtime | @FunctionalInterface | runtime/CodeSource.java |
CodeUnit | sh.interlock.sdk.runtime | record | runtime/CodeUnit.java |
DirectoryCodeSource | sh.interlock.sdk.runtime | final class, implements CodeSource | runtime/DirectoryCodeSource.java |
StoreFactory | sh.interlock.sdk.runtime | @FunctionalInterface | runtime/StoreFactory.java |
SecretResolver | sh.interlock.sdk.runtime | @FunctionalInterface | runtime/SecretResolver.java |
ContextProvider | sh.interlock.sdk.runtime | @FunctionalInterface | runtime/ContextProvider.java |
CodeInvoker | sh.interlock.sdk.runtime | interface | runtime/CodeInvoker.java |
EngineException | sh.interlock.sdk.runtime | extends RuntimeException | runtime/EngineException.java |
CodeNotFoundException | sh.interlock.sdk.runtime | extends EngineException | runtime/CodeNotFoundException.java |
UnitBuild | sh.interlock.sdk.runtime | final class (UnitBuild.Check) | runtime/UnitBuild.java |
InterlockClient | sh.interlock.sdk.client | final class, implements CodeSource | client/InterlockClient.java |
HostIdentity | sh.interlock.sdk.client | record | client/HostIdentity.java |
RunExecutor | sh.interlock.sdk.client | interface | client/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.
| Signature | Notes |
|---|---|
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 VERSION | the 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:
| Signature | Default 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 |
| Terminal | Notes |
|---|---|
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.
| Signature | Notes |
|---|---|
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:
| Signature | Notes |
|---|---|
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:
| Signature | Notes |
|---|---|
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_LANGUAGES | js, 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:
| Signature | Notes |
|---|---|
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:
| Signature | Notes |
|---|---|
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:
| Signature | Notes |
|---|---|
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.
| Signature | Notes |
|---|---|
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:
| Member | Notes |
|---|---|
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.
| Constructor | Notes |
|---|---|
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:
| Constructor | Notes |
|---|---|
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.
| Type | Package | Abstract method |
|---|---|---|
StoreFactory | sh.interlock.sdk.runtime | Store store(String codeId, String env) |
SecretResolver | sh.interlock.sdk.runtime | String 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.
| Signature | Kind | Notes |
|---|---|---|
String chat(String model, String prompt) | abstract | name the model with a Models constant, not a vendor id |
boolean live() | abstract | whether a real provider is configured, as opposed to an offline stub |
default String chat(String prompt) | default | delegates to the Models.MEDIUM rung |
default String chatWithOptions(ChatOptions options, String prompt) | default | chooses managed-only, self-hosted-only, or explicit self-hosted-then-managed routing |
default AiRun submit(ChatOptions options, String prompt) | default | submits 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 type | explicit exact completed-result reuse; exact(key) defaults to 24 hours |
ChatOptions cache(String key, long ttlSeconds) | builder | returns options with exact completed-result reuse enabled |
ChatOptions cache(String key) | builder | same, with the 24-hour default TTL |
default Media.Result media(Media.Kind kind, String model, String prompt, Media.Options opts) | default | returns 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) | default | false |
default Media.Request request(Media.Kind kind) | default | the 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().
| Signature | Notes |
|---|---|
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 EMPTY | no 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 #
| Type | Package | Surface |
|---|---|---|
Json | sh.interlock.sdk | static String toJson(Object value), static <T> T fromJson(String json, Class<T> type), static Map<String, Object> toMap(String json) |
Html | sh.interlock.sdk | static Html of(String markup), String markup(), String toString() |
EngineException | sh.interlock.sdk.runtime | EngineException(String message), EngineException(String message, Throwable cause) |
CodeNotFoundException | sh.interlock.sdk.runtime | CodeNotFoundException(String id, String env) |
Refusal | sh.interlock.sdk | see 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)
| arg | notes |
|---|---|
params | query parameters, each name to its values |
headers | lower-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 |
body | the parsed JSON body, or Map.of(). This is what makes req.str find a value that arrived in the body |
path | what req.path() returns |
method | "GET", "POST", … |
principal | who 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.