Documentation

TokenForge API docs

Every factory you build is also a program. This page covers running blueprints headless from Node, the blueprint JSON format, the HTTP API and CLI, bring-your-own-key providers, and a reference for all 43 buildings โ€” generated straight from the engine source.

On this page โ–ธ

01 ยท Quickstart

Two ways in

Play in the browser

Open play.html. No account, no key, no build step โ€” the deterministic Mock LLM ships in the box, so every machine works offline. Add a real API key later in the settings panel if you want live models.

Run headless in Node

The whole engine is plain ES modules with zero dependencies. Clone the repo, save the script below in the repo root, and run it โ€” a small factory is built inline, fed two inputs, and run to completion:

forge-demo.mjs

// forge-demo.mjs โ€” run a TokenForge factory headless. No deps, no build step.
// Usage: node forge-demo.mjs   (run from the tokenforge/ repo root)
import { runBlueprint } from './src/engine/api.js';

// inbox โ†’ belt โ†’ Prompt Forge (2ร—2) โ†’ delivery, all facing East.
const bp = {
  v: 1, name: 'shout-machine', w: 12, h: 6,
  entities: [
    { t: 'inbox',    x: 1, y: 2, r: 1, c: { name: 'in' } },
    { t: 'belt',     x: 2, y: 2, r: 1 },
    { t: 'llm',      x: 3, y: 2, r: 1, c: { prompt: 'Uppercase this: {{input}}' } },
    { t: 'belt',     x: 5, y: 2, r: 1 },
    { t: 'delivery', x: 6, y: 2, r: 1, c: { name: 'out' } },
  ],
};

const result = await runBlueprint(bp, {
  inputs: { in: ['hello world', 'the factory must grow'] },
  ticks: 300,   // hard cap; stops early once the line settles
  seed: 42,     // deterministic MockLLM + rng
});

console.log(result.outputs.out);   // raw payloads: ['HELLO WORLD', ...]
console.log(result.text.out);      // stringified:  same, JSON for objects
console.log(result.ticks, 'ticks,', result.deliveries.length, 'deliveries');
$ node forge-demo.mjs
[ 'HELLO WORLD', 'THE FACTORY MUST GROW' ]
[ 'HELLO WORLD', 'THE FACTORY MUST GROW' ]
38 ticks, 2 deliveries

No provider was configured, so the Mock LLM handled the Prompt Forge โ€” its uppercase rule matched the prompt. Swap in a real model via the provider config without touching the blueprint.

02 ยท Headless JS API

src/engine/api.js

One import gives you everything:

import { runBlueprint, createSim, Sim, MockLLM, load, serialize, makePacket, flush }
  from './src/engine/api.js';

runBlueprint(bp, opts) โ†’ Promise<result>

Loads a blueprint, feeds queued inputs to inbox buildings, ticks the sim until it settles (or the tick cap), and collects everything that landed in delivery buildings.

OptionDefaultMeaning
inputs{}Map of inbox name โ†’ array of payloads, e.g. { in: ['a', 'b'] }. Each value is queued for inbox buildings whose config.name matches.
ticks300Maximum ticks to run. The run stops early after 30 quiet ticks (settleTicks) with no new deliveries, nothing in flight, and all input queues drained.
llmnullAn LLM provider (anything with complete(req)). Defaults to a fresh MockLLM.
seed1234Seed for the sim's deterministic RNG.
services{}Extra services merged into sim.services.
settleTicks30Quiet-tick threshold for early stop.

The resolved result object:

FieldShapeMeaning
outputs{ [name]: payload[] }Raw payloads per delivery name (strings, numbers, or objects).
text{ [name]: string[] }Same as outputs but every payload stringified (objects via JSON.stringify).
deliveries[{ tick, name, packet, entId }]Full delivery records with complete packet objects ({id, type, payload, meta}) โ€” metadata included.
ticksnumberHow many ticks actually ran (sim.t).
events[{ tick, type, ... }]Rolling event log (last 500): deliver, log, error.

createSim(bp, {llm, seed, services}) โ†’ Sim

When you want to drive the simulation yourself โ€” stepping tick by tick, subscribing to events, feeding inputs mid-run โ€” build a Sim instead:

import { createSim, MockLLM, flush } from './src/engine/api.js';

const sim = createSim(bp, { llm: new MockLLM(), seed: 7 });

sim.on('deliver', ({ name, packet }) => console.log('deliver:', name, packet.payload));
sim.pushInput('in', 'tick by tick');   // queue a payload for inbox name 'in'

for (let i = 0; i < 20; i++) { sim.tick(); await flush(); }
// ...or simply: await sim.run(20)  โ€” ticks and flushes async jobs for you

sim.deliveries        // [{ tick, name, packet, entId }, ...]
sim.outputs('out')    // packets delivered under a given name
sim.events            // rolling log of deliver/log/error events

Sim essentials:

  • sim.tick() โ€” one synchronous step: machines tick โ†’ output buffers drain (1 packet per port per tick) โ†’ belts move. Async work (LLM calls, scripts) runs between ticks; call await flush() after each tick, or use await sim.run(n) which does both.
  • sim.pushInput(name, payload) โ€” queue a payload; the next free inbox with that config.name emits it as a packet.
  • sim.deliveries โ€” everything delivery buildings received, in order.
  • sim.on('deliver', fn) โ€” subscribe to events; also 'log' and 'error'. Handlers get the event data ({name, packet, ent} for deliveries).
  • sim.place(type, x, y, rot, config) โ€” add a building programmatically; serialize(sim, name) turns the grid back into blueprint JSON.

MockLLM โ€” the deterministic provider

src/engine/mockllm.js exports a fully offline "language model" that powers the game with no key and makes every test reproducible. It implements the same interface as real providers: await provider.complete(req) โ†’ { text, toolCalls?, usage } where req is { system?, prompt?, input?, messages?, tools?, temperature?, maxTokens?, model? }.

How a rule fires: the mock lowercases system + prompt and scans it for instruction keywords; the text it transforms is req.input if set, else the last user message, else the prompt itself. Rules are checked in this exact order โ€” first match wins:

Instruction matchesResponse
uppercase / upper case / all capsInput uppercased.
lowercase / lower caseInput lowercased.
reverseInput with characters reversed.
pirateArr! <input> โ€” yo ho ho!
translate โ€ฆ french / in frenchWord-by-word translation from a 21-word built-in dictionary (helloโ†’bonjour, worldโ†’monde, catโ†’chat, โ€ฆ); unknown words pass through.
emojiInput plus one emoji, hash-picked from a set of 8 (same input โ†’ same emoji).
sentiment / positive or negativepositive, negative, or neutral by counting keyword hits from built-in positive/negative word lists.
classify / categorโ€ฆ / which of the following / choose one ofLooks for Categories: / Options: / Labels: followed by a comma- or pipe-separated list in the prompt, then picks the option with the best word overlap with the input; zero overlap โ†’ deterministic hash pick. (No list found โ†’ falls through to later rules.)
score/rate/judge/grade plus a range like 0-10 or 1 to 100A deterministic hash score 0..10 as text.
summarโ€ฆFirst sentence of the input, truncated to 12 words with โ€ฆ if longer.
haiku / poemA three-line haiku template seeded with the input's first words.
json / extractJSON string: { text, words, emails, numbers } โ€” first 60 chars, word count, and any emails/numbers found in the input.
name / titleA hash-picked two-word name like Swift Fox or Crimson Anvil.
(nothing matched)[mock] <input> โ€” a dependable echo so pipelines always keep flowing.

Tool-call heuristic

When req.tools is non-empty, the mock behaves like a one-shot agent and the rules above are skipped:

  • First round (no role:'tool' messages yet): it scores every tool by word overlap between the tool's name + description and the task text, and returns { text: '', toolCalls: [{ name, args }] } for the best match. Arguments are filled heuristically from the input: number/integer params get numbers parsed out of the input (in order, 0 when exhausted); every other param gets the full input string.
  • After tool results (any role:'tool' messages present): it answers Result: <tool contents joined by '; '> โ€” a plain final answer, no further tool calls.

Scripted mode & call log

For exact tests, queue canned responses โ€” they're consumed in order before any rules run:

const mock = new MockLLM();          // new MockLLM({ latencyMs: 50 }) to simulate latency
mock.script([
  { text: '', toolCalls: [{ name: 'calc', args: { expr: '1+1' } }] },
  { text: 'The answer is 2.' },
]);
// each scripted response is returned as { usage: {in:10, out:10}, ...yourFields }

const result = await runBlueprint(bp, { llm: mock });
mock.calls   // every request object the mock received โ€” assert on prompts, tools, messages

In rules mode, usage is derived from real word counts ({ in: promptWords + 10, out: responseWords }). Once the script queue empties, the mock falls back to rules.

03 ยท Blueprint format

Blueprint JSON

A blueprint is the serialized factory โ€” what the game saves, what runBlueprint loads, and what you POST to the HTTP API:

{
  "v": 1,                    // format version โ€” must be 1
  "name": "shout-machine",  // display name
  "w": 12, "h": 6,           // grid size (defaults 48ร—32 when omitted)
  "entities": [
    { "t": "inbox",    "x": 1, "y": 2, "r": 1, "c": { "name": "in" } },
    { "t": "belt",     "x": 2, "y": 2, "r": 1 },
    { "t": "case",     "x": 3, "y": 2, "r": 1, "c": { "mode": "upper" } },
    { "t": "delivery", "x": 4, "y": 2, "r": 1, "c": { "name": "out" } }
  ]
}

Each entity: t = building id (see the reference), x, y = top-left grid tile, r = rotation (defaults to 1/East), c = config overrides merged over the building's defaults. Tiles hold at most one entity; multi-tile buildings (Prompt Forge and Agent Core are 2ร—2) occupy their whole footprint.

Rotation

r encodes the facing direction: 0=N, 1=E, 2=S, 3=W. Port sides in building definitions are declared for the default facing rot=1 (East) โ€” "W in, E out" reads left-to-right โ€” and the engine rotates them with the entity. Where a declared side ends up:

Declared side (at rot=1 E)r=0 (N)r=1 (E)r=2 (S)r=3 (W)
E โ€” front (usually out)NESW
W โ€” back (usually in)SWNE
N โ€” left flankWNES
S โ€” right flankESWN

Belts move packets one tile per tick toward their own r. A belt hands into a machine only if the machine has an in-port on the facing side; machines butt-joined port-to-port skip belts entirely.

Example files in examples/*.json are blueprints plus top-level title, description, inputs (sample inputs), and expect (assertions for tests) โ€” the blueprint fields above are a strict subset, so they load anywhere a blueprint does.

04 ยท HTTP API

Factory as a service

The bundled server serves the game and a JSON API from one process:

node server/serve.js
# โ†’ serving on http://localhost:8123  (set PORT to change)

It serves the repo root as static files with correct MIME types (html, js, css, json, svg, png), is path-traversal safe, and mounts the API below. All /api/* routes send Access-Control-Allow-Origin: * and answer OPTIONS preflights, so you can call them from any web page. Errors come back as JSON โ€” the server never crashes on bad input.

GET/api/health

Liveness probe: version and building count.

$ curl http://localhost:8123/api/health
{"ok":true,"version":"0.1.0","buildings":43}
GET/api/buildings

The full machine catalog straight from the engine's registered definitions โ€” an array of { id, label, icon, cat, desc, size, ports, configSchema, defaults, unlockTier }. Everything the building reference below shows, as JSON.

$ curl -s http://localhost:8123/api/buildings
[{"id":"belt","label":"Belt","icon":"โžก๏ธ","cat":"core", ...}, ...]
GET/api/examples

The manifest of shipped example factories (examples/index.json). Returns [] if no manifest is present.

$ curl http://localhost:8123/api/examples
POST/api/run

Run a factory server-side with runBlueprint. The body takes either an inline blueprint or the filename of a shipped example:

Body fieldTypeMeaning
blueprintobjectAn inline {v:1, ...} blueprint. Mutually exclusive with example.
examplestringA shipped example file, e.g. "hello-forge.json".
inputsobjectOptional { name: [values] } queued into matching inbox buildings.
ticksnumberOptional tick cap. Default 300, max 2000.
seednumberOptional RNG seed for reproducible runs.
providerobjectOptional LLM provider: { kind: 'mock' | 'openai' | 'anthropic', baseUrl?, apiKey?, model? }. Default mock. Non-mock kinds are built via makeProvider from src/engine/providers.js.

On success โ€” 200 with the runBlueprint result (events trimmed to the last 50):

{ "ok": true, "outputs": {...}, "text": {...}, "ticks": 38,
  "deliveries": [{ "tick": 19, "name": "out", "packet": {...} }, ...],
  "events": [ ...last 50... ] }

On an invalid blueprint, unknown example, or malformed JSON โ€” 400 with { "ok": false, "error": "..." }.

Full run with an inline blueprint:

curl -s http://localhost:8123/api/run \
  -X POST -H 'content-type: application/json' \
  -d '{
    "blueprint": {
      "v": 1, "name": "shout-machine", "w": 12, "h": 6,
      "entities": [
        { "t": "inbox",    "x": 1, "y": 2, "r": 1, "c": { "name": "in" } },
        { "t": "belt",     "x": 2, "y": 2, "r": 1 },
        { "t": "llm",      "x": 3, "y": 2, "r": 1, "c": { "prompt": "Uppercase this: {{input}}" } },
        { "t": "belt",     "x": 5, "y": 2, "r": 1 },
        { "t": "delivery", "x": 6, "y": 2, "r": 1, "c": { "name": "out" } }
      ]
    },
    "inputs": { "in": ["hello world"] },
    "ticks": 300,
    "seed": 42
  }'

Run a shipped example with your own inputs:

curl -s http://localhost:8123/api/run \
  -X POST -H 'content-type: application/json' \
  -d '{ "example": "hello-forge.json",
       "inputs": { "in": ["good morning, friend"] },
       "ticks": 400 }'

Run against a real model by adding a provider (see the security notes before doing this anywhere public):

  ...
  "provider": { "kind": "openai", "apiKey": "sk-...", "model": "gpt-4.1-mini" }
  ...

05 ยท CLI

server/cli.js

Run factories and inspect the catalog from a terminal:

# run a blueprint / example file, print the outputs
node server/cli.js run examples/hello-forge.json --ticks 300

# list every registered building
node server/cli.js buildings

06 ยท Providers

Bring your own key

src/engine/providers.js exports makeProvider(cfg); every provider implements the same complete(req) interface as MockLLM, tool calls included. Plain fetch, no SDKs:

ConfigTalks to
{ kind: 'mock' }The offline MockLLM. No network, no key.
{ kind: 'openai', baseUrl?, apiKey, model }Any OpenAI-compatible /chat/completions endpoint. baseUrl defaults to https://api.openai.com/v1 โ€” point it at https://openrouter.ai/api/v1 for OpenRouter, or your local Ollama, etc.
{ kind: 'anthropic', apiKey, model, baseUrl? }Anthropic's /v1/messages API. baseUrl defaults to https://api.anthropic.com.
import { makeProvider } from './src/engine/providers.js';
import { runBlueprint } from './src/engine/api.js';

const llm = makeProvider({
  kind: 'openai',
  baseUrl: 'https://openrouter.ai/api/v1',   // optional; default api.openai.com/v1
  apiKey: process.env.OPENROUTER_API_KEY,
  model: 'moonshotai/kimi-k2-thinking',
});

const result = await runBlueprint(bp, { inputs: { in: ['bonjour'] }, llm });

Where keys live

  • In the browser, your key is stored in localStorage and the page calls the provider directly โ€” there is no TokenForge server in the middle, no telemetry, no account. The Anthropic provider sends the anthropic-dangerous-direct-browser-access: true header, which is what Anthropic requires for direct browser calls; the name is a reminder that a key embedded in a page you share is a key you've given away.
  • Server-side (POST /api/run), the key travels in the request body's provider field and exists only for that run โ€” the server stores nothing.
Never expose your key on a public server. Anyone who can reach a /api/run endpoint can pass their own inputs โ€” and anyone who can read your request body or page source can take the key with them. Keep keyed servers on localhost, use per-project keys with hard spending caps, and leave public deployments on the default mock provider.

07 ยท Building reference

All 43 buildings

Generated from the engine's registered definitions (src/engine/buildings/). Port sides are declared for facing East (rot=1) โ€” see the rotation table. "Key config" lists each building's default-config keys; full schemas via GET /api/buildings. Tier is the campaign unlock tier (0 = available from level 1).

Core (7)

idBuildingDescriptionPortsKey configTier
โžก๏ธbeltBelt1ร—1Carries one packet per tile. Packets move one tile per tick when the next tile is free.0
โ›ฒsourceText Well1ร—1Emits a configured text packet on a timer.out: out:Etext, every, limit0
๐Ÿ“ฅinboxAPI Inbox1ร—1Emits packets pushed from outside (headless API / HTTP server / tests).out: out:Ename3
๐Ÿ“ฆdeliveryDelivery Depot1ร—1Accepts packets and records them as factory output. Levels check goals here.in: in:Wname0
๐Ÿ”ฅtrashIncinerator1ร—1Destroys packets. Every factory needs a place for its mistakes.in: in:W, in2:N, in3:S0
๐ŸชงdisplaySignboard1ร—1Shows the last packet that passed through, then forwards it.in: in:W / out: out:E0
๐Ÿ—’๏ธnoteNote1ร—1A sticky note on the factory floor. Purely decorative.text0

Text (10)

idBuildingDescriptionPortsKey configTier
๐Ÿ” caseCase Forge1ร—1Hammers text into UPPER, lower, or Title Case.in: in:W / out: out:Emode1
โœ‚๏ธtrimTrimmer1ร—1Shaves whitespace off both ends; optionally collapses inner runs to single spaces.in: in:W / out: out:Ecollapse1
๐Ÿ”stampStamper1ร—1Presses a prefix and suffix around every passing payload.in: in:W / out: out:Eprefix, suffix1
๐Ÿ”reverseReverser1ร—1Flips text end over end, character by character.in: in:W / out: out:E1
๐ŸชšsplitSplitter Saw1ร—1Saws text apart on a delimiter and feeds the pieces out one per tick.in: in:W / out: out:Edelimiter1
๐Ÿ“ƒlinesLine Saw1ร—1Saws text into its lines and feeds them out one per tick.in: in:W / out: out:E1
๐ŸงดjoinGluer1ร—1Collects incoming packets and glues them into one, on count or after a quiet spell.in: in:W / out: out:Ecount, delimiter, flushAfter2
๐ŸงฉtemplateTemplater1ร—1Fills {{a}}, {{b}}, {{c}} slots from three in-ports, then emits the assembled text.in: a:W, b:N, c:S / out: out:Etemplate2
๐ŸŒ€regexRegex Lathe1ร—1Turns text on a regex: replace it, extract a match, or test-route pass/fail.in: in:W / out: out:E, fail:Smode, pattern, flags, replacement2
๐Ÿ”ขcountCounter1ร—1Tallies words, characters, or lines and emits the number (source text kept in meta).in: in:W / out: out:Eunit1

Flow (9)

idBuildingDescriptionPortsKey configTier
๐ŸชžcopierCopier1ร—1Duplicates each packet: one copy out the front, one out the side.in: in:W / out: out:E, copy:S1
โš–๏ธbalancerBalancer1ร—1Splits a stream round-robin across two outputs; spills to the free side when one backs up.in: in:W / out: a:E, b:S2
๐Ÿซ™mergerFunnel1ร—1Merges two streams into one, alternating fairly between inputs.in: a:W, b:N / out: out:E1
๐ŸšฆgateGate1ร—1Passes packets while open. Control packets on the top port open (truthy) or close (falsy) it.in: in:W, ctrl:N / out: out:Einvert2
๐Ÿ›ค๏ธrouterSwitch Yard1ร—1Routes packets that match a rule out the front; everything else out the side.in: in:W / out: match:E, else:Smode, value2
๐ŸŒdelaySlow Roller1ร—1Holds each packet for N ticks before rolling it onward. Holds up to 4 at once.in: in:W / out: out:Eticks1
๐ŸฌbufferWarehouse1ร—1FIFO storage: absorbs bursts up to its size, drains one packet per tick.in: in:W / out: out:Esize1
๐Ÿฅ„samplerSampler1ร—1Passes every Nth packet; the rest are quietly incinerated (tallied in state.dropped).in: in:W / out: out:En2
๐ŸšฐthrottleValve1ร—1Lets at most one packet through every N ticks. The rest queue up behind it.in: in:W / out: out:Eticks2

Data (9)

idBuildingDescriptionPortsKey configTier
โš—๏ธjsonparseJSON Smelter1ร—1Smelts raw text into a json packet via JSON.parse. Slag (unparseable text) exits the error chute.in: in:W / out: out:E, err:S2
โ›๏ธjsongetExtractor1ร—1Mines a value out of a json packet by path, e.g. a.b[0].c. Misses fall back or get dropped.in: in:W / out: out:Epath, fallback2
๐Ÿ—๏ธjsonbuildAssembler1ร—1Stamps each packet into a JSON template โ€” every {{input}} in the template becomes the packet text.in: in:W / out: out:Etemplate3
๐ŸŒพcsvCSV Thresher1ร—1Threshes CSV text into one json packet per row โ€” objects when the first row is a header.in: in:W / out: out:Eheader2
๐ŸงฎmathMath Mill1ร—1Grinds packets through an arithmetic expression. x = numeric payload, len = text length.in: in:W / out: out:Eexpr2
โš–๏ธcompareComparator1ร—1Weighs each packet against a value; winners exit east, losers drop south.in: in:W / out: pass:E, fail:Sop, value2
๐Ÿ›ข๏ธaccumulateSilo1ร—1Hoards packets until the count is reached, then dumps one combined packet (collect / sum / concat).in: in:W / out: out:Emode, count, delimiter3
๐Ÿ‘ฏdedupeDeduper1ร—1Lets each payload through once; repeats go to the scrap heap. Limited memory, oldest forgotten first.in: in:W / out: out:Ememory2
๐Ÿ“ŠtallyTally Board1ร—1Chalks up how often each payload has come through; emits {key, count} for every packet.in: in:W / out: out:E3

LLM (4)

idBuildingDescriptionPortsKey configTier
๐Ÿ”ฎllmPrompt Forge2ร—2Runs each packet through an LLM prompt template and emits the reply.in: in:W / out: out:Esystem, prompt, model, temperature2
๐Ÿง™classifySorting Sage1ร—1Asks an LLM to classify each packet and routes it out the matching port.in: in:W / out: a:E, b:S, c:Nlabels, model3
โš–๏ธjudgeJudge1ร—1Scores each packet against a rubric and emits the number.in: in:W / out: out:Erubric, scale, model3
โš—๏ธdistillDistiller1ร—1Boils text down to structured JSON via an LLM. Unparseable replies pass through as text.in: in:W / out: out:Einstructions, model3

Script (1)

idBuildingDescriptionPortsKey configTier
๐Ÿ“œscriptScript Engine1ร—1Runs your JavaScript on every packet. `return` sends out the front; emit(x, "alt") drops out the side hatch. `state` persists between packets.in: in:W / out: out:E, alt:Scode, timeoutMs3

Agent (3)

idBuildingDescriptionPortsKey configTier
๐Ÿค–agentAgent Core2ร—2Runs a tool-calling loop on task packets. Docks with adjacent Toolboxes, Memory Shelves, and other Agent Cores (sub-agents).in: task:W / out: result:Ename, system, maxSteps, model4
๐ŸงฐtoolToolbox1ร—1A scripted tool for adjacent Agent Cores. Inside the code, `args` holds the parsed tool arguments; return the result.name, description, params, code4
๐Ÿ—„๏ธmemoryMemory Shelf1ร—1Labeled jars for agent memories. Adjacent Agent Cores can set, get, and list values that persist across tasks.name4

08 ยท Agents & tools

Agents are built out of bricks

The Agent Core (agent, 2ร—2) runs a tool-calling loop on each packet arriving at its task port and emits the final answer from result. What makes it a factory game: its tools are discovered by physical adjacency. When a task arrives, the agent scans every building touching its footprint (sim.grid.neighborsOf(ent)) and collects:

  • Any building whose definition has a tool property โ€” in the shipped set that's the Toolbox (tool) and the Memory Shelf (memory), but any building def can opt in.
  • Adjacent Agent Cores, exposed as delegable sub-agent tools named agent_<name> with a single task string parameter. Calling one runs that agent's own loop (with its adjacent tools) and returns its final text. Delegation depth is capped at 3.

The tool def contract

A building becomes agent-callable by declaring tool(ent, sim) on its definition, returning:

{
  name: 'shout',                    // tool name the model sees
  description: 'shout the input',  // the model reads this to pick tools
  parameters: { type: 'object', properties: { input: { type: 'string' } } },  // JSON Schema
  run: async (args) => 'result string',
}

The Toolbox fills this contract from its config: name, description, params (JSON Schema, as an object or a JSON string), and code โ€” a sandboxed JS body where args holds the parsed tool arguments and the return value becomes the tool result (1000 ms timeout). The Memory Shelf exposes one tool with op: 'set' | 'get' | 'list' plus key/value; its jar (ent.state.kv) persists across tasks.

The loop, maxSteps, and the trace

Per task the agent builds messages = [{role:'user', content: task}] and repeats, up to config.maxSteps times (default 6, clamped to โ‰ฅ1):

  1. Call api.llm({ system, messages, input, tools }) โ€” tools included only when any were discovered; config.model overrides the session model when set.
  2. If the reply has toolCalls, run each one (unknown names and thrown errors become ERROR: ... results), append the assistant turn and one role:'tool' message per call, and loop.
  3. Otherwise the reply text is the final answer โ€” emitted from result as a text packet with meta.agent (the agent's name) and meta.steps.

If the step cap is hit without a final answer, the agent falls back to its last assistant text, or [agent] step limit reached. Every step is recorded in ent.state.trace โ€” entries of { step, action: 'call' | 'tool' | 'final', detail }, capped at the last 50 โ€” which the game UI displays live.

Complete example: agent + toolbox

The Agent Core occupies (2,2)โ€“(3,3); the Toolbox at (2,4) touches its bottom edge, so it's discovered automatically โ€” no wiring needed:

const agentBp = { v: 1, name: 'agent-demo', w: 14, h: 8, entities: [
  { t: 'inbox', x: 1, y: 2, r: 1, c: { name: 'task' } },
  { t: 'agent', x: 2, y: 2, r: 1, c: { name: 'helper' } },
  { t: 'tool',  x: 2, y: 4, r: 1, c: {
      name: 'shout', description: 'shout the input text loudly',
      params: { type: 'object', properties: { input: { type: 'string' } } },
      code: 'return String(args.input).toUpperCase() + "!!!"' } },
  { t: 'delivery', x: 4, y: 2, r: 1, c: { name: 'out' } },
]};

const r = await runBlueprint(agentBp, { inputs: { task: ['please shout: hello'] } });
console.log(r.outputs.out);   // [ 'Result: PLEASE SHOUT: HELLO!!!' ]

Under MockLLM, the tool heuristic picks shout by word overlap, then summarizes the tool result โ€” deterministically. Swap in a real provider and the very same factory runs a real tool-calling agent.