Skip to content
Built by Postindustria. We help teams build agentic production systems.

Subgraphs

A Construct can contain other Constructs. When a Construct appears inside another Construct’s nodes list, it becomes a subgraph with its own isolated state. Internal nodes do not leak into the parent.

Top-level pipelines use @node and construct_from_module for ergonomic, signature-driven wiring. Sub-constructs use the declarative Construct(input=X, output=Y, nodes=[...]) form instead.

The reason is isolation. A sub-construct is a typed I/O boundary: it takes one type in, produces one type out, and hides everything in between. The explicit input and output declarations define that contract. With @node, dependencies are inferred from parameter names — but a sub-construct’s internal nodes should not be visible to the parent’s namespace. The declarative form makes the boundary explicit and prevents internal node names from leaking into parent wiring.

Think of it like a function signature: the top-level pipeline is the function body (where @node infers the flow), and each sub-construct is a called function with a declared interface.

A sub-Construct must declare both input and output to define what crosses the boundary:

from pydantic import BaseModel
from neograph import Construct, Node
class Claims(BaseModel):
items: list[str]
class ScoredClaims(BaseModel):
scored: list[dict]
# Internal nodes use the declarative Node(...) form
lookup = Node("lookup", mode="gather", input=Claims, output=LookupResults,
model="reason", prompt="rw/lookup",
tools=[Tool("search", budget=5)])
verify = Node("verify", mode="produce", input=LookupResults, output=VerifiedClaims,
model="fast", prompt="rw/verify")
score = Node("score", mode="produce", input=VerifiedClaims, output=ScoredClaims,
model="fast", prompt="rw/score")
# Sub-construct with I/O boundary
enrich = Construct(
"enrich",
input=Claims,
output=ScoredClaims,
nodes=[lookup, verify, score],
)

The input type tells the framework how to extract data from the parent state. At runtime, the framework scans the parent state for a field whose value is an instance of Claims and passes it into the sub-graph. The output type defines what comes back — the sub-graph’s result is stored in parent_state.enrich as a ScoredClaims instance.

Using a sub-Construct in a top-level pipeline

Section titled “Using a sub-Construct in a top-level pipeline”

The top-level pipeline uses @node for its own nodes. The sub-construct slots in alongside them:

from neograph import node, construct_from_module, compile, run
import sys
@node(output=RawText, prompt='rw/extract', model='fast')
def extract(topic: FromInput[str]) -> RawText: ...
@node(output=Claims, prompt='rw/decompose', model='reason')
def decompose(extract: RawText) -> Claims: ...
# enrich is the sub-Construct defined above
@node(output=FinalReport, prompt='rw/report', model='fast')
def report(enrich: ScoredClaims) -> FinalReport: ...
# construct_from_module discovers @node functions;
# add the sub-construct manually to the node list
pipeline = Construct(
"ingestion",
nodes=[extract, decompose, enrich, report],
)
graph = compile(pipeline)
result = run(graph, input={"node_id": "BR-042"})

The compiled graph executes extract -> decompose -> enrich (subgraph) -> report in sequence. The enrich subgraph runs its three internal nodes (lookup -> verify -> score) in isolation, then surfaces the ScoredClaims result back to the parent.

The parent state model will contain a field enrich: ScoredClaims | None, but it will not contain lookup, verify, or score fields. Those exist only inside the sub-graph’s state.

This means:

  • The report node can read enrich (the ScoredClaims output) but cannot see lookup or verify results.
  • If the sub-Construct has a node named score and the parent also has a node named score, there is no conflict. They write to different state models.
  • The sub-graph’s internal framework fields (neo_oracle_gen_id, neo_each_item, etc.) are confined to the sub-graph.

Nesting composes. A sub-Construct can itself contain sub-Constructs:

# Level 2: inner sub-construct
verify_block = Construct(
"verify-block",
input=LookupResults,
output=VerifiedClaims,
nodes=[cross_check, validate, consolidate],
)
# Level 1: outer sub-construct containing the inner one
enrich = Construct(
"enrich",
input=Claims,
output=ScoredClaims,
nodes=[lookup, verify_block, score],
)
# Top level
pipeline = Construct(
"ingestion",
nodes=[extract, decompose, enrich, report],
)

Each level gets its own isolated state. The inner verify-block is invisible to enrich’s siblings, and enrich’s internals are invisible to pipeline’s nodes.

All three modifiers — Oracle, Each, Operator — work on Constructs. When applied, the entire sub-pipeline is treated as the modified unit.

Run the entire sub-pipeline N times in parallel, then merge the outputs:

enrich = Construct(
"enrich",
input=Claims,
output=ScoredClaims,
nodes=[lookup, verify, score],
) | Oracle(n=3, merge_fn="combine_scores")

The compiler creates N copies of the sub-graph execution, each receiving the same parent state but running independently. The merge function receives a list of N ScoredClaims results.

Run the entire sub-pipeline once per item in a collection:

review_cluster = Construct(
"review-cluster",
input=ClusterGroup,
output=ClusterReview,
nodes=[analyze, check, summarize],
) | Each(over="clusters.groups", key="label")

If clusters.groups contains 5 items, the sub-pipeline runs 5 times in parallel. The result is a dict:

result["review_cluster"] == {
"auth": ClusterReview(...),
"payments": ClusterReview(...),
"search": ClusterReview(...),
...
}

Pause after the entire sub-pipeline completes:

validation = Construct(
"validation",
input=Draft,
output=ValidationResult,
nodes=[lint, test, report],
) | Operator(when="validation_failed")

The interrupt check runs after all three internal nodes have completed, not after each one individually.

The compiler enforces sub-Construct contracts:

  • A sub-Construct without input raises ConstructError at compile time.
  • A sub-Construct without output raises ConstructError during state model generation.
  • An Operator modifier anywhere in the graph without a checkpointer passed to compile() raises ConstructError.

Documentation © 2025-2026 Constantine Mirin, mirin.pro. Licensed under CC BY-ND 4.0.