Workers
A worker is your code. It is an HTTP endpoint. The engine asks it before each step of a turn, and your code answers.
Use a worker to change any decision the engine would make. Run a tool, rewrite a prompt, swap the model, or pause for a person.
There is no SDK. The engine POSTs JSON and reads JSON back.
A minimal worker
The engine proposes each step. Return the proposal to accept it.
import { createServer } from "node:http";
function decide({ proposed }) {
return proposed;
}
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(decide(JSON.parse(body))));
});
});
server.listen(4444);That is a complete agent. It behaves the same as an agent with no worker.
Point an agent at the worker
[agent.oncall]
llm = "openrouter"
model = "anthropic/claude-sonnet-4-5"
worker = "http://localhost:4444"worker sets who decides. Only the agents that name a worker use one. The rest
stay with the engine, in the same project and the same file.
Run the worker in one terminal and a turn in another.
node server.mjs
subs run oncall "hi"See Local development for the local loop.
Handle one trigger at a time
Start from return proposed and handle the triggers you care about.
This worker declares a tool and runs it.
const tools = [
{
name: "get_current_time",
description: "Get the current UTC date and time.",
exec: () => new Date().toISOString()
}
];
function decide({ trigger, proposed }) {
// Add the tools to the config from the file.
if (trigger.type === "session.start") {
return {
agent: {
...proposed.agent,
tools: tools.map(({ name, description }) => ({ name, description }))
}
};
}
// Run the tool when the model calls it.
if (trigger.type === "tool.execute") {
const tool = tools.find((t) => t.name === trigger.name);
const text = tool.exec();
return { actions: [{ type: "tool.result", result: { content: [{ type: "text", text: text }] } }] };
}
return proposed;
}subs run oncall "what time is it?"The model calls the tool. Your worker runs it. The engine records the result and prompts the model again.
Verify the signature
The engine signs every decision it sends. Check the signature before you act on one.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SUBS_SIGNING_SECRET;
function verify(body, header) {
const expected = "sha256=" + createHmac("sha256", SECRET).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Refuse a request that does not match.
if (!verify(body, req.headers["x-substructure-signature"])) {
res.writeHead(401).end();
return;
}The cloud creates a signing secret for each agent that has a worker. Read it
with subs agents secret <id>. See Authentication.
Common patterns
Each of these is a branch inside decide. trigger, proposed, agent, and
state all come from the decision request.
Refuse a tool call.
if (trigger.type === "tool.execute" && trigger.name === "delete_account") {
return { actions: [{ type: "tool.error", error: "Not allowed here." }] };
}Change the model mid-conversation. Return an agent on any decision.
if (trigger.type === "client.messages" && isLongTask(trigger.messages)) {
return { ...proposed, agent: { ...agent, model: "anthropic/claude-opus-4-5" } };
}Give the model a different tool set later in the conversation.
return { ...proposed, agent: { ...agent, tools: toolsFor(state.mode) } };Remember something between turns. Write state on the response. It comes
back on every later request. See Agent state.
return { ...proposed, state: { turns: (state?.turns ?? 0) + 1 } };Ask a person. Pause the branch. The turn stays open and uses no compute. See Interrupts.
return { actions: [{ type: "interrupt", reason: "confirm", payload: { message: "Send it?" } }] };End the turn early.
return { actions: [{ type: "done" }] };Fail the turn.
return { actions: [{ type: "fail", error: { message: "no data for that account", code: "handler_error" } }] };Answer a tool later. Return an empty decision and report the result when the work finishes. See Async tools.
if (trigger.type === "tool.execute") {
startRender(trigger.id, trigger.input.value);
return {};
}Serve several agents from one worker
Every request carries agent_id. Route on it.
const decide = (req) => (req.agent_id === "poet" ? poet(req) : assistant(req));Next steps
- Tool calls: schemas, errors, and where a tool runs.
- Agent state: memory that travels with the session.
- Interrupts: pause for a person.
- Protocol: every field of the request and the response.
- Typed bindings: generate the types for your language.