LangGraph Unit-Node Interaction Model — Concept Exploration
Status: ACTIVE (conversation observation) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-11T19:10:00Z Claim: synthesis | 2026-05-11T19:05:00Z Session: MjF conceptual model — units as state cases moving through task nodes with access gating, config overrides, and passive intel harvesting
Prior Context
- [[langgraph-node-anatomy]] — Full node anatomy catalog
- [[langgraph-gameplay-modes]] — Auto-run vs control mode (deferred, focusing on interactive layer first)
- [[langgraph-tool-execution-tradeoffs]] — LangChain tools vs external harnesses
- This page: interactive-layer model. No auto-run. Just units, nodes, access, and intel.
Core Model (As Observed)
Unit = Persistent State Case
A unit is an entry in LangGraph state. It carries:
- Identity (unit_id, name)
- Role ("researcher", "designer", "developer", "reviewer")
- Default config (model, harness, temperature)
- Access lists (context_access and context_block)
- Per-node config overrides
- Output log (what it's produced at each node visited)
As the unit moves through 4 nodes (research → design → development → review), each node is a separate LangGraph invocation — but the unit's state persists across all of them via the checkpointer. The unit's backpack stays on its back.
# State entry for a unit
{
"unit_id": "rif",
"name": "Rif",
"role": "researcher",
"default_config": {
"model": "deepseek-v4",
"harness": "opencode"
},
"node_overrides": {
"design_node": {
"model": "kimi-k2.6",
"harness": "hermes"
},
"review_node": {
"model": "claude-sonnet",
"harness": "opencode"
}
},
"context_access": [
"wiki/research/langgraph-pi.md",
"unit_sherpa/research_output",
"unit_echo/design_output"
],
"context_block": [
"wiki/research/deprecated-approach.md"
],
"output_locales": {
"research_node": "wiki/research/feature-x-research.md",
"design_node": "forgejo/issue/42",
"development_node": "forgejo/pr/17",
"review_node": "wiki/review/feature-x-review.md"
},
"history": [
{"node": "research_node", "timestamp": "...", "output": "wiki/research/..."},
{"node": "design_node", "timestamp": "...", "output": "forgejo/issue/42"}
]
}
Node = Task Space With Output Locale
Each node is a LangGraph node function. When activated: 1. It identifies which unit is present (from state) 2. It resolves the effective config (base + per-node override) 3. It resolves the access context (fetch only allowed sources, skip blocked ones) 4. It runs its task (LLM call, tool work, whatever) 5. It writes output to a locale (wiki page, Forgejo issue, file) 6. It returns state update with output pointer, any new intel, updated unit state
The node's output is not just the work product — it's a pointer to where the work lives:
def research_node(state: GameState, config, runtime):
unit = state["units"][state["active_unit"]]
effective_config = resolve_config(unit, "research_node")
context = fetch_context(unit["context_access"], unit["context_block"])
# Do the research work with resolved config and context
agent = runtime.context.get_agent(effective_config["harness"])
result = agent.research(
task=state["task"],
context=context,
model=effective_config["model"]
)
# Write to output locale
locale = write_to_wiki(result, "wiki/research/feature-x-research.md")
return {
"unit_output": {"research_node": locale},
"unit_history": [{"node": "research_node", "output": locale, "summary": result.summary}]
}
Access Gating = Context Fetch With Allow/Block Lists
Before a node runs its task, it resolves what context it can see:
def fetch_context(access: list[str], block: list[str]):
"""Only fetch sources in access list that are not in block list."""
context = {}
for source in access:
if source in block:
continue # explicitly blocked
context[source] = resolve_source(source) # wiki page, other unit's output, etc.
return context
Sources can be:
- Wiki pages (wiki/research/langgraph-pi.md or wiki/research/langgraph-pi.md#section-3)
- Other unit outputs (unit_sherpa/research_output — the research output of another unit named Sherpa)
- Forgejo issues / PRs (forgejo/issue/42)
- External URLs (raw docs links)
- Named intel items (intel/pi-torch-001)
Access is configured per-unit. It persists across nodes by default — the unit's access lists travel with it. But can be overridden per-node (a node can have its own access list that merges with or replaces the unit's default).
Config Overrides = Per-Node Model/Harness Switching
The unit has a default config. Each node can override it:
def resolve_config(unit, node_name):
base = unit["default_config"] # {"model": "deepseek-v4", "harness": "opencode"}
overrides = unit.get("node_overrides", {}).get(node_name, {})
return {**base, **overrides} # overrides win
So Unit Rif runs research on deepseek-v4/opencode by default, but at the design node it switches to kimi-k2.6/hermes because the override says so. At the next node (development), if there's no override, it falls back to deepseek-v4/opencode. The trailing config is the unit's default — overrides are per-node exceptions.
Trailing Context = State Is the Backpack
If you don't override access per-node, the unit's context_access and context_block lists persist from node to node. After moving through 4 nodes, the unit's history accumulates (output locales visited), its config travels (base + any per-node overrides), its access lists travel. State is the backpack — LangGraph's checkpointer ensures it survives between invocations.
Passive Intel Harvesting (Resource Queue)
Harvester Units
A harvester is a unit with role: "harvester". It runs in the background, prospecting for intel. It doesn't move through the production pipeline — it feeds it.
The harvester: 1. Receives a prospecting directive ("research LangGraph + Pi agent integration") 2. Pulls down documentation, scans repos, queries APIs 3. Stages findings into the wiki 4. During a review pass, flags valuable intel chunks
Flagged Intel Items
When a harvester identifies something valuable, it creates a named intel item:
{
"intel_id": "pi-torch-001",
"name": "pi torch",
"description": "pattern for welding Pi agents into LangGraph nodes natively — subprocess bridge with JSON handoff",
"source": "wiki/research/langgraph-pi-integration.md#section-3",
"source_excerpt": "...",
"confidence": 0.85,
"harvested_by": "sherpa",
"harvested_at": "2026-05-11T19:00:00Z",
"suggested_for": [
"unit_rif/design_node",
"unit_echo/architecture_node"
],
"tags": ["pi", "langgraph", "integration", "runtime-bridge"],
"status": "pending" # pending, accepted, rejected, consumed
}
The harvester doesn't just dump findings — it identifies what the finding is RELEVANT TO. "Pi torch" explains something essential to Pi+LangGraph integration. The harvester suggests it for Rif's design node and Echo's architecture node because those nodes are working on adjacent problems.
Routing Suggestions = Conditional Intel Delivery
When a node activates, before it runs its task, it checks: are there any pending intel items suggested for this unit+node combination?
def pre_node_intel_check(state, unit_id, node_name):
pending = [i for i in state["intel_queue"]
if unit_id in i["suggested_for"]
and i["status"] == "pending"]
if pending:
# Present suggestions to human via interrupt
choices = interrupt({
"message": f"Intel available for {node_name}:",
"items": [{"name": i["name"], "description": i["description"], "source": i["source"]} for i in pending]
})
# Human selects which to accept
for item in choices:
item["status"] = "accepted"
# Add to unit's context_access for this and future nodes
add_to_access(state, unit_id, item["source"])
This is not auto — it's a suggestion. The human sees: "Pi Torch intel is available. Include it?" They can accept (add to access), reject (mark consumed), or defer (leave pending). The enabling happens on either surface: the research lab (harvester management view) or the node (just-in-time suggestion).
Modular Intel Items = Named, Reusable, Routable
"Pi torch" is not a document. It's a named pointer to a chunk of intel. It can be: - Routed to multiple nodes - Accepted by some, rejected by others - Referenced by name in other intel items ("see also: pi torch") - Composed into larger briefs for other units - Deprecated when superseded
The name is a handle — it makes intel discussable, shareable, and trackable across the graph.
Mapping to LangGraph Primitives
| Concept | LangGraph Mapping | How |
|---|---|---|
| Unit identity | State entry with unit_id key | Persists via checkpointer across invocations |
| Unit config | State dict: default_config, node_overrides |
Resolved at node activation, merge with base |
| Node as task space | LangGraph node function | Each node is a function that does a type of work |
| Output locale | State key: output_locales[node_name] |
Written by node, points to wiki/Forgejo/file |
| Access gating | context_access + context_block lists in state |
Node's pre-processing reads these, fetches only allowed |
| Config override | node_overrides[node_name] dict |
Resolved at activation, overrides base config |
| Trailing context | State persistence | Unit's lists travel in state, survive invocations |
| Harvester | Separate LangGraph thread or node | Runs independently, writes intel to Store or state |
| Intel item | State entry with name, source, suggestions, status | Created by harvester, consumed by production nodes |
| Auto-suggest routing | interrupt() at node activation |
Human decides what intel to accept into access list |
| Enabling on either surface | State mutation via Command | Accept intel → update access list in state |
| Intel item as handle | Named pointer in state | Referenced by name, not embedded content |
Observed Properties
The Unit IS the State
Everything about a unit — its role, config, access lists, node overrides, history, output pointers — lives in LangGraph state. This means:
- It survives between invocations (checkpointer)
- It can be inspected at any time (graph.get_state(config))
- It can be rewound (time-travel to previous state)
- It can be modified mid-run (flip a config, add access, change role)
Nodes Are Stateless Functions Over Unit State
A node doesn't own the unit. It receives the unit from state, does work, and returns updates. The unit's identity is independent of any single node. This separation means nodes can be composed and recomposed — the unit stays the unit regardless of which nodes it visits.
Access Is Explicit and Gated
Every piece of context the unit sees is in its access list. If it's not in the list, the unit can't see it. This prevents context pollution (a design node flooded with irrelevant research) while allowing cross-pollination (a design node pulling from another unit's research output) — but only when explicitly allowed.
Intel Is Passively Harvested, Actively Routed
Harvesters work continuously in the background. They don't block the pipeline. They surface findings as suggestions. The human gates what enters the production pipeline. Nothing auto-injects into a unit's context without human approval.
Open Questions (Not Prescriptive)
- Should access lists be hierarchical? (Unit-level default + node-level overrides that merge or replace?)
- Can a harvester flag intel for nodes that haven't been created yet? (Pre-positioned context for future work)
- What happens when a unit's access block conflicts with another unit's access grant for the same node? (Cross-unit access collisions)
- Should intel items have an expiry or staleness mechanism? (Intel that ages out)
- How does the rendering surface present intel suggestions during node activation? (UI for the interrupt prompt)