Skip to Content
WorkflowWorkflow.route

Workflow.route

from loomflow import Workflow wf = Workflow.route(classifier, {"a": fn_a, "b": fn_b})

Workflow.route sends each input to one of several handlers, picked by a classifier step. Use it when inputs fall into known categories that each need different handling (support tickets by department, for example). The classifier step’s output is converted to str and used as a key into routes. The matching step runs with the original input (not the classifier’s output), so handlers see what the user asked, not the routing label.


Signature

@classmethod def route( cls, classifier: StepLike, routes: Mapping[str, StepLike], *, default: StepLike | None = None, name: str = "route", telemetry: Telemetry | None = None, audit_log: AuditLog | str | Path | dict | None = None, memory: Memory | None = None, response_tone: str | None = None, max_steps: int = 100, max_visits_per_node: int = 25, ) -> Workflow: ...
ParameterTypeDefaultDescription
classifierStepLikerequiredReturns a string used as a key into routes. The output is str()-cast and .strip()-ed before lookup, so a classifier that returns “billing\n” still routes to "billing". Can be a function, Agent, or nested Workflow.
routesMapping[str, StepLike]requiredKeys are routing labels; values are handlers. Must be non-empty. Each handler receives the original input (the classifier’s output is only used for routing).
defaultStepLike | NoneNoneFallback for unmatched routing keys. When None, an unmatched key raises RuntimeError.
namestr"route"Workflow name.
telemetry / audit_log / memory / response_tone.NoneSame as Workflow.chain.
max_steps / max_visits_per_node.100 / 25Same as Workflow.chain.

Why handlers see the original input

In a hand-rolled router, you’d typically write:

label = await classifier(input) result = await {"billing": billing_h, "tech": tech_h}[label](input) # ← input, not label

Workflow.route matches that shape: the routing label is consumed by the router only; handlers see the original message. The original input is passed through to the handler automatically.


Example: Agent classifier and Agent handlers

from loomflow import Agent, Workflow billing = Agent("Handle billing.", model="claude-opus-4-7", tools=[...]) tech = Agent("Handle tech.", model="claude-opus-4-7", tools=[...]) sales = Agent("Handle sales.", model="claude-opus-4-7") classifier = Agent( "Classify the request as 'billing', 'tech', or 'sales'. " "Reply with exactly one of those words.", model="claude-haiku-4-5", # cheap classifier; expensive specialists ) support = Workflow.route( classifier, {"billing": billing, "tech": tech, "sales": sales}, default=tech, # ambiguous → tech name="customer_support", ) result = await support.run( "My credit card was charged twice for last month's invoice.", user_id="user_42", ) print(result.visited) # ['classify', 'route_billing'] print(result.output) # ← billing agent's response

Example: function classifier

The classifier doesn’t need to be an Agent. A pure function works:

async def classify_by_keyword(text: str) -> str: text = text.lower() if any(w in text for w in ("invoice", "charge", "refund")): return "billing" if any(w in text for w in ("error", "bug", "broken")): return "tech" return "general" wf = Workflow.route( classify_by_keyword, {"billing": billing_handler, "tech": tech_handler}, default=general_handler, )

For RAG / structured-tag classifiers, use whatever’s appropriate for your latency budget. An LLM classifier is overkill if a regex works.


What the resulting graph looks like

Workflow.route builds:

Each route_<key> is a node; the classifier is followed by an add_router edge that maps classifier output → next node. The underlying graph is exposed via the same add_node / add_edge / add_router API documented in the explicit graph builder.


Routing-key normalization

The classifier’s output is normalized via str(value).strip() before lookup. A classifier that emits:

OutputLookup key
"billing""billing"
" billing\n""billing"
"BILLING""BILLING" (case-sensitive)
42"42"

Case sensitivity is intentional. Your route keys should match the classifier’s emission convention, not assume normalization. If your classifier sometimes returns mixed case, lowercase inside the classifier:

async def normalized(text: str) -> str: return (await classifier.run(text)).output.strip().lower()

Unmatched keys

wf = Workflow.route(classifier, {"a": fn_a}, default=None) result = await wf.run("hello") # classifier returns 'b' → no match, no default # → RuntimeError: router on 'classify' produced key 'b' with no matching route # and no default

With a default=, the same call routes to default instead of raising:

wf = Workflow.route(classifier, {"a": fn_a}, default=fn_default) result = await wf.run("hello") # routes to route_default

When to reach for route

  • Customer support: Billing / tech / sales triage.
  • Coding agent gateway: Language-specific specialists.
  • Search front-end: Multiple retrieval strategies, picked by query shape.

For multi-domain tasks (research + write + review), use Supervisor instead. Router / Workflow.route commits to one specialist; Supervisor can delegate to many.

Workflow.route vs Router architecture. Both classify-and-dispatch. The Router architecture lives inside an Agent (its base loop sees the routes); Workflow.route lives at the workflow layer. Use the architecture when you want the routing wrapped in agent semantics (retries, structured outputs, full ReAct-style tool access at the Agent level); use the workflow when you want a deterministic graph that other workflow nodes can route into / out of.

Last updated on