Skip to content

Advanced Routing

Claude edited this page Oct 18, 2026 · 21 revisions

Routing patterns

Patterns of routers on the paths (#272, #280): one choice, routers in a chain, a router in a loop, a branch by the result of the chosen step, a router's failure, a rule decided by code. The grammar is on Workflow, the router component on Routers. Everything here is in the root entry agboss (start, router, route, caseOf, toAction, toFinish, …); parallel launches, quorums and child workflows build on these paths later (see the end).

The nodes used below (ordinary components):

1. One choice, routers in a chain

A router's choice lists its routes, each continued to its end. A route may lead to another router — here the front router hands money matters to a second one:

@Workflow({
  name: "front-desk-workflow",
  version: "1.0.0",
  defaults: { router: { model: "typesafe/jev-1.13" } }, // the routers' model
  flow: [
    start(TicketWorkflowStart)
      .returns(WorkflowStartTextDto)
      .toRouter(FrontRouter)
      // 4. the router's own failure: a failed call or a not-allowed answer goes to a finish
      .onError(RouterFailedError, toFinish(UnroutedWorkflowFinish))
      .chooseOne(
        // 1. a route to another router: its choice is continued right there
        route(MoneyRouter)
          .chooseOne(
            // 2. a branch by the result of the chosen agent
            route(BillingAgent)
              .returns(oneOf(AnswerDto, NeedsHumanDto))
              .switch(
                caseOf(AnswerDto)
                  .toFinish(AnswerWorkflowFinish),
                caseOf(NeedsHumanDto)
                  .toFinish(EscalatedWorkflowFinish),
              ),
            route(RefundAgent)
              .returns(AnswerDto)
              .toFinish(AnswerWorkflowFinish),
          ),
        route(ChatAgent)
          .toFinish(ReplyWorkflowFinish), // a text agent answers text
        // 3. a router in a loop: the triage agent is reached from two places → its own path
        route(TriageAgent),
        route(UnroutedWorkflowFinish),
      ),

    agent(TriageAgent)
      .returns(TicketDto)
      .toRouter(TicketRouter)
      .chooseOne(
        route(OpenTicketAction)
          .returns(AnswerDto)
          .toFinish(AnswerWorkflowFinish),
        route(TriageAgent), // back: maxVisits bounds the loop
      ),
  ],
})
export class FrontDeskWorkflow {}

2. A branch by the chosen step's result

An agent with a oneOf output continues with .returns(oneOf(…)).switch(caseOf(…)…) — one case per variant, each its own path; a missing or unknown case fails assembly ([flow.switch-not-exhaustive], [flow.switch-unknown-case]). Asking or refusing is just a case that leads to a finish.

3. A router in a loop

A node reached from two or more places is continued in its own path (agent(TriageAgent)…, router(R)…), and every path into it ends at it — the loop is visible. Every cycle needs maxVisits on one of its nodes ([graph.cycle-without-max-visits]); a visit past it fails before the model call with VisitsLimitExceededError, which .onError right after that step can take.

4. When the router fails

.onError(…) right after .toRouter(R) takes the router's failure (#280 S19); a side path with toAction(…) can log it before its finish. Without a handler the run fails.

  • .onError(RouterFailedError, …) — any failure of the step: its model call or its route() failed (step.router), or its answer is not allowed.
  • .onError(RouterDecisionError, …) — only a not-allowed answer (step.router.invalid-decision: a target outside its routes, an unknown or unreadable answer); never retried.
  • .onError(DomainError, …) — the error a code router's route() (or the service it calls) threw, caught through RouterFailedError's cause.

5. A rule decided by code

A model reads the text into a typed DTO; a code router decides by the type — no second model call, no matching text (Routers → Routers decided by code):

@Router({
  name: "priority-router",
  description: "Pages the on-call engineer for urgent messages, queues the rest",
  decidedBy: DecidedBy.Code,
  input: { priority: PriorityDto },
  routes: [{ target: PageOnCallAction }, { target: QueueTicketAction }],
})
export class PriorityRouter implements RouterHandler<PriorityRouter> {
  route(input: InputOf<PriorityRouter>): Route<PriorityRouter> {
    return input.priority.value.isUrgent ? PageOnCallAction : QueueTicketAction;
  }
}

@Workflow({
  name: "priority-workflow",
  version: "1.0.0",
  flow: [
    start(TicketWorkflowStart)
      .returns(WorkflowStartTextDto)
      .toAgent(PriorityAgent)
      .returns(PriorityDto)
      .toRouter(PriorityRouter) // a code router: the flow is the same as for a model router
      .chooseOne(
        route(PageOnCallAction)
          .returns(AnswerDto)
          .toFinish(AnswerWorkflowFinish),
        route(QueueTicketAction)
          .returns(AnswerDto)
          .toFinish(AnswerWorkflowFinish),
      ),
  ],
})
export class PriorityWorkflow {}

A code router may also sit inside a model router's route(…) — route(PriorityRouter).chooseOne(…) — or the other way round; routers chain in any mix with no step between them.

6. A router decides who is launched

After .toRouter(R) a parallel launch (#273) lists the router's routes ([router.routes-mismatch]) and its routes carry the launch's policies: spawn — SpawnPolicy.Always (the framework launches it; no model sees it, so it has no prompt) or SpawnPolicy.OnDemand (the router decides; a model router reads its route prompt) — and failure — FailurePolicy.FailFast (default) or FailurePolicy.Skip (a failed agent drops out of the join). A value of an OnDemand or Skip branch is optional(XDto) in .join().returns(…) and in every consumer's input.

@Router({
  name: "support-router",
  description: "Picks everyone needed for the customer's message",
  decidedBy: DecidedBy.Model,
  model: "typesafe/jev-1.13",
  prompt: "Pick everyone needed for the customer's message.",
  input: { message: WorkflowStartTextDto },
  routes: [
    { target: OrderAgent, spawn: SpawnPolicy.Always },
    { target: RefundPolicyAgent, spawn: SpawnPolicy.OnDemand, prompt: "The message is about returns" },
    { target: LoyaltyAgent, spawn: SpawnPolicy.Always, failure: FailurePolicy.Skip },
  ],
})
export class SupportRouter {}
Rule
A model router states spawn before a launch every route of a model router says spawn — [router.spawn-required]; an Always route has no prompt — [router.prompt-on-always] (a compile error too); an OnDemand one has one — [router.no-prompt]
It decides something at least one OnDemand route — [router.all-routes-always]
A code router its route is OnDemand unless it says spawn: SpawnPolicy.Always; before a launch its route(input) returns the OnDemand agents to launch — RouterAnswerOf<Self> is a list (readonly [Route<Self>, ...Route<Self>[]] when no route is Always, so [] does not compile)
Agents only a router launches agents: .toParallelActions(…) after a router does not compile
Policies live in the router with .chooseOne(…) they are [router.policy-with-choose-one]; a launch from an agent, a start or an action has none (all Always + FailFast)

At run time (#273 PR 2, S21, S23):

A model router decides in one call one independent yes/no question per OnDemand route, its text the route's prompt, keyed by the target's name in the order of routes; the router's own prompt leads the state it decides on. A decision model (Jev) answers one decide with a Decision.noul per route; a chat model one answer tool (answer-launch) with a boolean field per route. A route is launched when its answer is yes — P(yes) above 0.5, no threshold setting; every probability goes into the trace ("launch decision"). Always routes are never shown to the model
All no with an Always route only the Always targets run; with none, nothing would run → RouterDecisionError (step.router.invalid-decision), as for a code router's empty list
A code router route(input) returns the OnDemand targets to launch, as a list; one class, a target outside its OnDemand routes (an Always one included) or [] without an Always route → RouterDecisionError
FailurePolicy.Skip drops only the branch's own step failure — error instanceof StepFailedError (AgentFailedError, AgentAnswerInvalidError, QualityGateError, …): the branch ends BranchStatus.Skipped, its error stays in result.launches, its siblings go on and its value is absent (optional). A limit (maxSteps, maxVisits, a budget) in a Skip branch fails the launch like FailFast; a cancel of the execution stops it whole (ExecutionCancelledError). .onJoinError(StepFailedError, …) takes exactly what Skip drops: StepFailedError has its own code, step.failed
In a test script the router's decision like a decision model's, by target name: mockLlm(SupportRouter).thenReturn(decideWith({ "refund-policy-agent": 0.9 })) — also for a chat-model router (P(yes) above 0.5 is true) (Testing)

Not in the core yet

Pattern Issue
Quorum joins (.joinQuorum(…)), groups, arbiters — checked and typed (Parallel), run from PR 2 #279
Child workflows (.toWorkflow(W), caseOf per child finish) #263

The transition DSL's nextParallel / join, batchParallel, @QuorumRouter, parallel(…) / optional(…) route targets, Self and nested @Workflow nodes were removed with it (ADR-0019); rollback is on Rollback.

Clone this wiki locally