Skip to content

tool node

A tool node runs a deterministic JavaScript/TypeScript module. It is the backbone of any OpenExpertise flow that does not need an LLM: file I/O, API calls, data transformation, aggregation, and any side-effects with predictable output.


When to use it

  • Parsing or transforming data before an LLM step (e.g. load a diff, flatten a CSV).
  • Aggregating results after a for_each fan-out (sum totals, merge arrays).
  • Any side-effect you want fully under your control: writing a file, calling a REST API, computing a hash.
  • Seeding initial state from hard-coded or environment-derived values.
  • Any step where you want 100% determinism and unit-testability without LLM involvement.

No API key required

A graph composed entirely of tool nodes runs with no LLM API key. examples/hello-tool and examples/dataset-aggregate both demonstrate this.


YAML fields

FieldRequiredTypeDescription
idyesstringUnique node identifier within the graph.
kindyes"tool"Must be the literal string "tool".
implyesstringPath to the .mjs (or .js) module, relative to experience.yaml.
argsnoobjectStatic key-value pairs passed into the module's default export as part of the args argument.
readsnostring[]State fields to make available in _state. Declarative — does not restrict what _state contains.
writesnostring[]State fields this node is expected to write. Must be declared in state.schema.
phasenostringPhases group nodes in the TUI and in evolution proposals.
on_errornoErrorPolicyskip | fail_run | retry. Default is fail_run.
for_eachnoForEachClauseFans out: runs the node once per item in a state array.

The implementation contract

The dispatcher (ToolDispatcher from @openexpertise/node-kinds-tool) resolves the module with import() and calls the default export:

typescript
// Signature the dispatcher calls
type ToolFn = (args: ToolArgs) => Promise<NodeOutput> | NodeOutput

interface ToolArgs {
  // All static `args:` fields from the YAML node are spread at the top level.
  [key: string]: unknown

  // The current state snapshot, keyed by field name.
  _state: Record<string, unknown>

  // The outputs of direct predecessor nodes (from `reads:` + edge routing).
  _edge_inputs: Record<string, unknown>
}

interface NodeOutput {
  // Keys must match declared `writes:` fields and be declared in state.schema.
  state_delta: Record<string, unknown>

  // Optional: value passed to dependent nodes via edge_inputs.
  edge_output?: unknown

  // Optional: token/cost metrics (tool nodes typically omit this).
  metrics?: { tokens_in?: number; tokens_out?: number; cost_usd?: number }
}

The dispatcher validates:

  1. The module has a default export.
  2. The export is a function.
  3. The function returns an object with at least a state_delta key.

If state_delta is empty ({}), no state is written. If it contains keys not declared in state.schema, the StateStore.write() call throws.

Module format

Use ESM .mjs files. The loader calls import() with a file:// URL, so .mjs is the safest extension. Plain .js works if your package.json has "type": "module". TypeScript .ts files must be pre-compiled to .mjs — the dispatcher does not run tsc at runtime.

javascript
// tools/my_tool.mjs  (minimal valid module)
export default async function myTool(args) {
  return { state_delta: { result: 'done' } }
}

Full working example

Source: examples/hello-tool/

yaml
# experience.yaml
name: hello-tool
version: 0.1.0

state:
  schema:
    name: { type: string }
    greeting: { type: string }

graph:
  nodes:
    - id: greet
      kind: tool
      impl: ./tools/greet.mjs
      args:
        name: World
      writes: [greeting]
  edges: []
javascript
// tools/greet.mjs
export default async function greet(args) {
  return { state_delta: { greeting: `hello, ${args.name}` } }
}
bash
oe run examples/hello-tool
# finalState: { greeting: "hello, World" }

A more realistic example — loading state from a predecessor via _state:

Source: examples/dataset-aggregate/tools/aggregate.mjs

javascript
export default async function aggregate(args) {
  const rows = args._state?.rows ?? []
  const total = rows.reduce((acc, r) => acc + Number(r.amount ?? 0), 0)
  return { state_delta: { total } }
}

Variations

With for_each

Fan out a tool across every item in a state array. The node runs once per item; args._item contains the current element.

yaml
- id: process_row
  kind: tool
  impl: ./tools/process.mjs
  for_each: { source: $.rows }
  reads: [rows]
  writes: [results]
javascript
// tools/process.mjs
export default async function process(args) {
  const row = args._item // current item from the for_each array
  const idx = args._item_index // 0-based index
  return { state_delta: { results: [{ id: row.id, processed: true }] } }
}

Declare results with merge: array_append in state.schema so each iteration's output accumulates.

With on_error: skip

Continue the run even if this node throws. Useful for best-effort enrichment steps.

yaml
- id: enrich_data
  kind: tool
  impl: ./tools/enrich.mjs
  on_error: { policy: skip }
  writes: [enriched]

With on_error: retry

Retry up to 3 times with exponential backoff — useful for flaky external API calls.

yaml
- id: call_api
  kind: tool
  impl: ./tools/call_api.mjs
  on_error:
    policy: retry
    attempts: 3
    backoff: exponential
    base_ms: 500
  writes: [api_result]

Reads-only (no writes)

A tool can read state without writing — useful for validation or side-effect-only operations like writing a file.

yaml
- id: write_report
  kind: tool
  impl: ./tools/write_report.mjs
  reads: [findings, risk_score]
  # no writes: key
javascript
export default async function writeReport(args) {
  const findings = args._state.findings ?? []
  // write to disk, send Slack message, etc.
  return { state_delta: {} }
}

With edge_output

Pass structured data directly to dependent nodes without writing to the shared state:

javascript
export default async function fetchData(args) {
  const data = await callApi(args.endpoint)
  return {
    state_delta: {}, // nothing persisted
    edge_output: data, // available to downstream nodes as _edge_inputs.fetch_data
  }
}

Gotchas

  1. .mjs extension, not .ts — The dispatcher calls import() at runtime. TypeScript files must be compiled first. Run pnpm -r build or compile the tools directory with tsc.

  2. Every write key must be declared in state.schema — If state_delta contains an undeclared key, the StateStore throws. Add the key to state.schema before running.

  3. _state is a snapshot, not a live reference — Mutations to args._state have no effect. The only way to write state is via state_delta.

  4. reads: is declarative, not a filter_state always contains the full state snapshot. The reads: field is metadata for the scheduler and TUI, not an access control list.

  5. Empty state_delta is valid — Return { state_delta: {} } for side-effect-only nodes. The dispatcher accepts it.


See also

Released under the MIT License.