Control execution flow with conditional routing. Route to different paths based on data, loop for agentic workflows, or terminate early.
- @ifelse - Simple boolean routing: True goes one way, False goes another
- @route - Route execution to one of several target nodes based on a function’s return value
- END - Sentinel indicating execution should terminate along this path
- multi_target - Route to multiple nodes in parallel when needed
When to Use Routing
Routing solves problems that pure DAGs cannot:
| Pattern | Example | Why DAGs Fail |
|---|
| Conditional paths | Route based on document type | DAGs execute all branches |
| Early termination | Stop if cache hit | DAGs run to completion |
| Agentic loops | Retry until quality threshold | DAGs have no cycles |
| Multi-turn conversation | Continue until user satisfied | DAGs are single-pass |
Binary Branching with @ifelse
For simple true/false decisions, use @ifelse. It’s cleaner than @route when you only have two paths.
Basic If/Else
from hypergraph import Graph, node, ifelse, END, SyncRunner
@node(output_name="is_cached")
def check_cache(query: str) -> bool:
return query in cache
@ifelse(when_true="use_cache", when_false="full_retrieval")
def cache_gate(is_cached: bool) -> bool:
return is_cached
@node(output_name="response")
def use_cache(query: str) -> str:
return cache[query]
@node(output_name="response")
def full_retrieval(query: str) -> str:
return expensive_rag_pipeline(query)
graph = Graph([check_cache, cache_gate, use_cache, full_retrieval])
runner = SyncRunner()
result = runner.run(graph, {"query": "What is RAG?"})
print(result["response"])
The function returns True or False. Based on the result:
True → routes to when_true target
False → routes to when_false target
Early Termination with END
from hypergraph import ifelse, END
@ifelse(when_true="process", when_false=END)
def should_process(is_valid: bool) -> bool:
return is_valid # False terminates, True continues
When when_false=END, returning False terminates execution along this path.
When to Use @ifelse vs @route
| Use @ifelse when… | Use @route when… |
|---|
| Two mutually exclusive paths | Three or more paths |
| Decision is boolean | Decision returns a name |
| No fallback needed | Need fallback for None |
| Simple branching | Multi-target routing |
Flexible Routing with @route
Route to One of Several Targets
from hypergraph import Graph, node, route, END, SyncRunner
@node(output_name="complexity")
def analyze(document: str) -> str:
"""Classify document complexity."""
if len(document) < 100:
return "simple"
elif "diagram" in document.lower():
return "visual"
return "complex"
@route(targets=["simple_path", "visual_path", "complex_path"])
def choose_path(complexity: str) -> str:
"""Route based on complexity analysis."""
if complexity == "simple":
return "simple_path"
elif complexity == "visual":
return "visual_path"
return "complex_path"
@node(output_name="response")
def simple_path(document: str) -> str:
return f"Quick answer: {document[:50]}"
@node(output_name="response")
def visual_path(document: str) -> str:
return f"Processing visual content: {document}"
@node(output_name="response")
def complex_path(document: str) -> str:
return f"Deep analysis: {document}"
graph = Graph([analyze, choose_path, simple_path, visual_path, complex_path])
runner = SyncRunner()
result = runner.run(graph, {"document": "This contains a diagram of the architecture"})
print(result["response"]) # "Processing visual content: ..."
The routing function examines data and returns the target node name. Only that node executes.
Terminate Early with END
from hypergraph import route, END
@route(targets=["process", END])
def check_cache(query: str) -> str:
"""Skip processing if answer is cached."""
if query in cache:
return END # Stop here, don't run "process"
return "process"
When a route returns END, execution terminates along that path. Other independent paths continue.
Real-World Example: RAG with Diagram Detection
Documents containing diagrams need special handling - convert pages with diagrams to images and send them alongside text to a multimodal LLM.
from hypergraph import Graph, node, route, END, SyncRunner
@node(output_name="documents")
def retrieve(query: str, embedding: list[float]) -> list[dict]:
"""Retrieve relevant documents from vector store."""
return vector_db.search(embedding, top_k=5)
@node(output_name="doc_analysis")
def analyze_documents(documents: list[dict]) -> dict:
"""Analyze documents to detect which pages contain diagrams."""
analysis = {
"has_diagrams": False,
"diagram_pages": [],
"text_content": [],
}
for doc in documents:
for page_num, page in enumerate(doc["pages"]):
if page.get("has_diagram"):
analysis["has_diagrams"] = True
analysis["diagram_pages"].append({
"doc_id": doc["id"],
"page_num": page_num,
"content": page["content"],
})
analysis["text_content"].append(page["text"])
return analysis
@route(targets=["text_only_response", "multimodal_response"])
def route_by_content(doc_analysis: dict) -> str:
"""Route based on whether documents contain diagrams."""
if doc_analysis["has_diagrams"]:
return "multimodal_response"
return "text_only_response"
@node(output_name="response")
def text_only_response(query: str, doc_analysis: dict) -> str:
"""Generate response using text-only LLM."""
context = "\n".join(doc_analysis["text_content"])
return llm.generate(
model="gpt-5.2",
messages=[
{"role": "system", "content": f"Context:\n{context}"},
{"role": "user", "content": query},
],
)
@node(output_name="response")
def multimodal_response(query: str, doc_analysis: dict) -> str:
"""Generate response using multimodal LLM with diagram images."""
# Convert diagram pages to images
images = []
for page_info in doc_analysis["diagram_pages"]:
image = pdf_to_image(page_info["doc_id"], page_info["page_num"])
images.append(image)
# Combine text and images for multimodal LLM
context = "\n".join(doc_analysis["text_content"])
return multimodal_llm.generate(
model="gpt-5.2",
messages=[
{"role": "system", "content": f"Context:\n{context}"},
{"role": "user", "content": [
{"type": "text", "text": query},
*[{"type": "image", "image": img} for img in images],
]},
],
)
# Build the graph
rag_with_diagrams = Graph([
retrieve,
analyze_documents,
route_by_content,
text_only_response,
multimodal_response,
])
# Run
runner = SyncRunner()
result = runner.run(rag_with_diagrams, {
"query": "Explain the system architecture",
"embedding": [0.1, 0.2, ...],
})
print(result["response"])
The routing logic is clean and explicit:
Retrieve documents
Fetch relevant documents from vector store
Analyze for diagrams
Detect which pages contain visual content
Route to appropriate LLM
Text-only or multimodal based on content type
Generate response
Use the right model with the right inputs
Multi-Target Routing
Sometimes you need to run multiple paths in parallel. Use multi_target=True.
@route(targets=["notify_slack", "notify_email", "log_event"], multi_target=True)
def choose_notifications(event_type: str, severity: str) -> list[str]:
"""Route to multiple notification channels based on event."""
targets = ["log_event"] # Always log
if severity == "critical":
targets.extend(["notify_slack", "notify_email"])
elif severity == "warning":
targets.append("notify_slack")
return targets
@node(output_name="slack_sent")
def notify_slack(message: str) -> bool:
return slack.send(message)
@node(output_name="email_sent")
def notify_email(message: str) -> bool:
return email.send(message)
@node(output_name="logged")
def log_event(message: str) -> bool:
return logger.info(message)
With multi_target=True, the function returns a list of targets to execute. All returned targets run (potentially in parallel with AsyncRunner).
When using multi_target=True, target nodes must have unique output names. If multiple nodes produce the same output name, you’ll get a GraphConfigError at build time.
Fallback Targets
When a routing function might return None, use fallback to specify a default:
@route(targets=["premium_path", "standard_path"], fallback="standard_path")
def route_by_tier(user_tier: str | None) -> str | None:
"""Route premium users to premium path."""
if user_tier == "premium":
return "premium_path"
return None # Falls back to standard_path
Validation and Error Handling
Build-Time Validation
Hypergraph validates routing at graph construction:
@route(targets=["step_a", "step_b", END])
def decide(x: int) -> str:
return "step_c" # Typo - not in targets
graph = Graph([decide, step_a, step_b])
# GraphConfigError: Route target 'step_c' not found.
# Valid targets: ['step_a', 'step_b', 'END']
# Did you mean 'step_a'?
Type Safety
Routing functions must be synchronous and non-generator:
# This raises TypeError at decoration time:
@route(targets=["a", "b"])
async def async_decide(x: int) -> str: # Can't be async
return "a"
# Error: Routing function 'async_decide' cannot be async.
# Routing decisions should be fast and based on already-computed values.
Move complex async logic to a regular @node before the gate. Keep routing functions fast and deterministic.
Patterns and Best Practices
Keep Routing Functions Simple
Routing functions should be fast and deterministic. Move complex logic to regular nodes:
# Good: Routing based on pre-computed value
@node(output_name="doc_type")
def classify(document: str) -> str:
"""Heavy classification logic here."""
return classifier.predict(document)
@route(targets=["pdf_processor", "image_processor", "text_processor"])
def route_by_type(doc_type: str) -> str:
"""Simple routing based on classification result."""
return f"{doc_type}_processor"
Use Descriptive Targets
You can provide descriptions for visualization and documentation:
@route(targets={
"text_only_response": "Use text-only LLM for documents without visual content",
"multimodal_response": "Use vision LLM for documents containing diagrams",
END: "Terminate if no relevant documents found",
})
def route_by_content(doc_analysis: dict) -> str:
...
What’s Next?