Node Kinds¶
A graph is a set of nodes. There are eleven kinds, and the kind decides what the node needs, what it is allowed to do, and whether it calls a model at all.
Three of them reason (base, react, router), three run code (tool, function, python), three contain other nodes (loop, map, subgraph), and two mark the edges of the run (input, output).
What a kind requires is declared once, in neurosurfer/graph/engine/kinds/. The validator, the engine and this page all read the same declaration, so "what can go wrong with a tool node" is a query rather than a careful read.
Use the class, not the string¶
Every kind is available as a class, and that is the form to reach for in Python:
from neurosurfer.graph import RouterNode
RouterNode(id="triage", instructions="Route by urgency: {ticket}", routes={...})
Your editor completes the name, the class is greppable, and isinstance(node, RouterNode) works. The kind= string form is equivalent and still fully supported — it is what YAML uses, and what existing code is full of:
from neurosurfer.graph import GraphNode
GraphNode(kind="router", id="triage", instructions="Route by urgency: {ticket}", routes={...})
Both produce the same node. Graph upgrades whatever it is given, so isinstance(node, RouterNode) is true however the node was made, YAML on disk is untouched, and the engine dispatches on the class rather than on a kind string.
Why every name ends in Node
The bare names were not sayable at a call site. Tool, Input, Output, Map, Function and Python all already mean something else here — and Tool was an outright collision with neurosurfer.tools.base.Tool, the ABC every registered tool subclasses.
Choosing a kind¶
| I need to… | Class | kind= |
|---|---|---|
| Write, summarise, classify, transform text | BaseNode | base |
| Work that needs a model to decide what to call, repeatedly | ReactNode | react |
| Call one known tool with arguments I already have | ToolNode | tool |
| Run deterministic Python | FunctionNode | function |
Same as FunctionNode today | PythonNode | python |
| Take one branch of several | RouterNode | router |
| Repeat until something is good enough | LoopNode | loop |
| Do the same thing to every item of a list | MapNode | map |
| Group a chunk of graph as one unit | SubgraphNode | subgraph |
| Stop and ask a person | InputNode | input |
| Declare what the workflow returns | OutputNode | output |
Plus ContainerNode — the shared base for the three kinds that run a nested body (LoopNode, MapNode, SubgraphNode). You do not instantiate it; it is what you check against when you want "does this node have a body".
from neurosurfer.graph import (
BaseNode, ReactNode, ToolNode, FunctionNode, PythonNode, RouterNode,
LoopNode, MapNode, SubgraphNode, InputNode, OutputNode, ContainerNode,
)
The distinction that matters most: BaseNode reasons and cannot act; ToolNode acts and cannot reason; ReactNode does both. A node whose goal describes reaching outside the model — reading a file, fetching a URL, sending a message — cannot be a BaseNode with a well-worded prompt. See Validation.
Fields every node can use¶
| Field | Effect |
|---|---|
depends_on | Ids this node waits for. Their outputs are passed to it. |
when | An expression; the node runs only if it is truthy. |
on_error | On failure, reroute to a fallback node instead of failing the branch. |
writes | Store the output as {name} for downstream templates and expressions. |
policy.retries | Re-run a flaky node up to N times before it counts as failed. |
See Control flow for how when and on_error interact with branching, and State & secrets for writes.
BaseNode · kind="base"¶
One LLM call — writing, summarising, classifying, transforming text.
BaseNode(
id="writer",
instructions="Write a 2-paragraph explanation from the research notes above.",
depends_on=["researcher"],
)
| Field | Notes |
|---|---|
instructions | What the step should do. The older purpose / goal / expected_result trio is still read when it is absent. |
tools | Optional. The model gets one round with them. |
tool_settings, secrets | See State & secrets. |
provider, mode, output_schema | Per-node model and structured output. |
Constraints
- For structured output, set an output schema — without one, a node asked for an object returns JSON as a string.
- Tools are optional and the model gets one round. A step that must call tools repeatedly to finish its job is a
reactnode. Abasestep cut off mid-plan now fails rather than reporting success — see Upgrading.
ReactNode · kind="react"¶
An LLM that calls tools in a loop — for work that must touch the outside world and needs a model to decide what to send.
ReactNode(
id="scout",
instructions="Use list_dir to explore, read README.md, summarise, then call finish.",
tools=["list_dir", "read_file", "finish"],
)
| Field | Notes |
|---|---|
instructions | What the agent should accomplish. |
tools | Required. A react node with none cannot act, and is refused. |
tool_args | Bound arguments supplied on every call and hidden from the schema the model is offered. This is how a credential reaches a tool without reaching the model. |
tool_settings, secrets | See State & secrets. |
provider | Per-node model. |
Constraints
- The only kind that both reasons and acts.
- No
modeoroutput_schema. The loop does not honour a schema today, so the field is not offered rather than being written, reviewed, and read by nothing. - A node that ends with
finish()returns what it finished with.
ToolNode · kind="tool"¶
Calls one registered tool with the arguments you bind. No LLM call.
| Field | Notes |
|---|---|
tools | Required — the single tool this node invokes. |
tool_args | The whole instruction. Each argument is a literal, a reference to an upstream node or graph input, or a ${CREDENTIAL}. |
tool_settings, secrets | See State & secrets. |
Constraints
- There is no LLM call, so nothing composes its arguments and nothing reads an instruction. If an argument has to be worked out — a query, a search phrase, a request body — the step is a
reactnode with that tool attached. - Every required parameter of the chosen tool must be supplied from somewhere: an argument here, or a graph input or upstream output of the same name. One missing calls the tool with nothing and fails at run time.
- A credential goes in
secretsand is written${NAME}in an argument, never in a prompt.
FunctionNode · kind="function"¶
Deterministic Python: imports a callable and calls it with the inputs and upstream outputs that match its signature.
| Field | Notes |
|---|---|
callable | Required. Import path to a Python callable, module:attr. |
Constraints
- Arguments are matched to the signature by name, so a parameter whose name is not a graph input or an upstream node id receives nothing. Nothing warns about this today.
- No
export: the exporter is only consulted on the LLM path, soexport: truehere would be a setting that silently does nothing.
PythonNode · kind="python"¶
Today, an alias of function. Same executor path, same required import path.
It does not run inline code, despite the name. The spec states what the engine does rather than what the label suggests; resolving the gap — real inline code, or retiring the kind — is an open decision.
RouterNode · kind="router"¶
Picks one downstream branch — by LLM classification, or by deterministic predicates. The branches not taken are skipped, not errored.
RouterNode(
id="triage",
instructions="Route this support ticket by urgency: {ticket}",
routes={"urgent": "escalate", "routine": "reply"}, # N-way, one LLM call
default="reply",
)
| Field | Notes |
|---|---|
routes | Label → target node. The router classifies with one LLM call and picks a label. |
cases | Ordered predicates, first match wins. No LLM call — free and reproducible. |
default | Used when the model's answer maps to no route. |
repair | routes only: retry once with corrective feedback when the model picks a label that does not exist. |
Constraints
- Declare
routesorcases, never both. - Every target must declare this router in its
depends_on. A target that does not, or that names a node which does not exist, is reported by the validator rather than pruning the whole branch at run time. - The
casesform makes no LLM call, soprovidermeans nothing for it.
See Control flow for the deterministic form and merge behaviour.
LoopNode · kind="loop"¶
Runs a nested body over and over until a condition holds, up to a hard ceiling.
| Field | Notes |
|---|---|
body | Required. Nested nodes, run once per iteration. |
max_iterations | The ceiling. |
until | The one stop condition — a function or plain English. |
accumulate | Collect per-iteration output. |
item_var | The name the previous iteration's output is available under inside the body. |
Constraints
- No
untilmeans it runs to the ceiling. - A plain-English
untilcosts one LLM call per iteration; a function costs nothing. Prefer a function whenever the condition is checkable in code. - A plain-English
untilabout a different subject than the body produces stops the loop with a warning rather than running to the ceiling — so the condition must actually describe the body's output.
break_when was removed; see Upgrading and Control flow for the full until contract.
MapNode · kind="map"¶
Runs a nested body once per item of a collection, and returns the ordered list of results.
MapNode(id="per_item", over="inputs.items", item_var="item", concurrency=4,
body=[BaseNode(id="handle", instructions="Process one item: {item}")])
| Field | Notes |
|---|---|
body | Required. Run once per item. |
over | Expression selecting the collection. |
item_var | The name each item is bound to inside the body. Default item. |
concurrency | How many items run in parallel. |
Constraints
- The output is the ordered list of per-item results — the gather is implicit, so no node is needed to collect them.
- The body is handed one item, not the whole collection.
SubgraphNode · kind="subgraph"¶
A workflow inside a workflow: runs a nested body once, and its final outputs become this node's output.
| Field | Notes |
|---|---|
body | Required. The nested graph. |
body_outputs | Which body nodes' outputs surface. |
Constraints
- Body nodes may only depend on their siblings — a dependency pointing outside the body is refused when the graph loads.
InputNode · kind="input"¶
Pauses the run and waits for a person to supply a value.
| Field | Notes |
|---|---|
input_mode | dict (named, typed fields) or text. |
depends_on, on_error | Wiring. |
Constraints
- In
dictmode the fields a person fills in are the workflow's declared inputs. - With nothing supplied the run finishes
awaiting_input, notfailed; a client resumes it. - Resuming currently re-runs the whole graph, so a node before this one runs twice.
No instructions is offered on a panel — the conversation happens at run time. The engine still uses one if a graph sets it: it is the question a headless CLI run asks.
OutputNode · kind="output"¶
Declares what the workflow returns. The graph stops here.
| Field | Notes |
|---|---|
value | A template for what to return. |
depends_on | A dependency whose output it passes through. |
Constraints
- Needs something to return: a dependency whose output it passes through, or a value template. With neither it returns nothing.
- Nothing may depend on one, and it cannot be an error target.
- An unresolved placeholder in the value is refused, not warned about — this text is what a caller receives.
- Takes precedence over the older graph-level
outputs:list.
Next¶
- Control flow — branching, looping, fanning out, and recovering from errors.
- State & secrets — what a node can see, and what it must never see.
- Validation — what is checked, and when.
- Building in Python — the
GraphBuilderfluent API.