Units #
Raw markdown: /units.md
For humans #
Read this if you are writing your first unit, or a unit is not answering the way you expected. Skip it if you only run units other people wrote.
Do this #
A unit is one file. Four things decide how it behaves:
- The file.
.java,.js,.jsx,.html,.css,.md,.txt, or a binary asset. - The id. Its path under
code/, minus the extension. Move the file and you rename it everywhere: URL,il.call, storage. - The handler. One entry point:
export defaultin JS,implements InterlockHandler<R>in Java. - The return value. It is the response.
Htmlserves HTML, a bareStringserves text, anything else serves JSON.
// code/hello.js → id "hello"
export default (req, il) => {
il.log('running');
return { ok: true, name: req.str('name', 'world') };
};
What will bite you #
- A file with no handler is a library. It compiles and other units can import it, but it will not run as a page. The refusal names which kind of file it decided you had, so read it.
- Envs move forward by promotion only. Re-syncing never reaches
prod. A host pointed at the wrong env serves whatever is there with a 200 and no error anywhere.
Then read #
- The handler contract for both entry points and their refusal texts.
- Ids are paths for how a file path becomes a URL.
- Environments and promotion for the promote command.
For robots #
The unit model: what a unit is, where it lives, how it is addressed, what it must export, and what it is handed at run time.
Everything on this page was read out of interlock-java-sdk/src/main/java/sh/interlock/sdk/ and interlock-java-api/src/main/java/sh/interlock/api/service/CodeService.java. Versions in play: JDK 21 (build.gradle), Quarkus 3.15.1 (gradle.properties), GraalVM polyglot 23.1.2 (interlock-java-sdk/build.gradle).
Error text on this page is quoted verbatim from the source that throws it, so it can be pasted into a search box. Where a rule was proven by a test or a live run it is labelled MEASURED; where it follows from reading the code but has no test pinning it, INFERRED.
- What a unit is
- Environments and promotion
- Ids are paths
- Assets keep their extension
- camelCase ids resolve kebab-case
- The handler contract
- Return types and content types
- Library units
- The per-unit store
- The
ilsurface - The
reqsurface
What a unit is #
Rule. A unit of code is one file: .java, .js, .jsx, .html, .css, .md, .txt, or a binary asset. It is published with interlock sync, fetched by the host through a CodeSource, and compiled and executed in the host's own JVM. Git is the master; the server is a cache of the git tree.
The runnable language set is one constant, Engine.KNOWN_LANGUAGES:
js, jsx, java, html, css, md, txt
Engine.STATIC_LANGS is html, css, md, txt: those four are served as-is and never executed. js, jsx and java are the executable three.
Why it exists. One authority for the language list means a typo is caught at save time, where the author is, instead of at run time on a visitor's request.
Failure it prevents. A unit saved with a language nobody runs. Two different literals depending on where you hit it:
unsupported language: typescript
(from UnitBuild.check, at validation time)
cannot execute language: typescript
(from Engine.executeIn, at run time)
Example.
code/caller/UseIt.java → runnable Java unit
code/caller/panel.jsx → runnable experience
code/caller/style.css → static, served as text/css
Verify. In your own tree: every id the server holds, with the language it was recognised as. An extension the platform does not know never reaches this list.
interlock list --env dev
# each row is `id language vN`; the language column is the authority, not the file extension
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.EngineTest'
MEASURED (EngineTest.knownLanguagesIsTheSingleAuthority, EngineTest.anUnrunnableLanguageFailsLoudly).
Environments and promotion #
Rule. Every unit exists per environment: dev, staging, prod. Code moves between them by promotion, never by re-syncing. A production host pins its env (interlock.env=prod in the taskman sample, AppResource.env), so only a promotion can change what it runs.
Why it exists. A host pinned to dev serves a Studio save on the very next request. That is the right behaviour for a demo and the wrong behaviour for traffic. Pinning prod makes "what is running" a deliberate act rather than a side effect of somebody's editor.
Failure it prevents. Syncing to dev and expecting a prod-pinned host to pick it up. The host answers with CodeNotFoundException, which hosts map to 404:
no code 'caller/UseIt' in env 'prod'
The inverse failure has no error text at all, which is what makes it worse: a host left on dev silently serves unreviewed code with a 200.
Example.
interlock sync code # writes code/** to the server
interlock promote caller/UseIt --from dev # dev → staging → prod
interlock list --env prod # confirm it landed
Verify.
interlock list --env dev
interlock list --env prod
The two listings print id language vN. A unit present in dev and absent from prod has not been promoted. MEASURED (the promote route is POST /api/code/{id}/promote?from=<env>, code/assets/interlock-cli.js).
Ids are paths #
Rule. A unit's id is its path under code/, minus the extension, folders included.
code/world/WorldApi.java → id world/WorldApi
code/hello.js → id hello
The id is what every other surface addresses: il.call("world/WorldApi", …), il.url("world/WorldApi", …), interlock run world/WorldApi, and the host's HTTP passthrough. The Interlock service serves units at /run/{id}; an embedding host typically mounts its own passthrough, e.g. @Path("/app/{id:.+}") in samples/taskman/src/main/java/com/taskman/AppResource.java.
Why it exists. One name for a unit in the filesystem, in the store, in code, and in the URL. Two naming schemes would be two things to keep in step.
Failure it prevents. Calling a unit by a name that is not its path. The engine cannot invent one:
no code 'WorldApi' in env 'dev'
Example.
// in code/world/Panel.java
Object answer = il.call("world/WorldApi", Map.of("n", 21));
Verify.
interlock run world/WorldApi --env dev
MEASURED (DirectoryCodeSource.fetch maps world/WorldApi + dev to code/world/WorldApi.java, pinned by JavaSiblingTest.directoryCodeSourceServesAGitTree).
Assets keep their extension #
Rule. Text ids drop the extension. Ids under assets/ keep it.
code/assets/logo.png → id assets/logo.png → /assets/logo.png
Why it exists. If assets dropped their extension the way text units do, logo.png and logo.webp would both collapse to the id assets/logo and the second sync would silently overwrite the first.
Failure it prevents. A binary sitting outside assets/, which the CLI used to skip in silence (78 PNGs sat in a tree for weeks looking synced). It now says so, once per file:
⚠ img/logo.png is a binary file outside assets/ — not synced. Move it to assets/logo.png to serve it at /assets/logo.png
Example.
code/assets/logo.png ✅ id assets/logo.png
code/img/logo.png ❌ warned and skipped
Verify. Make the CLI tell you, in your own tree, changing nothing:
interlock sync code --dry
# every misplaced binary prints a warning naming the file; a clean tree prints none
find code/ -type f ! -path 'code/assets/*' \
! -name '*.java' ! -name '*.js' ! -name '*.jsx' ! -name '*.html' \
! -name '*.css' ! -name '*.md' ! -name '*.txt'
# expected output: nothing. Every line is a file that will be warned about and skipped.
MEASURED (the warning is emitted per file by warnMisplaced on every sync walk).
camelCase ids resolve kebab-case #
Rule. Ids keep the casing you authored them with. The Interlock service additionally stores a lowercase-kebab slug per path segment, and resolves a request by exact id first, then by slug. So examples/HelloJava is also reachable at /run/examples/hello-java.
Segment rules, from CodeService.kebab: _ and space become -, and a - is inserted before an uppercase letter that follows a lowercase letter, a digit, or an uppercase letter that itself precedes a lowercase one (so HelloJava → hello-java, WorldAPIClient → world-api-client).
Why it exists. Java wants CamelCase type names; URLs want kebab. Authoring one and addressing the other should not require two units.
Failure it prevents, and the sharp edge. Slug resolution lives in CodeService (the service), not in the SDK. DirectoryCodeSource.fetch resolves the id as a literal path with no slug fallback. A host running units off a git checkout, offline, therefore gets:
no code 'examples/hello-java' in env 'dev'
for an id that resolves fine against a real Interlock service. Address units by their authored id in code; reserve the kebab form for URLs a human types.
Example.
curl -s localhost:8090/run/examples/hello-java # service: resolves via slug
engine.run("examples/HelloJava", "dev", req); // DirectoryCodeSource: exact id only
Verify. Ask both resolvers the same question and watch them disagree. That disagreement is the rule:
# the service resolves either form
interlock run examples/HelloJava --env dev
interlock run examples/hello-java --env dev
# your host, if it runs off a git checkout with DirectoryCodeSource, resolves only the authored id
curl -s -o /dev/null -w '%{http_code}\n' localhost:8099/app/examples/HelloJava # 200
curl -s -o /dev/null -w '%{http_code}\n' localhost:8099/app/examples/hello-java # 404
MEASURED for the service path (CodeService.resolve tries id then slug). INFERRED for the DirectoryCodeSource gap: it follows from fetch building envRoot.resolve(id + "." + ext) with no fallback, and no test currently pins it.
The handler contract #
Rule.
- JS / JSX:
export default. For.jsit is(req, il) => value; for.jsxit is a React component. - Java: the class implementing
InterlockHandler<R>, whose single method isR handle(Request req, Interlock il).
Prefer a typed R (a plain POJO or record) over Object or Map. The interface between a unit and the JVM should be a dumb data class.
Why it exists. The return value is the response. A single declared entry point means the runtime never has to guess which function to call, and the type on handle is the only place the response shape is written down.
Failure it prevents. A file that looks like a unit and has no entry point:
unit 'hello' has no default export function (expected `export default (req, il) => …`)
(JS, JsRunner)
unit 'caller/UseIt' is a library — no class implements InterlockHandler, so it can be imported by other units but not run
(Java, JavaRunner.run. See Library units: for a helper this is correct, for a page it means you forgot implements InterlockHandler.)
Example.
import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;
public class UseIt implements InterlockHandler<UseIt.Out> {
public record Out(int doubled, boolean ok) { }
public Out handle(Request req, Interlock il) {
int n = req.integer("n", 21);
il.log("doubling", n);
return new Out(n * 2, true);
}
}
export default (req, il) => {
il.log('running');
return { ok: true, name: req.str('name', 'world') };
};
Verify. Run a unit that has no entry point and read the sentence. Both texts are reachable from your own project in one command each:
interlock run caller/UseIt --env dev # a handler: answers
interlock run lib/MathBox --env dev # a library: refuses, and says which kind of file it is
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test
MEASURED for the Java library refusal (JavaSiblingTest.aLibraryUnitRefusesToRunAndSaysWhy). INFERRED for the JS text: it is thrown from JsRunner but no test asserts on the sentence.
Return types and content types #
Rule. Engine.toResult shapes the handler's return value:
| returned | served as |
|---|---|
Html (Html.of("<main>…</main>")) | text/html; charset=utf-8 |
a bare String | text/plain; charset=utf-8 |
| anything else (Map, List, POJO, number, boolean) | application/json |
Why it exists. A bare String is almost always prose, not a JSON document. Quoting it into "…" would make every plain-text unit serve a JSON string that a client has to unwrap.
Failure it prevents. Returning Json.toString(map) (a String) instead of the map, then watching a browser client fail to parse a body that looks like JSON but arrives as text/plain:
SyntaxError: Unexpected token in JSON at position 0
Return the Map and let the engine serialize it.
Example.
return Map.of("ok", true); // application/json
return "queued"; // text/plain
return Html.of("<main><h1>42</h1></main>"); // text/html
Verify.
curl -sD- localhost:8090/run/caller/UseIt -o /dev/null | grep -i content-type
MEASURED (Engine.toResult, exercised by EngineTest).
Library units #
Rule. A unit with no handler is a library unit. It compiles, it is importable by other units, and it refuses to run with an error that says what a library unit is. constants.jsx is not a page, and neither is lib/MathBox.java.
Why it exists. Splitting logic into a helper should not require inventing a fake handler, and a helper accidentally exposed at a URL should fail loudly rather than answer 200 with nothing.
Failure it prevents. Requesting a helper as if it were an endpoint:
unit 'lib/MathBox' is a library — no class implements InterlockHandler, so it can be imported by other units but not run
Example.
// code/lib/MathBox.java — importable, not runnable
package lib;
public class MathBox {
public static int twice(int n) { return n * 2; }
}
lib here is just a directory somebody chose, not a reserved name: any valid Java identifier works, and it has nothing to do with the lib/main/ and lib/boot/ directories of a packaged Quarkus fast-jar, which are the host's own libraries and are never part of a unit id.
To exercise one from a test, use the reflective door rather than an HTTP call:
Class<?> c = engine.unitClass("lib/MathBox", "dev");
assertEquals(14, c.getMethod("twice", int.class).invoke(null, 7));
Engine.unitClass returns the class the id implies (lib/MathBox → lib.MathBox), falling back to the handler, then to the unit's first own class. The host cannot cast the result, because the unit is deliberately not on the host's test classpath. That is the boundary working, not a workaround.
Verify. A library unit must compile, be importable, and refuse to run. All three are checkable in your own project:
./scripts/check-units-compile.sh # it compiles
interlock run caller/UseIt --env dev # its importer runs and uses it
interlock run lib/MathBox --env dev # it refuses, naming itself a library
The refusal is the point: a helper reachable at a URL should say so rather than answer 200 with nothing.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest'
MEASURED (aLibraryUnitRefusesToRunAndSaysWhy, unitClassIsTheReflectiveDoorToALibrary).
The per-unit store #
Rule. il.store() is this unit's own key-value namespace. Reads return the value directly, with no wrapper. il.store("other/Unit") reaches another unit's namespace.
The interface (sh.interlock.sdk.Store) has five fundamentals a host must implement:
Object get(String key);
Map<String, Object> all();
void put(String key, Object value);
void remove(String key);
void clear();
and four conveniences derived from them: get(key, Class<T>), getString, getMap, getList, plus putAll(Map).
Why it exists. A store that returned Optional<Value> or {value: …} would put a wrapper in every call site of every unit for the benefit of none of them.
Failure it prevents (the sharp edge). get(String, Class<T>) is not a checked cast. Its body is (T) get(key) under @SuppressWarnings("unchecked"), so with erasure the cast happens at the assignment site, not inside the store. Ask for the wrong type and the exception names the wrong place:
java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
thrown at your String s = store.get("count", String.class); line, with nothing pointing at the store. Use getString, which goes through String.valueOf, when you want coercion rather than a cast.
getMap and getList do not have this problem: both test with instanceof and return null on a type mismatch. getList also copies into a fresh ArrayList, so mutating the result does not mutate whatever the host handed back.
Example.
const store = il.store();
store.put('count', (store.get('count') || 0) + 1);
var store = il.store();
long n = ((Number) store.get("count")).longValue();
store.put("count", n + 1);
String label = store.getString("label"); // coerces, never throws
Map<String, Object> cfg = store.getMap("config"); // null if it is not a Map
Verify. Read the interface off the SDK jar your own host ships. This is the whole surface, and the five fundamentals are the ones a custom StoreFactory must implement:
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.Store
MEASURED for the interface shape (it is the file). INFERRED for the ClassCastException site: it follows from erasure plus the unchecked cast, and no SDK test currently pins it.
The il surface #
Every method on sh.interlock.sdk.Interlock, as declared:
| call | returns | notes |
|---|---|---|
il.store() | Store | this unit's namespace |
il.store(codeId) | Store | another unit's namespace |
il.secret(name) | String | host-resolved, null when unset, never in source |
il.call(id, params) | Object | run another unit, get its raw value |
il.call(id, params, tag) | Object | run a unit with an optional phase tag in its trace |
il.withTag(tag) | Interlock | independent capability whose traced child calls inherit the tag |
il.url(id, params) | String | stable URL by id |
il.log(args…) | void | streams to a watching client while the unit runs |
il.ai() | Ai | generative AI |
il.geo() | Geo | defaulted; a host that wires nothing answers Geo.Location.UNKNOWN |
il.context() | HostContext | read-only; never null, empty when the host seeds none |
il.context(String key) | Object | one entry |
il.context(Class<T>) | T | the one a Java unit should use |
il.session(Class<T>) | T | per-request facts, null when the host passed none |
il.unitId() / il.unitEnv() / il.unitVersion() | String / String / int | this unit's own provenance |
Name a phase in traces (Java) #
Give calls a short optional customer tag so a waterfall can show the purpose of the work:
Object ranked = il.withTag("Rank candidates").call("example/rank", params);
// Equivalent one-call form:
Object rankedAgain = il.call("example/rank", params, "Rank candidates");
withTag returns an independent capability; it does not mutate il. Child local and remote traced calls inherit the tag, and a nested withTag overrides it for that branch. Parallel branches can therefore use different tags. Tags are trace metadata, never added to prompts or unit parameters. Control characters are removed, whitespace is trimmed, and tags are limited to 120 characters; an empty tag is untagged.
A tag can appear on several layers of the same operation (unit, agent, inference, model call). Those rows describe the operation's execution layers, not additional calls created by the tag. Existing callers need no changes; historical traces without a tag remain untagged.
Name the AI phase (Java) #
String explanation = il.ai().withTag("Explain choices").chat(Models.MEDIUM, prompt);
The optional tag names the phase in traces without changing the prompt, model, or response. withTag returns an independent AI capability, so concurrent calls can carry different tags. Chat and conversation calls inherit a tag from il.withTag(...); a nested AI withTag(...) overrides it. The same tag may appear on the agent, inference, and model-call rows for one logical operation.
Tags are trimmed, control characters are removed, and the length is limited to 120 characters. Empty tags and historical traces without tags remain untagged. This scope applies to chat and conversation calls only; durable submit and media calls do not propagate it.
Rule. In Java, reach context by type (il.context(MyContext.class)), not by string.
Why it exists. A string lookup returns Object, so every Java caller casts anyway. The string bought nothing except a lookup that fails at run time instead of compile time, and a ClassCastException raised at the call site rather than where the mistake was. A host seeds one object per capability, so the type already is the identity of the thing being asked for.
Failure it prevents. Two of them. The string form gives you:
java.lang.ClassCastException: class java.lang.String cannot be cast to class com.example.MyContext
at your cast, for a key that was simply misspelled. And if a host seeds two objects of the same type, context(Class) refuses rather than picking one arbitrarily:
the host seeded more than one context of type com.example.MyContext
Example.
MyContext ctx = il.context(MyContext.class); // typed, compile-checked
if (ctx == null) {
throw new Refusal(503, "unwired", "this host seeds no context of that type");
}
From JS the string form remains the natural one and is untouched: il.context().db.
Verify. Read the surface off the SDK jar your own host ships, then confirm your seed reaches a unit by the typed door:
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.Interlock | grep context
interlock run world/WorldApi --env dev
# a unit whose first line is il.context(YourContext.class) answers, or throws the 503 above
MEASURED (the duplicate-seed IllegalArgumentException is thrown in Interlock.context(Class)).
The req surface #
Every method on sh.interlock.sdk.Request, as declared:
String str(String name, String def);
String str(String name); // default overload → str(name, null)
int integer(String name, int def);
double number(String name, double def);
boolean bool(String name, boolean def);
List<String> list(String name);
boolean has(String name);
String header(String name);
String cookie(String name);
String path();
String method();
Map<String,Object> body();
String principal(); // host-defined, or null
void mark(String label); // timing/diagnostics checkpoint
Rule. req.str(name, def) reads query params AND the JSON body, and query wins.
Why it exists. It did not always. Before it did, every unit API silently ran its default branch on every POST: a well-formed request, a 200 response, the wrong code path, and nothing anywhere reporting it.
Failure it prevents. The one with no error text. That bug was found only because an end-to-end check asserted on the effect ("did the mute request actually arrive?") rather than on the status code. Assert effects, not status codes: a unit that can only answer 200 makes every "no" look like a "yes".
Example.
public Object handle(Request req, Interlock il) {
String target = req.str("target", null); // reads ?target=… or {"target": "…"}
if (target == null) {
throw new Refusal(400, "no-target", "say who");
}
return Map.of("ok", true, "target", target);
}
Verify. Send the field in the body only, and read the body of the answer. A unit that answers the default has not read the body:
curl -s -X POST localhost:8099/app/world/world-api \
-H 'content-type: application/json' -d '{"target":"bob"}' | jq .
# expect {"ok":true,"target":"bob"}, not the 400 refusal
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.RequestBodyTest'
MEASURED (4 body tests, added alongside the fix).