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

Input Renderers

When a node’s upstream output is inserted into an LLM prompt, it needs to be serialized to text. Renderers control how that serialization happens. When no renderer is configured, BaseModel values are rendered in TypeScript-style BAML notation via describe_value(). Three built-in renderers are available when you want a different format; you can also define custom rendering per model.

When no renderer is configured on the node or construct, Pydantic BaseModel values are rendered via describe_value() — the same TypeScript-style notation used for structured output instructions. This is the default for all LLM-mode nodes.

from pydantic import BaseModel
class Claim(BaseModel, frozen=True):
text: str
category: str
confidence: float
class Claims(BaseModel, frozen=True):
items: list[Claim]
claims = Claims(items=[
Claim(text="The system shall authenticate users via SSO",
category="authentication", confidence=0.95),
Claim(text="All API responses shall include trace IDs",
category="observability", confidence=0.88),
])

The default BAML rendering produces:

{
items: [
{
text: "The system shall authenticate users via SSO"
category: "authentication"
confidence: 0.95
},
{
text: "All API responses shall include trace IDs"
category: "observability"
confidence: 0.88
}
]
}

This notation is the same format LLMs see in structured output instructions (describe_type()), so input rendering and output schema rendering are symmetric. Lists of BaseModels (list[BaseModel]) are also rendered via describe_value() in the same notation.

BAML-style XML elements. Fields become tags; nested models become nested XML; lists become repeated <item> elements. Multi-line prose is not JSON-escaped.

from neograph import XmlRenderer
renderer = XmlRenderer()
print(renderer.render(claims))

Output:

<items>
<item>
<text>The system shall authenticate users via SSO</text>
<category>authentication</category>
</item>
<item>
<text>All API responses shall include trace IDs</text>
<category>observability</category>
</item>
</items>

The include_field_info parameter controls field description emission:

ValueBehavior
'once' (default)Description as attribute on first occurrence of each field
'always'Description as attribute on every occurrence
'never'No descriptions
renderer = XmlRenderer(include_field_info='always')

DSPy-style [[ ## field ## ]] headers. Fields get delimited headers; nested models recurse with dotted prefixes; lists use - bullet points.

from neograph import DelimitedRenderer
renderer = DelimitedRenderer()
print(renderer.render(claims))

Output:

[[ ## items ## ]]
- [[ ## items.text ## ]]
The system shall authenticate users via SSO
- [[ ## items.text ## ]]
All API responses shall include trace IDs

Backward-compatible JSON serialization. Uses model_dump_json for Pydantic models, json.dumps for everything else.

from neograph import JsonRenderer
renderer = JsonRenderer(indent=2)
print(renderer.render(claims))
RendererBest for
None (BAML default)Most use cases. TypeScript-style notation is symmetric with structured output schema instructions. No configuration needed.
XmlRendererWhen you want XML structure. LLMs parse XML reliably, and nested structure is preserved without JSON escaping.
DelimitedRendererDSPy-style prompts. Flat, human-readable, good for simple schemas.
JsonRendererWhen the downstream LLM expects JSON input, or for backward compatibility with existing prompts.

Pass renderer= when creating a node:

from neograph import node
from neograph import XmlRenderer
@node(outputs=Summary, prompt='rw/summarize', model='reason',
renderer=XmlRenderer(include_field_info='never'))
def summarize(claims: Claims) -> Summary: ...

Set renderer= on a Construct. It propagates to every child node that does not have its own renderer set:

from neograph import Construct
from neograph import DelimitedRenderer
pipeline = Construct(
"analysis",
nodes=[discover, classify, summarize],
renderer=DelimitedRenderer(),
)

The dispatch hierarchy is:

  1. model.render_for_prompt() (model method wins)
  2. node.renderer
  3. construct.renderer
  4. BAML default (describe_value() — TypeScript-style notation)
  5. Primitives pass through unchanged

Any Pydantic model can define a render_for_prompt() method. When present, this method is called instead of the renderer — regardless of what renderer is configured on the node or construct.

from pydantic import BaseModel
class Claims(BaseModel, frozen=True):
items: list[str]
def render_for_prompt(self) -> str:
return "\n".join(f"- {item}" for item in self.items)

This is useful when a model knows best how to represent itself for an LLM, independent of the pipeline’s default renderer.

Typed projections: render_for_prompt() returning BaseModel

Section titled “Typed projections: render_for_prompt() returning BaseModel”

When render_for_prompt() returns a BaseModel instead of a string, NeoGraph automatically renders the returned model through the active renderer (or BAML default if none is configured). This lets you define typed presentation projections — slim views of your data optimized for the LLM — without manual string formatting.

from pydantic import BaseModel, Field
class ResearchPresentation(BaseModel, frozen=True):
"""What the LLM sees -- no internal fields."""
content: str
confidence: float = Field(description="0.0 to 1.0")
class ResearchData(BaseModel, frozen=True):
"""Full data for pipeline logic."""
raw_text: str
source_url: str # internal -- LLM doesn't need this
internal_score: float # internal -- LLM doesn't need this
def render_for_prompt(self) -> ResearchPresentation:
return ResearchPresentation(
content=self.raw_text.strip(),
confidence=round(self.internal_score, 2),
)

With XmlRenderer, the LLM sees:

<content>The system uses event sourcing for auditability.</content>
<confidence description="0.0 to 1.0">0.87</confidence>

The source_url and internal_score fields never reach the prompt. The projection is typed and validated by Pydantic, so you get IDE autocomplete and type checking on the presentation layer.

When render_for_prompt() returns a BaseModel, each field of the returned model becomes an individually addressable template variable in template-ref prompts. This means a projection like ResearchPresentation above exposes both {content} and {confidence} as separate template variables, in addition to the whole rendered model.

BaseModel children and list[BaseModel] children within the returned model are preserved as objects (not string-rendered), so you can use dotted access like {items[0].source} in template-ref prompts. Scalar fields are rendered through the active renderer (or BAML default).

Flattened fields are only available in template-ref prompts, not in inline prompts.

See Example 18 for a full runnable demo.

RenderedInput is the internal dataclass that bundles raw values, rendered values, and flattened fields into a single object. It is built once per node execution and consumed by the dispatch and prompt compilation layers.

build_rendered_input(input_data, renderer=None) is the single entry point that produces it. When renderer is None, BaseModel values are BAML-rendered via describe_value().

Fields:

FieldTypePurpose
rawdict[str, Any] | AnyRaw Pydantic models — for inline prompt ${var.field} dotted access
rendereddict[str, Any] | AnyBAML/rendered strings — for template-ref prompt compilation
flatteneddict[str, Any]Extra fields from render_for_prompt() BaseModel returns (template-ref only)
available_keys_inlineset[str]Keys valid for inline ${var} — raw dict keys only
available_keys_templateset[str]Keys valid for template-ref {var} — raw keys + flattened keys

The for_template_ref property merges rendered and flattened dicts into a single namespace for the prompt compiler. Flattened keys do not overwrite rendered keys if they share a name.

NeoGraph supports two prompt styles, and they receive different views of the rendered input:

Inline prompts (prompt="Summarize: ${claims}") get raw Pydantic objects. Variable resolution uses getattr chains, so ${claims.items[0].text} traverses the live model. This means full Python-level access to nested fields.

Template-ref prompts (prompt="rw/summarize") get BAML-rendered strings plus flattened fields from render_for_prompt() BaseModel returns. Template variables like {claims} resolve to the rendered string; flattened fields like {content} are available as separate variables.

Key differences:

Inline ${var}Template-ref {var}
Values receivedRaw Pydantic objectsBAML-rendered strings
Dotted access${var.field} via getattrLimited to flattened fields
Flattened fieldsNot availableAvailable as top-level vars
Framework extras (node_id, project_root)Not availableAvailable via config injection

Lists of BaseModel instances are rendered via describe_value() in the same BAML notation as single models, producing an array of typed objects. When an explicit renderer is configured, the renderer’s render() method handles the list instead.

Two container shapes are produced by the framework itself at fan-in, and the BAML default (no explicit renderer) renders them specially so you don’t have to re-teach the renderer in your prompt compiler. Both rules apply only to the BAML default — an explicit XmlRenderer/DelimitedRenderer/JsonRenderer walks dicts and lists its own way.

When an Each upstream’s fanned-out results merge back onto the bus, a downstream node consuming them as a whole receives a dict[str, BaseModel] (result-key -> model). The BAML default renders each value via describe_value(), joined by a blank line:

{
cluster_label: "auth"
matched: ["SSO", "OAuth"]
}
{
cluster_label: "observability"
matched: ["trace IDs"]
}

The keys are dropped: an Each barrier collects results in arrival order under framework-synthesized keys, so the keys carry no semantic meaning — the dict is an unordered bag of results. (If you need deterministic ordering, consume the upstream as dict[str, X] and sort on the key in a scripted node before the LLM node.)

An agent/act node that declares a tool_log output produces a list[ToolInteraction]. When a downstream LLM node consumes it, the BAML default folds each interaction into a research packet — a {tool_name}: header plus its .result (the already-rendered tool output), entries joined by a \n\n---\n\n separator:

search_records:
found 3 matching records for "SSO"
---
fetch_rfc:
fetched RFC-6749 section 4.1

The tool_name is kept as provenance — a downstream node reasoning over a tool_log must know which tool produced each result. Only the bookkeeping fields (args, duration_ms, typed_result) are omitted.

A None value renders as the empty string (""), not the literal "None". A str value passes through unchanged. Plain scalar dicts (dict[str, int], dict[str, str]) also pass through untouched — only a dict whose values are all BaseModels gets the merged-fan-out treatment.

describe_type: schema descriptions for prompts

Section titled “describe_type: schema descriptions for prompts”

The describe_type() function renders a Pydantic model’s schema in TypeScript-style notation. This is used internally for structured output instructions but is also available for custom prompt construction.

from neograph import describe_type
class Claim(BaseModel, frozen=True):
text: str
category: str
confidence: float
class Claims(BaseModel, frozen=True):
items: list[Claim]
print(describe_type(Claims))

Output:

Answer in JSON matching this schema:
{
items: [{
text: string
category: string
confidence: float
}]
}

When a nested type appears two or more times in the schema, describe_type automatically hoists it into a top-level type declaration to avoid duplication:

describe_type(Report, hoist_classes='auto') # default

Control hoisting with:

ValueBehavior
'auto' (default)Hoist classes appearing 2+ times
'all'Hoist every nested BaseModel
['ClassName']Hoist only the named classes

Enum classes are always hoisted by default (always_hoist_enums=True).


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