Step-by-step tutorial for turns, sessions, engines, middlewares, tools, and toolloop hooks from JavaScript.
This tutorial is intentionally deep and script-first. You will learn the model behind the API, then build from a blank script file to a multi-step tool-enabled flow. Every stage is executable.
Reference docs:
Each step follows one structure:
If you already know Geppetto’s internal Go architecture, focus on the API and flow sections. If you are new, read every fundamentals section in order.
Before writing code, keep this picture in mind.
+------------------------------ JS Script -------------------------------+
| require("geppetto") |
| |
| turn -> session -> engine -> middlewares -> tools -> toolloop |
| |
+------------------------------+----------------------------------------+
|
v
+--------------------------- Goja Boundary -----------------------------+
| decode JS values -> Go structs -> execute -> encode back to JS |
+------------------------------+----------------------------------------+
|
v
+-------------------------- Geppetto Runtime ---------------------------+
| turns, sessions, inference engines, middleware chain, tool execution |
+----------------------------------------------------------------------+
Key entities:
Turn: one inference state container containing blocks plus metadata/data maps.Block: typed unit inside a turn (user, system, llm_text, tool_call, tool_use, ...).Engine: function-like inference component that transforms a turn.Session: stateful history wrapper around repeated engine execution.Middleware: interceptors around engine execution.ToolRegistry: callable tool definitions available to the model and runtime.Toolloop: iterative cycle that executes tool calls and re-enters inference.Your JS does not run in Node.js in this workflow. It runs inside goja, hosted by geppetto-js-lab.
That host is responsible for:
require("geppetto")assert, console, ENV)useGoToolsWithout a host, your JS code has no access to Geppetto API surfaces.
go run ./cmd/examples/geppetto-js-lab --list-go-toolsstart host
register geppetto native module
register host go tools
if --list-go-tools:
print tool names
Terminal command
|
v
geppetto-js-lab
|
+--> builds Go tool registry
+--> configures goja runtime
+--> registers require("geppetto")
|
v
prints host capabilities
go run ./cmd/examples/geppetto-js-lab --list-go-tools
Expected output includes:
go_doublego_concatA Turn is the core payload for inference. Think of it as an envelope with two categories of content:
blocks: conversational and tool eventsmetadata and dataA Block has:
kind: semantic type (user, tool_call, etc.)payload: type-specific fieldsWhy start here:
Normalization matters because JS objects are flexible while Go structs are typed. turns.normalize round-trips through the canonical mapper and guarantees your turn shape is compatible with runtime expectations.
gp.turns.newTurn(opts)gp.turns.newUserBlock(text)gp.turns.newToolCallBlock(id, name, args)gp.turns.normalize(turn)build user block
build tool_call block
construct turn with blocks + metadata
assert shape
normalize turn through codec
assert normalized fields preserved
JS object literal
|
v
turns.newTurn(...)
|
+--> blocks array
+--> metadata map
+--> data map
|
v
turns.normalize(turn)
|
+--> decode JS -> Go Turn
+--> encode Go Turn -> JS
|
v
canonical turn object
Create scratch/js-lab/01_turns.js:
const gp = require("geppetto");
const turn = gp.turns.newTurn({
id: "turn-1",
blocks: [
gp.turns.newUserBlock("hello"),
gp.turns.newToolCallBlock("call-1", "js_add", { a: 2, b: 3 })
],
metadata: { session_id: "s-1" }
});
assert(turn.blocks.length === 2, "expected two blocks");
assert(turn.blocks[0].kind === "user", "first block kind mismatch");
assert(turn.blocks[1].kind === "tool_call", "second block kind mismatch");
const normalized = gp.turns.normalize(turn);
assert(normalized.metadata.session_id === "s-1", "metadata mismatch");
console.log("PASS step 1");
Run it:
go run ./cmd/examples/geppetto-js-lab --script scratch/js-lab/01_turns.js
kind values are exactly expectedA Session provides conversation state and execution lifecycle:
engines.echo is deterministic and should be your first engine in any new flow because it removes provider variability. You can test state wiring before involving real model APIs.
Conceptually:
Session is your state containerEngine is your transformation functionrun() executes engine over current turn context and stores resultgp.engines.echo({ reply })gp.createSession({ engine })session.append(turn)session.run()session.turnCount()engine := echo("READY")
session := createSession(engine)
append user turn
out := session.run()
assert last block is llm_text READY
assert history length == 1
user turn
|
v
session.append
|
v
session.run
|
v
echo engine appends llm_text("READY")
|
v
updated turn returned + stored in session history
Create scratch/js-lab/02_session.js:
const gp = require("geppetto");
const session = gp.createSession({
engine: gp.engines.echo({ reply: "READY" })
});
session.append(gp.turns.newTurn({ blocks: [gp.turns.newUserBlock("reply READY")] }));
const out = session.run();
const last = out.blocks[out.blocks.length - 1];
assert(last.kind === "llm_text", "missing llm_text output");
assert(last.payload.text === "READY", "assistant text mismatch");
assert(session.turnCount() === 1, "turnCount mismatch");
console.log("PASS step 2");
Run it:
go run ./cmd/examples/geppetto-js-lab --script scratch/js-lab/02_session.js
run() returns updated turnMiddleware is a chain-of-responsibility around engine execution. It is used to enforce policies, inject prompts, log traces, or transform turn content.
A middleware receives:
next(turn) callbackIt can:
nextOrdering is critical. If middleware A must run before B, register A first.
In this step:
systemPrompt adds a system blocknextgp.engines.fromFunction(fn)gp.createBuilder()builder.withEngine(engine)builder.useGoMiddleware("systemPrompt", opts)gp.middlewares.fromJS(fn, name?)builder.useMiddleware(middleware)builder.buildSession()engine := fromFunction(append "ok")
builder := createBuilder
builder.withEngine(engine)
builder.useGoMiddleware(systemPrompt, {prompt:"SYSTEM"})
builder.useMiddleware(jsMiddleware(add trace_id))
session := builder.buildSession
append user turn
out := session.run
assert system block inserted first
assert trace_id exists
Input Turn
|
v
[Go systemPrompt middleware]
|
v
[JS trace middleware pre]
|
v
Engine (append assistant "ok")
|
v
[JS trace middleware post -> metadata.trace_id]
|
v
Output Turn
Create scratch/js-lab/03_middleware.js:
const gp = require("geppetto");
const engine = gp.engines.fromFunction((turn) => {
turn.blocks.push(gp.turns.newAssistantBlock("ok"));
return turn;
});
const session = gp
.createBuilder()
.withEngine(engine)
.useGoMiddleware("systemPrompt", { prompt: "SYSTEM" })
.useMiddleware(gp.middlewares.fromJS((turn, next) => {
const out = next(turn);
out.metadata = out.metadata || {};
out.metadata.trace_id = "js-mw";
return out;
}, "trace"))
.buildSession();
session.append(gp.turns.newTurn({ blocks: [gp.turns.newUserBlock("ping")] }));
const out = session.run();
assert(out.blocks[0].kind === "system", "system prompt missing");
assert(out.metadata.trace_id === "js-mw", "trace metadata missing");
console.log("PASS step 3");
Run it:
go run ./cmd/examples/geppetto-js-lab --script scratch/js-lab/03_middleware.js
trace_idTooling has two layers:
Core loop idea:
tool_calltool_use result blockThis means your engine can act as orchestrator logic while toolloop handles reliable execution and retries/policies.
gp.tools.createRegistry()registry.register(spec)gp.turns.newToolCallBlock(id, name, args)builder.withTools(registry, opts)enabled, maxIterations, toolChoice, maxParallelToolsregister js_add tool
engine:
if no tool_use yet:
emit tool_call(js_add)
else:
emit assistant done
builder.withTools(reg, loopOpts)
run session
assert tool_use block exists with sum result
Iteration 1:
Engine -> tool_call(js_add)
Toolloop executes js_add -> tool_use({sum:5})
Iteration 2:
Engine sees tool_use -> assistant("done")
No new tool_call -> stop
Create scratch/js-lab/04_tools.js:
const gp = require("geppetto");
const reg = gp.tools.createRegistry();
reg.register({
name: "js_add",
description: "Add numbers",
handler: ({ a, b }) => ({ sum: a + b })
});
const engine = gp.engines.fromFunction((turn) => {
const hasToolUse = turn.blocks.some((b) => b.kind === "tool_use");
if (!hasToolUse) {
turn.blocks.push(gp.turns.newToolCallBlock("call-1", "js_add", { a: 2, b: 3 }));
return turn;
}
turn.blocks.push(gp.turns.newAssistantBlock("done"));
return turn;
});
const session = gp
.createBuilder()
.withEngine(engine)
.withTools(reg, { enabled: true, maxIterations: 3, toolChoice: "auto" })
.buildSession();
session.append(gp.turns.newTurn({ blocks: [gp.turns.newUserBlock("compute")] }));
const out = session.run();
const toolUse = out.blocks.find((b) => b.kind === "tool_use");
assert(!!toolUse, "missing tool_use block");
assert(String(toolUse.payload.result).includes("sum"), "tool result missing sum");
console.log("PASS step 4");
Run it:
go run ./cmd/examples/geppetto-js-lab --script scratch/js-lab/04_tools.js
tool_call generated internally by enginetool_use appended by runtimeIn production, you often need mixed tools:
useGoTools imports host-exposed Go tools into your JS registry. This keeps one unified invocation model in JS while still using typed Go implementations.
Important constraint:
geppetto-js-lab does this automaticallyregistry.useGoTools([names])registry.call(name, args)builder.withTools(registry, opts) for loop-driven usagereg := createRegistry
reg.useGoTools(["go_double"])
result := reg.call("go_double", {n:21})
assert result.value == 42
JS script
|
v
registry.useGoTools("go_double")
|
v
host Go tool registry lookup
|
v
tool becomes callable in JS registry
|
v
registry.call("go_double", {n:21}) -> {value:42}
Create scratch/js-lab/05_go_tools.js:
const gp = require("geppetto");
const reg = gp.tools.createRegistry();
reg.useGoTools(["go_double"]);
const direct = reg.call("go_double", { n: 21 });
assert(direct.value === 42, "direct go tool call mismatch");
console.log("PASS step 5", JSON.stringify(direct));
Run it:
go run ./cmd/examples/geppetto-js-lab --script scratch/js-lab/05_go_tools.js
Live inference introduces non-determinism and credential dependency. That is why it appears last.
What changes from deterministic steps:
Recommended order:
gp.engines.fromConfig({ apiType, model, apiKey })gp.createSession({ engine })session.run()ENVapiKey := ENV.GEMINI_API_KEY or ENV.GOOGLE_API_KEY
if missing:
print SKIP
exit success
create engine fromConfig(gemini)
run one-turn session
assert output blocks exist
log final block
Script start
|
+--> key exists? -- no --> print SKIP --> success exit
|
yes
|
v
create gemini engine -> run session -> inspect final block
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/06_live_profile_inference.js
If keys are absent, script self-skips and still exits successfully.
If you want all maintained example scripts in sequence:
go run ./cmd/examples/geppetto-js-lab --list-go-tools
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/01_turns_and_blocks.js
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/02_session_echo.js
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/03_middleware_composition.js
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/04_tools_and_toolloop.js
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/05_go_tools_from_js.js
go run ./cmd/examples/geppetto-js-lab --script examples/js/geppetto/06_live_profile_inference.js
| Step | Main APIs | Main Concept |
|---|---|---|
| 0 | geppetto-js-lab CLI | host runtime wiring |
| 1 | turns.* | canonical turn/block schema |
| 2 | engines.echo, createSession, run | deterministic stateful inference |
| 3 | createBuilder, useGoMiddleware, middlewares.fromJS | chain-of-responsibility composition |
| 4 | tools.createRegistry, withTools | tool execution loop |
| 5 | useGoTools, call | hybrid JS+Go tool ecosystem |
| 6 | engines.fromConfig | real provider smoke validation |
| Problem | Why it happens | Fix |
|---|---|---|
module geppetto not found | runtime host did not register module | use geppetto-js-lab or register via gp.Register |
builder has no engine configured | withEngine omitted | call withEngine before buildSession |
no go tool registry configured | useGoTools in host without Go registry | run in geppetto-js-lab or inject Options.GoToolRegistry |
| no tool execution | tool registry not bound to builder | use .withTools(reg, { enabled: true }) |
runAsync requires module options Runner to be configured | missing runtime runner in host | use run() or register module with Options.Runner |
| live script fails auth | missing/invalid API key | set GEMINI_API_KEY or GOOGLE_API_KEY |
Use these conventions when building larger JS automation packs:
kind, payload fields, metadata)A practical naming pattern:
01_... schema and shapes02_... basic session behavior03_... middleware behavior04_... toolloop behavior05_... integration with host tools06_... live smokeexamples/js/geppetto with your own domain scripts and keep them executable through geppetto-js-lab.