# Routers

> Route conversations between multiple agents on a single channel

Routers direct incoming conversations to the right agent using rules-based matching or LLM-powered classification, with per-message routing, configurable inactivity resets, and transfer limits. Struere routers support multi-agent orchestration with a chain depth limit of 3, automatic cycle detection, and shared conversation context across child threads.

## How Routers Work

Routers re-evaluate every inbound message. When a message arrives on a channel with a router assigned, the routing engine picks an agent and routes the message:

```
Inbound message
    │
    ▼
Router evaluates (rules or classify)
    │
    ├─ Rules mode: evaluate conditions top-to-bottom
    └─ Classify mode: LLM intent classification
    │
    ▼
Pick agent (or fallback)
    │
    ▼
If different from currentAgentId → patch currentAgentId, increment transferCount, fire router.transferred
    │
    ▼
Route message to chosen agent
```

### Routing Characteristics

| Property | Behavior |
|----------|----------|
| Per-message | Router re-evaluates on every inbound message |
| currentAgentId | Reflects the most recent routing decision (used by UI, `router.transfer` tool, inactivity reset job) |
| transferCount | Increments only when the chosen agent differs from `currentAgentId` |
| router.transferred event | Fires only on actual agent switches |
| Transfer | Agents can hand off via the `router.transfer` tool |
| Transfer cap | Maximum transfers per conversation (default: 5) |
| Inactivity reset | `currentAgentId` clears after configurable inactivity period (defaults to 4 hours) |
| Loop prevention | An agent cannot transfer to itself (self-transfer blocked) |
| Mutex | Only one `router.transfer` tool call runs per conversation at a time |

## Routing Modes

### Rules Mode

Rules mode evaluates conditions with zero LLM cost. Rules are checked top-to-bottom, and the first matching rule wins:

```typescript
{
  mode: "rules",
  rules: [
    {
      conditions: [
        { field: "customer.plan", operator: "in", value: ["enterprise"] },
      ],
      route: "vip-agent",
    },
    {
      conditions: [
        { field: "customer.type", operator: "eq", value: "lead" },
      ],
      route: "sales-agent",
    },
  ],
  fallback: "general-agent",
}
```

Multiple conditions within a rule use AND logic — all must match. If no rule matches, the `fallback` agent handles the conversation.

Rules mode is ideal for deterministic routing based on known data: phone numbers, entity attributes, time of day, or message type. Entity fields use a `{entityType}.*` prefix — the routing engine extracts the entity type slug from the prefix and queries that entity type by the sender's phone number (e.g., `customer.plan` queries the `customer` entity type, `teacher.subject` queries the `teacher` entity type).

When a message is routed via rules (e.g., `teacher.name exists`), the router has already identified the sender by matching their phone number against an entity. The matched agent can then use `{{threadContext.params.phoneNumber}}` in its system prompt or access `context.threadContext.params.phoneNumber` in a custom tool to load that entity's data directly.

### Classify Mode

Classify mode uses an LLM to determine intent from the conversation context. The router sends the agent descriptions and recent messages to the classification model, which selects the best agent:

```typescript
{
  mode: "classify",
  classifyModel: "openai/gpt-5-mini",
  contextMessages: 5,
  fallback: "support-agent",
}
```

The `description` field on each agent reference is critical — it provides the LLM with context to make accurate routing decisions.

Classify mode is ideal when routing depends on message intent rather than structured data.

## Handoff via router.transfer

Agents can transfer a conversation to another agent using the `router.transfer` tool. This is useful when a conversation changes topic or the agent determines a different specialist should handle it:

```typescript
export default defineAgent({
  name: "Support Agent",
  slug: "support-agent",
  tools: ["entity.query", "router.transfer"],
  systemPrompt: `You handle technical support. If the customer asks about billing, transfer to billing-agent.`,
})
```

When `router.transfer` is called, the current agent's turn ends and the conversation is handed to the target agent. The target agent receives the conversation history for context.

### Transfer Restrictions

| Restriction | Description |
|-------------|-------------|
| Must be in router | `router.transfer` only works for agents registered in a router |
| Valid target | Target agent must be in the same router's agent list |
| Transfer cap | Transfers stop after `maxTransfers` is reached (default: 5) |
| Loop prevention | Cannot transfer to yourself (self-transfer blocked) |

## Per-Message Routing

The router runs on every inbound message. The chosen agent handles that message; if it differs from the current `currentAgentId`, the thread's `currentAgentId` is updated, `transferCount` is incremented, and a `router.transferred` event fires. If the router picks the same agent that already owns the thread, none of those happen — the message just routes to that agent.

`currentAgentId` reflects the most recent routing decision. It is used by the dashboard UI to display which agent owns the thread, by the `router.transfer` tool's same-agent-rejection check, and by the inactivity reset job.

For `rules` mode, this means rules with content-based conditions (e.g. `message.text contains '...'`) fire on the matching message, not just on the first message of the conversation.

For `classify` mode, this means an LLM classification call runs on every inbound message. The default classify model `openai/gpt-5-mini` is sub-second and sub-cent per call, but choose `rules` mode when deterministic per-message routing is sufficient.

The `inactivityResetMs` option clears `currentAgentId` after a period of inactivity. The next inbound message routes from a clean state — no prior agent to compare against, so the routing decision will not increment `transferCount`. Defaults to 4 hours (`14400000` ms) if not configured.

```typescript
{
  inactivityResetMs: 1800000,
}
```

## Safety Mechanisms

### Transfer Cap

The `maxTransfers` field limits the number of agent-to-agent transfers per conversation. Once the cap is reached, `router.transfer` calls are rejected and the current agent continues handling the conversation:

```typescript
{
  maxTransfers: 3,
}
```

This prevents runaway transfer loops where agents keep handing off to each other.

### Loop Prevention

The router prevents self-transfers. An agent cannot transfer to itself. Transfers to other agents (including the one that previously transferred) are allowed.

### Mutex

Only one `router.transfer` tool call can execute per conversation at a time. If two transfer calls happen simultaneously, the second waits for the first to complete. The mutex applies only to the `router.transfer` tool — the per-message router evaluation runs outside it.

## Environment Scoping

Routers are environment-scoped. A router defined in `development` only routes conversations in the development environment. The `struere dev` command syncs routers to the development and eval environments, and `struere deploy` pushes them to production.

## Assigning a Router to a Voice Connection

In the dashboard, navigate to **Integrations > Voice** and select a Twilio phone number. Assign a router to handle inbound calls. The router's `voiceConfig` determines voice settings (model, voice, turn detection, auditor agent). See [Voice integration](/integrations/voice) for configuration details.

## Assigning a Router to a WhatsApp Connection

In the dashboard, navigate to **Integrations > WhatsApp** and select a phone number connection. Instead of assigning a single agent, select a router. All incoming messages on that number will flow through the router.

A WhatsApp connection can be assigned either a single agent or a router, but not both.

## Example: Internal Team vs Customers

A single WhatsApp number shared between internal team members and external customers:

```typescript
import { defineRouter } from 'struere'

export default defineRouter({
  name: "Shared Number Router",
  slug: "shared-number-router",
  description: "Routes internal team to ops agent, customers to support agent",
  mode: "rules",
  agents: [
    { slug: "ops-agent", description: "Internal operations assistant for the team" },
    { slug: "customer-agent", description: "Customer-facing support agent" },
  ],
  rules: [
    {
      conditions: [
        { field: "staff.type", operator: "eq", value: "employee" },
      ],
      route: "ops-agent",
    },
  ],
  fallback: "customer-agent",
  maxTransfers: 5,
  inactivityResetMs: 3600000,
})
```

Internal team members (entities of type `staff` with `type: "employee"`) are routed to the ops agent. All other messages go to the customer support agent. After 1 hour of inactivity, `currentAgentId` clears.
