Quick start

Build a chat agent, talk to it, give it a tool, pick the conversation back up, then add a sub-agent. Two terminals, no dependencies.

1. Create the worker

Create a file called server.mjs:

server.mjs
// A complete chat agent served with Node's built-in http server.
import { createServer } from "node:http";

function decide({ trigger, proposed }) {
    if (trigger.type === "session.start") {
        // The engine will use this agent config to generate proposed actions.
        return {
            agent: {
                model: "claude-haiku-4-5-20251001",
                stream: true
            }
        };
    }

    // Accept the engine's proposal for every other decision.
    return proposed;
}

const server = createServer((req, res) => {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
        const decision = decide(JSON.parse(body));
        res.writeHead(200, { "content-type": "application/json" });
        res.end(JSON.stringify(decision));
    });
});

server.listen(4444, () =>
    console.log("worker listening on http://localhost:4444"));

Start it:

node server.mjs

2. Send a message

In another terminal, install the CLI:

npm install -g @substructure.ai/cli

Send a message:

export ANTHROPIC_API_KEY=sk-ant-...
subs run \
    --worker-url http://localhost:4444 \
    --agent my-agent \
    --provider anthropic \
    --output pretty \
    --input '{"type":"client.message","message":{"role":"user","content": "hi"}}'

The reply streams to your terminal. The engine ran the loop; your worker made every decision.

3. Add a tool

Replace decide in server.mjs:

server.mjs
const tools = [
    {
        name: "get_current_time",
        description: "Get the current UTC date and time.",
        exec: () => new Date().toISOString()
    }
];

function decide({ trigger, proposed }) {
    if (trigger.type === "session.start") {
        return {
            agent: {
                model: "claude-haiku-4-5-20251001",
                stream: true,
                tools: tools.map(({ name, description }) => ({ name, description }))
            }
        };
    }

    // Run our tool when the model calls it.
    if (trigger.type === "tool.execute") {
        const tool = tools.find((t) => t.name === trigger.name);
        return { actions: [{ type: "tool.result", result: tool.exec() }] };
    }

    // Accept the engine's proposal for every other decision.
    return proposed;
}

Restart the worker and ask a question that needs the tool:

subs run \
    --worker-url http://localhost:4444 \
    --agent my-agent \
    --provider anthropic \
    --output pretty \
    --input '{"type":"client.message","message":{"role":"user","content": "what time is it?"}}'

The model calls the tool, your worker runs it, and the engine folds the result back into the conversation.

4. Continue the conversation

Each run so far started a new session. After every run, the CLI prints the command that continues it:

continue this session with:
  subs run --session <session-id> --worker-url http://localhost:4444 ...

Run it with a follow-up question:

subs run \
    --session <session-id> \
    --worker-url http://localhost:4444 \
    --agent my-agent \
    --provider anthropic \
    --output pretty \
    --input '{"type":"client.message","message":{"role":"user","content": "what was my first question?"}}'

The agent remembers. The whole session is persisted in data.db: messages, tool calls, results. Stop everything, come back tomorrow, and it still resumes.

5. Add a sub-agent

One worker can host several agents, told apart by agent_id. Replace the agent code in server.mjs (everything above createServer):

server.mjs
const tools = [
    {
        name: "get_current_time",
        description: "Get the current UTC date and time.",
        exec: () => new Date().toISOString()
    }
];

function assistant({ trigger, proposed }) {
    if (trigger.type === "session.start") {
        return {
            agent: {
                model: "claude-haiku-4-5-20251001",
                stream: true,
                tools: tools.map(({ name, description }) => ({ name, description })),
                sub_agents: [{ id: "poet", description: "Writes a haiku on any topic." }]
            }
        };
    }

    if (trigger.type === "tool.execute") {
        const tool = tools.find((t) => t.name === trigger.name);
        return { actions: [{ type: "tool.result", result: tool.exec() }] };
    }

    return proposed;
}

function poet({ trigger, proposed }) {
    if (trigger.type === "session.start") {
        return {
            agent: {
                model: "claude-haiku-4-5-20251001",
                stream: true,
                system: "You are a poet. Respond with a single haiku."
            }
        };
    }

    return proposed;
}

// One worker, two agents told apart by agent_id.
function decide(req) {
    return req.agent_id === "poet" ? poet(req) : assistant(req);
}

The sub-agent appears to the model as a tool. Restart the worker and start a conversation that uses both:

subs run \
    --worker-url http://localhost:4444 \
    --agent my-agent \
    --provider anthropic \
    --output pretty \
    --input '{"type":"client.message","message":{"role":"user","content": "what time is it? have the poet write a haiku about it"}}'

The assistant calls your tool, delegates to the poet, and folds the haiku into its answer. The poet ran in its own session, and its cost and token usage rolled up to the parent.

Next