Domain-Driven Flow Design
A flow is a program whose only shared state is the variables dict. Every node reads through input_map and writes through output_variable_path. A well-designed flow treats variables as its domain model, shaped deliberately rather than accumulated node by node.
This skill is your reference for explaining or reviewing how a flow's variables namespace and node choices hold together. Companion: flow-qa — the structural/wiring checklist for a flow you're reviewing end to end.
Core Principle — One Namespace, Shaped as Domains
There is exactly one shared store: variables. Every downstream input_map path resolves into it, every output_variable_path writes into it.
A good flow does not treat variables as a bag of flat keys. It shapes it as domain dicts — each top-level key is a bounded context. This keeps the namespace readable as the flow grows, and makes node responsibilities visible.
// Bad — flat, every node scribbles in the same scope:
{
"city": "Amsterdam",
"temperature": 18.5,
"service_account_info": {...},
"model_id": "gpt-5",
"jira_url": "..."
}
// Good — DDD-style, domains are explicit:
{
"request": { "city": "Amsterdam", "units": "celsius" },
"weather": { "temperature": null, "conditions": null },
"gchat": { "service_account_info": {...} },
"model": { "model_id": "gpt-5", "provider_id": "openai" },
"jira": { "base_url": "...", "project_key": "..." }
}
Input map then reads as intent:
{
"city": "variables.request.city",
"jira_cfg": "variables.jira"
}
What Good variables Design Looks Like — Four Rules
1. Every path should be declared — but a missing one doesn't crash, it goes quietly wrong
Every variable a node reads should exist in the start node's variables at session start, even if the value is initially null. But when a path IS missing, map_variables_to_input does not raise. It catches the lookup failure, logs a warning, and sets the value to None — or to the literal string "not found", only when the caller passes set_missing_variables=True (used for output_map resolution). That makes a missing path worse than a crash in practice: no stack trace, just a None that surfaces several nodes downstream as a confusing bug.
When reviewing a flow, don't treat "it won't crash" as safety — trace every input_map path back to a start-node declaration or an upstream writer regardless.
Two real, intentional escape hatches exist for paths that may legitimately be absent:
- Pipe-default syntax —
"key": "variables.a.b|fallback"in aninput_mapvalue suppliesfallbackwhen the path is missing. The fallback is coerced:null/none→None,true/false→ bool, all-digits →int, anything else stays a string. - List-index paths —
variables.items[0]resolves through a list index, not just dict keys.
Both are the correct answer to "what if this path may not exist" — prefer them over letting a silent None propagate.
2. Group by bounded context, not by data shape
Top-level keys are domains — things the business talks about. Typical domains:
request— the invoking user's input (what the webhook/trigger brought in, or the initial question).config(or per-system:jira,gchat,slack) — credentials and endpoints for external systems.- Output domains named after what is produced (
weather,report,ticket,transcript). session— optional; state that survives across turns of the same session.
A domain named after a node (e.g. python_node_1) is a smell — domains are nouns in the problem space, not implementation artifacts.
3. One writer per path
Two nodes writing the same variables.<path> makes execution order hidden logic. Each path should have exactly one writer. If two nodes enrich the same thing, they should write to different subpaths and merge in a single dedicated node:
fetch_weather→variables.weather.rawformat_weather→variables.weather.report_text- downstream consumers read whichever they need.
4. Readers explicitly name what they need
input_map keys are local kwargs — they should name the parameter the function takes, not the domain path:
{
"city": "variables.request.city",
"weather_data": "variables.weather.raw"
}
This keeps a node self-documenting — reading its main() signature tells you exactly what it consumes.
Python Node main() — The Implicit Input Map
When a python node has no explicit input_map, the runtime auto-generates one from main() parameter names: each param foo maps to variables.foo.
Consequences worth flagging when reviewing:
- Granular parameter names matter.
def main(city, units)maps tovariables.city,variables.units— flat, top-level. - A parameter named
variablescreatesvariables: "variables.variables"— almost always a bug. - To read a nested path, the node needs an explicit
input_map.def main(jira)withinput_map = {"jira": "variables.jira"}works fine.
A node whose parameter names don't line up with the domain structure, and has no explicit input_map to compensate, is reading the wrong thing at runtime — worth calling out.
Choosing the Right Node for a Responsibility
The question when explaining a node's presence in a flow is always: "What is the right primitive for this responsibility?"
| Responsibility in the spec | Node |
|---|---|
| Fetch data from a known API, deterministic inputs/outputs | python |
| Validate a payload, shape an error response | python |
| Map a raw API response into domain objects | python |
| Pick one of a small fixed set of targets by a Python boolean rule | decision_table if 3+ branches; conditional_edge if 2 |
| Decide next step using LLM judgment over free text | classification_decision_table |
| Compose a narrative, summarize, hold a persona, or converse — as one wiring point in the graph | task (its own agent_definition, one node = one wiring point) |
| Same, but as an ordered sequence of sub-steps under one persona (e.g. draft → critique → revise) that only needs one entry/exit in the graph | agent (bundles an ordered list of internal sub-tasks; nothing else in the graph can wire between them) |
| Parse a user-uploaded document | file_extractor |
| Transcribe an audio message | audio_transcription |
| Start on an external event or schedule | webhook_trigger / telegram_trigger / schedule_trigger |
| Reuse a whole existing flow | subgraph |
Heuristics that matter:
- If the logic is a pure function of structured inputs,
pythonis right. It is cheaper, faster, and more deterministic than any LLM-backed node. taskandagentare both LLM-backed graph nodes, each with its ownagent_definition— they are alternatives, not a pair. Atasknode is one LLM step with its owninstructions,output_schema, and wiring point; use it when a single open-ended step (drafting a reply, summarizing free text, deciding between options that don't reduce to a boolean rule) needs to sit in the graph like any other node, with its own edges in and out. Anagentnode bundles an ordered list of internal sub-tasks (each with its owninstructionsandoutput_schema, able to reference earlier sub-tasks as context) under oneagent_definition— use it when several LLM sub-steps genuinely belong together as one unit, and nothing else in the graph needs to intervene between them. The sub-tasks inside anagentnode are not separate graph nodes and can't be individually wired.crewis the older project/crew abstraction that predatesagent/task. It still runs in legacy flows and is deprecated — a flow being reviewed today should be usingagent/task, notcrew. Flag acrewnode in an otherwise-new flow as worth migrating, not as broken.classification_decision_tableis the LLM-backed node for branching — it applies an LLM's judgment as a routing rule over fuzzy text.agentnodes are also LLM-backed, but for generation, not branching; don't reach for anagentto pick between a fixed set of outcomes — that's whatclassification_decision_tableis for.- Use
decision_tablewhen branching is a business rule expressible as Python boolean expressions over variables. Useconditional_edgewhen branching is a short Python expression that returns a target node's name and there are just two paths. - Use
subgraphwhen the sub-workflow is genuinely reusable and has its own lifecycle. Copy-pasting nodes is worse than a subgraph, but a subgraph called only once is pure indirection.
Contracts — The Shape of Data Between Nodes
A well-specified flow has a clear contract for each edge — useful to reconstruct when explaining how data moves through a flow:
| From node | Writes | Shape | Read by |
|---|---|---|---|
Weather Request (webhook_trigger) |
variables.request.city: str |
non-empty string, else 400 | Fetch Weather |
Fetch Weather (python) |
variables.weather.raw |
{temperature: float, conditions: str, humidity: int, wind_speed: float} |
Format Report |
Format Report (python) |
variables.weather.report_text: str |
multi-line string | Friendly Reporter |
Friendly Reporter (task) |
variables.weather.narration: {message: str, ...} |
shaped by the node's output_schema |
__end_node__ |
If this table can't be filled in from what the tools return, the flow's contracts are ambiguous — flag it as an open question rather than guessing the shape.
Patterns That Work
Trigger + manual dual-entry
Triggers have no input port, so both __start__ and the trigger fan into the first real node:
__start__ ──▶ Validate Request
Weather Request ──▶ Validate Request
Without the __start__ leg, manual "Run" fails: "No node connected to start node".
Validate-then-proceed
Right after a trigger, a python node whose only job is to validate inputs and shape errors is the expected pattern. It returns {"error": "...", "status": 400} on bad input, routed by a decision_table that short-circuits to end on error, otherwise continues.
Fan-in at end node
Multiple success/error paths converge on __end_node__, whose output_map picks the right fields. That's cheaper than funneling every path through one pre-end "merge" node.
Enrichment pipeline
request → fetch_raw → normalize → classify → respond. Each node writes to its own subpath (variables.raw, variables.normalized, variables.classification). Downstream nodes read only what they need.
Decision-table routing with manipulation
When a branch also needs to tweak variables before routing, the right place is the group's manipulation field on the decision-table node — not a follow-up python node. It keeps the routing atomic.
Patterns That Break
- Flat variable namespace.
variables.city,variables.api_key,variables.temperature— grows into a minefield. Domain dicts are the fix. - Two writers, one path. Order-dependent correctness. Always resolvable by splitting into subpaths.
- Undeclared variables. Every
input_mappath should exist at session start — even though a missing one won't crash (see Rule 1), it will misbehave silently. agent/taskwherepythonwould do. If the work is deterministic and typed, an LLM adds latency, cost, and non-determinism for no benefit.pythonnode as a hidden router. If branching is the node's real job, it belongs in adecision_tableorconditional_edgeinstead — visible in the graph rather than buried in code.- Decision-table node with overlapping groups. First match wins, in declared order. Ambiguous rules silently route to the first listed group. Order groups deliberately or write mutually exclusive conditions.
- Webhook handler that returns nothing. The returned dict merges into
variables. A handler that doesreturn Noneor omitsreturnwrites nothing — downstream reads will fail. - Changing domain shape mid-flow. Node A writing
variables.user = "alice"then node B overwritingvariables.user = {"name": "alice"}is a bug waiting to happen. One shape per path, picked up front.
Persistent Variables — Cross-Session State
By default, every session starts fresh from the start node's variables. A flow can carry state forward across sessions by declaring persistent variables — but only one scope of the feature is actually implemented. Know the gap before explaining it to anyone.
How it works, and what's actually wired up
Graph.enable_persistent_variablesis read-only and derived — there is no manual toggle. It flipstruethe moment the start node declares at least oneorganizationpath, and back tofalsewhen the last one is removed.- The start node's
variablesdeclares two lists under apersistent_variableskey:{ "variables": { "context": { "history": [], "user_prefs": {} } }, "persistent_variables": { "organization": ["context.user_prefs"], "user": ["context.history"] } } - Only the
organizationlist does anything at runtime. Session start merges onlyGraphOrganization.persistent_variablesin; session end writes back only theorganizationpaths. Both are gated onGraph.enable_persistent_variables. - The
userlist is inert. The per-user storage row is only everget_or_created so the session has a valid FK to point at — nothing is ever read from it or written to it. Declaring a path underusersaves fine and does nothing at runtime: no per-user state persists. - Because the flag derives from
organizationpaths only, a start node that declares paths ONLY underuserleavesenable_persistent_variablesfalse — meaning nothing persists at all, not even in the sense of the inertuserpaths looking active.
Validation is loud, not silent
Every declared path — in both organization and user lists — must already exist under variables in the same payload, or the save is rejected outright at validation time with a ValidationError naming the missing path. There is no silent-drop behavior for an undeclared persistent path; the platform refuses the save up front.
What's genuinely unchecked
- No duplicate-across-scopes check. A path can legally appear in both
organizationanduser— nothing stops it (moot in practice today, sinceuserdoesn't persist anyway). - No array-index check. Declared paths must navigate object properties only (
context.history, notcontext.items.0orcontext.items[0]) — pointing one at a list index isn't rejected, it just misbehaves silently at merge time.
Worth checking when reviewing
- Persistent paths are usually nested under a domain dict (e.g.
variables.context) — convention only, not enforced; a flatvariables.counterworks identically. Nesting is there to avoid the flat-namespace problems above, not because the platform requires it. - The declared path must be the exact path the node writes to. If a node's
output_variable_pathisvariables.context.counterbut the declared persistent path iscounter(i.e.variables.counter), the session runs fine and updates the mismatched path in-session — but the declared path never changes, so nothing gets written back, and no error surfaces anywhere. Worth double-checking whenever a node has been copied between flows with a different variable layout.
When it's the right call
- Flow-wide (organization-scoped) shared state today: shared context, org-level counters, config that should stick across every user's runs.
- Not yet for per-user memory — the
userlist exists in the schema but does nothing. If someone wants per-user isolation across sessions, say so plainly rather than pointing them at theuserlist as if it worked. - Not for secrets that should always be pulled fresh from a vault — persistent values sit in the DB and can go stale.
Review Checklist
When explaining or reviewing a flow's variable design, you should be able to answer:
- What are the domains in
variables? Name each with a business noun. - For every domain, what paths exist at session start? (They should all be in the start node, even as
null.) - For every node, what does it read? What does it write? Is the type/shape clear?
- Is each path written by exactly one node?
- Where are the branches —
decision_tableorconditional_edge? What are the groups and their targets? - Where are the end points? What does
output_mappick fromvariables? - Is there a trigger? If so, is
__start__also connected to the first real node (dual entry)? - For each
tasknode: what tools does it need, and what's itsoutput_schema? For eachagentnode: what tools/surfaces does it need, and what's each internal sub-task'soutput_schema? - Does the flow need cross-session state? If so, which paths are declared under
organization(the only scope that actually persists), and are they nested under a domain dict likevariables.context?
When every question has a concrete, tool-grounded answer, the design holds together. Anything you can't answer from the tools is an open question — say so rather than guessing.