# Routers

> Route conversations between multiple agents on a single channel

Routers direct incoming conversations to the right agent when multiple agents share a single channel. Instead of assigning one agent per WhatsApp number or API endpoint, a router evaluates each conversation and decides which agent should handle it.

## How Routers Work

When a message arrives on a channel with a router assigned, the routing engine determines which agent should handle the conversation:

```
Incoming message on channel
    │
    ▼
Check for sticky route (existing conversation)
    │
    ├─ Sticky route found → Resume with assigned agent
    │
    └─ No sticky route (new conversation or reset)
        │
        ▼
    Router evaluates (rules or classify)
        │
        ├─ Rules mode: evaluate conditions top-to-bottom
        └─ Classify mode: LLM intent classification
        │
        ▼
    Route to matched agent (or fallback)
        │
        ▼
    Sticky route established for conversation
```

### Routing Characteristics

| Property | Behavior |
|----------|----------|
| Sticky | Once assigned, a conversation stays with its agent until transferred or reset |
| Transfer | Agents can hand off via the `router.transfer` tool |
| Transfer cap | Maximum transfers per conversation (default: 5) |
| Inactivity reset | Sticky routing resets after configurable inactivity period (defaults to 4 hours) |
| Loop prevention | An agent cannot transfer to itself (self-transfer blocked) |
| Mutex | Only one `router.transfer` 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.channelParams.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) |

## Sticky Routing

After a router assigns an agent to a conversation, that assignment is sticky — subsequent messages in the same conversation go directly to the assigned agent without re-evaluating the router.

Sticky routing resets in two cases:

1. **Explicit transfer** — An agent calls `router.transfer`, which reassigns the sticky route to the target agent.
2. **Inactivity timeout** — After the conversation has been inactive for the `inactivityResetMs` duration, the next message re-evaluates the router from scratch. Defaults to 4 hours (`14400000` ms) if not configured.

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

This resets the sticky route after 30 minutes of inactivity, allowing the router to re-evaluate whether the same agent or a different one should handle the resumed conversation.

## 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` call can execute per conversation at a time. If two transfer calls happen simultaneously, the second waits for the first to complete. Initial classification is not subject to this mutex.

## 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 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, the sticky route resets.
