Start here #
Raw markdown: /start.md
This page takes you from nothing to a unit running inside your own application. It is in two parts, and they are very different sizes.
Part 1 takes a minute. You write a file and call it.
Part 2 takes an afternoon. You wire the SDK into your own application so units run in your JVM, against your classes and your data. Most of that time is your build file, not our API.
Do them in that order. Wiring the host first leaves you debugging two unfinished things at once.
Part 1: your first unit #
Install the CLI #
curl -fsSL https://interlock.sh/assets/install.sh | bash
One file, no package manager: the script (itself an Interlock unit) downloads the CLI from /assets/interlock-cli.js and puts interlock on your PATH. Node 18+ is the only requirement — the installer checks and says so if it is missing.
Create the project #
interlock init
This signs you in, writes a .interlock-key into the directory, and installs the Interlock skills into your AI tools. The key binds this tree to one project on one server, and the CLI walks up from the working directory to find it the same way git finds .git.
Write a file #
Ids are paths. A file at code/hello.js has the id hello.
export default (req, il) => {
const name = req.str('name', 'world');
il.log('greeting ' + name);
return { ok: true, msg: 'hello ' + name };
};
The default export is the handler. What you return becomes the response: an object is JSON, a string is text/plain, and html(...) serves markup. A file with no handler is a library unit, which other units can import but nobody can call as a page.
Publish it and call it #
interlock sync code
interlock run hello
{ "ok": true, "msg": "hello world" }
Now change the message in the file, run interlock sync code again, and call it again. That is the whole loop, and it is the thing worth feeling before you read anything else.
For a tighter loop, interlock yolo code mirrors the directory in both directions continuously. Read sync versus yolo before you use it on a tree you care about.
Java works the same way #
A class instead of a function:
package examples;
import sh.interlock.sdk.*;
public class HelloJava implements InterlockHandler<Greeting> {
@Override
public Greeting handle(Request req, Interlock il) {
return new Greeting(true, "hello " + req.str("name", "world"));
}
}
A Java unit's package is its directory, exactly as its id is its URL. So code/examples/HelloJava.java declares package examples;. Getting this wrong is refused at compile time with a sentence that says so. See Java units.
Prefer a typed return over Object or Map. The interface between a unit and your JVM should be a dumb POJO.
Moving code towards production #
Environments are dev, then staging, then prod, and code moves between them by promotion, never by re-syncing:
interlock promote hello --from dev
A production host pins prod, so only a promotion changes what it runs. That is the entire safety story for hot-deployable code, and it is why a careless sync cannot reach production on its own.
Part 2: running units inside your own application #
Everything above ran on an Interlock server. This part is what makes units useful: the same unit, running in your process, able to call your classes and read your database.
1. Add the dependency #
dependencies {
implementation 'sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT'
}
<dependency>
<groupId>sh.interlock</groupId>
<artifactId>interlock-java-sdk</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
While the version carries -SNAPSHOT it is not on Maven Central, and there is no public artifact yet. Ask whoever operates your Interlock server which coordinate to use and where to resolve it from: either an internal Maven repository they publish to, or a checkout of the Interlock repository you build yourself. With a checkout that is one command, and then your build resolves it out of your local repository:
./gradlew :interlock-java-sdk:publishToMavenLocal
repositories { mavenLocal(); mavenCentral() }
2. The whole build file #
This is the entire file rather than the Interlock fragment, because a fragment is not a build. Two separate acceptance runs lost most of their time here, reinventing roughly 130 lines that nobody had written down.
plugins {
id 'java'
id 'io.quarkus' version '3.15.1'
}
repositories { mavenLocal(); mavenCentral() } // mavenLocal FIRST: the SDK is a -SNAPSHOT
dependencies {
implementation enforcedPlatform('io.quarkus.platform:quarkus-bom:3.15.1')
implementation 'sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT'
implementation 'io.quarkus:quarkus-arc'
implementation 'io.quarkus:quarkus-rest' // the JAX-RS stack the /app passthrough assumes
implementation 'io.quarkus:quarkus-rest-jackson'
implementation 'io.quarkus:quarkus-hibernate-orm-panache'
implementation 'io.quarkus:quarkus-jdbc-postgresql' // or your driver
implementation 'io.quarkus:quarkus-narayana-jta' // units may open their own transaction
implementation 'io.quarkus:quarkus-smallrye-health' // /q/health, which /verify step 1 curls
}
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
// REQUIRED. Units are compiled at RUNTIME against the packaged application. If code/ leaks into a
// sourceSet, Gradle compiles them at BUILD time against the untransformed classpath instead, which
// silently defeats everything on the Quarkus page: entities have not been rewritten yet, so a unit
// that is wrong in production compiles clean here.
sourceSets { main { java { srcDirs = ['src/main/java'] } } }
settings.gradle needs the plugin repository or id 'io.quarkus' will not resolve:
pluginManagement {
repositories { gradlePluginPortal(); mavenCentral() }
}
rootProject.name = 'my-host'
Where code/ lives. At the repository root, a sibling of build.gradle, and never under src/. The gate script assumes it (UNITS=code), the CLI assumes it, and the sourceSets line above is what keeps Gradle out of it.
Which JAX-RS stack. quarkus-rest, which is RESTEasy Reactive. The /app passthrough on Embedding the SDK is written against it, and a String body method parameter behaves differently on the classic stack.
3. Build the Engine #
InterlockSDK.init takes your SDK key and returns a builder. It is fluent, and build() returns the Engine:
Engine engine = InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY"))
.codeSource(new InterlockClient(url, key))
.stores(myStores)
.secrets(mySecrets)
.context("app", myContext) // your own class, whatever it is
.build();
There are three init overloads and none of them takes a configuring lambda: init(String sdkKey), init(String sdkKey, Map<String, Object> context), and init(String sdkKey, ContextProvider context).
Use build() rather than constructing new Engine(...) yourself. build() is what auto-anchors your context classes for javac. Skip it and units fail to compile with package com.example.billing does not exist.
An anchor is how javac finds your host's classes: the anchor's protection-domain code source is the host classpath entry, and registering one also reserves its package against units declaring into it. You do not normally call Engine.addCompileAnchor yourself, because build() anchors every class you seeded as context. Call it by hand only when you use a dynamic ContextProvider, or when units name a type you never seed. Full wiring on Embedding the SDK.
Packages, because getting these wrong costs you a compile: sh.interlock.sdk holds InterlockSDK, Request, Interlock, Refusal, Html and Json. Engine and Result are in sh.interlock.sdk.runtime, along with CodeSource, DirectoryCodeSource, StoreFactory, SecretResolver and the exception types. The full table is on Reference.
4. Which secret is which #
Five names, and mixing them up fails quietly, so keep them straight:
| Name | What it is | Who reads it |
|---|---|---|
INTERLOCK_SDK_KEY | your HOST's key, minted per project | InterlockSDK.init(...) in your application |
.interlock-key | the same kind of key, written into a project directory by interlock init | the CLI, so it knows which project a tree belongs to |
INTERLOCK_URL | which Interlock server to talk to | both the SDK and the CLI |
INTERLOCK_ENV | which env the SDK fetches units from | the SDK |
interlock.env | the same choice, as Quarkus config | your host, if you prefer properties to environment |
The host key and the CLI key are the same kind of secret and can be the same value. Set INTERLOCK_ENV (or interlock.env) deliberately: pointing a production host at dev is silent and serves the wrong code.
What will bite you #
Three things, in the order people meet them.
The project key binds the tree. .interlock-key ties that directory to one project on one server. Run the CLI in a tree you copied and you publish into somebody else's project. Read Operating a project before your first sync.
Quarkus rewrites your entities after your build. If your host is Quarkus and your units touch Panache entities, read Quarkus before you write the first one. Quarkus builds every entity twice and the JVM loads the rewritten copy. A direct field read compiles clean and then dies on the first real request:
java.lang.IllegalAccessError: tried to access protected field com.example.billing.Note.uuid
The rule: give unit-facing entities private fields with hand-written accessors, and read them through those accessors, including the identity field. A field literally named id keeps its public modifier and so appears to work, which is exactly why this hides. Name it uuid, ref or hash and it is privatized like anything else. Full table, the javap commands that measured it, and the mechanism: the entity rule.
A 200 proves nothing. It proves your request was accepted, not that it did anything. Assert effects, not status codes, and read the body back. That distinction is the only reason a bug where every POST body was silently ignored was ever found.
Before you call it working #
Run the verification sequence. It is eight commands and it checks the things that fail quietly rather than the things that fail loudly.
If you are an agent rather than a person: read /llms-full.txt instead of clicking through these pages. It is this page and every other one, in a single fetch, in dependency order.