# Proposal — Multi-Agent Routing (One Widget, Many Brains)

**Status:** draft (audit iter 13, 2026-05-16)
**Owner:** TBD
**Effort:** 6 days (3 backend, 2 widget, 1 docs+QA)
**Plan gate:** Business+ tier (per-route agent count gated)

---

## Problem

Today each widget script tag binds to **one agent**. A workspace that
operates multiple products / brands / customer segments has to either:

- Drop multiple widget snippets on different pages (visitor on the
  `/store` page gets the Shopping bot; visitor on `/docs` gets the
  Documentation bot) — but the agent doesn't follow the visitor when
  they navigate between sections.
- Settle for one generic agent that's "decent at everything but
  great at nothing".

Buyer feedback:
> "We have a SaaS app AND a Shopify store on the same domain. Our
>  shopping bot keeps trying to answer billing questions, and vice
>  versa. We want one widget that switches behind the scenes." —
>  whispbar, batch v3
> "Our marketing site has 4 verticals — banking, retail, healthcare.
>  Each needs a different tone, different starter prompts, different
>  CTA. One agent can't be all 4." — ttsoft, batch v4

Competitors:
- **Intercom** — has "audience rules" routing to per-segment bots.
- **HubSpot Chat** — per-page bot configuration via the page-rule
  matcher.
- **Drift** — playbooks per URL pattern.

Pitchbar's one-widget-one-agent model loses every workspace that
needs differentiation.

---

## Goals

1. Workspace admin defines **routing rules** on the widget level:
   - URL pattern (`/store/*` → Shopping agent)
   - Visitor metadata (`utm_source=newsletter` → Marketing agent)
   - Page-context signal (`og:type=article` → Documentation agent)
   - Locale (`navigator.language=es-*` → Spanish-trained agent)
   - First match wins; fallback to a workspace-default agent.
2. Widget evaluates rules at boot **and** on every page navigation
   (SPA route changes via `popstate` + `pushState` hooks). Agent
   swap reuses the JWT but replaces the active agent_id.
3. Server validates that the requested agent_id belongs to the
   workspace embedded in the JWT.
4. Operator can A/B test routes (50% of `/store/*` visitors → Agent
   A, 50% → Agent B) — but that piggybacks on existing
   `ExperimentResolver` work, not new code.

---

## Non-goals

- **No conversation merging across agents.** Visitor on Shopping
  bot navigates to Docs → switches to Docs agent → conversation
  history visible (read-only context for the Docs agent's prompt),
  but the two agents don't share the assistant's "memory".
- **No automatic agent training.** Operator manually configures each
  agent's knowledge base — multi-agent is routing only.
- **No CMS connector-level routing.** The widget is the routing
  point; we don't push routing into Shopify / WP via plugin headers.

---

## Data model

New table:

```php
Schema::create('agent_routes', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
    $table->string('name', 80);
    $table->integer('priority')->default(0);  // higher = checked first
    $table->json('match');                    // {url_pattern: '/store/*', locales: ['en'], utm_source: null, ...}
    $table->foreignUuid('agent_id')->constrained()->cascadeOnDelete();
    $table->boolean('enabled')->default(true);
    $table->timestampsTz();

    $table->index(['workspace_id', 'enabled', 'priority']);
});
```

`workspaces.default_agent_id` (existing column) becomes the fallback
when no route matches.

Match JSON shape:

```json
{
  "url_pattern": "/store/*",
  "locales": ["es", "es-MX"],
  "utm_source": ["newsletter"],
  "page_meta": {"og:type": ["article"]},
  "device": null
}
```

All fields optional; ALL specified fields must match for the rule to
fire. AND semantics within a rule; OR semantics across rules (first
match wins).

---

## Init API change

`POST /api/v1/widget/init` now accepts optional routing-decision
context and returns the resolved agent + a list of route candidates:

```json
{
  "workspace_id": "01HFA…",
  "visitor_locales": ["en-US"],
  "page": {
    "url": "https://shop.example.com/store/products/red-pen",
    "meta": {"og:type": "product"}
  },
  "utm_source": "newsletter"
}
```

Response:

```json
{
  "jwt": "…",
  "agent_id": "01HFB…",
  "agent_name": "Shopping Bot",
  "routing": {
    "matched_rule_id": "01HFC…",
    "rules": [/* full list, so widget can re-route on navigation */]
  },
  "conversation_id": "01HFD…"
}
```

The widget caches `routing.rules` and re-evaluates them client-side
on every navigation. If a new agent wins, widget POSTs to
`POST /api/v1/widget/switch-agent` with `{conversation_id,
candidate_agent_id, match_reason}`; server validates same-workspace +
returns the new agent's persona / starter prompts.

---

## Widget hooks

`resources/widget/src/core/router.ts` (new):

```typescript
export type RouteRule = {
    id: string;
    priority: number;
    match: RouteMatch;
    agent_id: string;
};

export function pickAgent(rules: RouteRule[], ctx: RouteContext): RouteRule | null {
    return rules
        .slice()
        .sort((a, b) => b.priority - a.priority)
        .find((r) => matchesRule(r, ctx)) ?? null;
}
```

Wired into `init.ts` for first-paint resolution, and to a
`navigation` event listener for subsequent route changes:

```typescript
window.addEventListener('popstate', evaluateRoute);
const origPushState = history.pushState;
history.pushState = function (...args) {
    origPushState.apply(history, args);
    evaluateRoute();
};
```

`evaluateRoute()` re-runs `pickAgent()`; if the result differs from
the active agent, POSTs `switch-agent` and updates the widget state.

---

## Conversation persistence across switches

Single conversation row per visitor session. Each `messages` row
already has `agent_id` (verified — column exists). On switch:

- New assistant turns are tagged with the NEW agent_id.
- The system prompt receives the LAST 6-10 turns as context with a
  short banner: "The previous turns of this conversation were
  handled by a different agent. Use them as context but ground your
  reply in YOUR knowledge base."
- Workflow runs, lead capture, CTA cards from prior turns stay
  attached to the conversation.

---

## Admin UX

New tab on the workspace settings: **Routing rules**.

- Drag-to-reorder priority list.
- Per rule: name, match conditions (URL pattern, locales, UTM,
  meta), assigned agent.
- Live "Test against a URL" input that previews which rule fires.

Wayfinder routes:
- `GET /app/routes` — list rules.
- `POST /app/routes` — create rule.
- `PATCH /app/routes/{rule}` — update.
- `DELETE /app/routes/{rule}` — destroy.
- `PATCH /app/routes/{rule}/reorder` — bulk priority update.

`AgentRoutePolicy::manage` gated to Admin+ (mirrors WorkflowPolicy
pattern).

---

## Hot-path safety

- Rule evaluation is client-side in the widget post-init. Zero
  server round-trip per page navigation.
- The `switch-agent` endpoint is NOT on the visitor-message stream
  path — it fires only on actual navigation events, which are
  separate from the first-token contract.
- Server-side rule evaluation on `init` adds one indexed query
  (`agent_routes WHERE workspace_id = ? AND enabled = true ORDER BY
  priority DESC`) — sub-millisecond at any reasonable workspace
  size.

---

## Test plan

Pest feature tests:
- Init resolves to fallback agent when no rule matches.
- Init resolves to highest-priority matching rule.
- URL pattern matcher: `/store/*` matches `/store/products/red-pen`
  but not `/blog/store-review`.
- Locale matcher: rule `{locales: ['es']}` fires for `es-MX` (prefix
  match).
- UTM matcher: rule `{utm_source: ['newsletter']}` fires only on
  exact match.
- AND-within-rule semantics: rule with `url_pattern` + `locale`
  requires both to match.
- `switch-agent` endpoint validates same-workspace.
- `switch-agent` endpoint 403s when candidate agent is in a
  different workspace.
- Conversation message thread preserves agent_id per turn after
  switch.
- Cross-tenant: workspace A's rules never returned to workspace B's
  widget init.
- Plan gate: Free tier rejected when adding a 2nd route.

Widget unit tests:
- `pickAgent()` returns null on empty rule set.
- Priority order respected when multiple rules match.
- URL glob matcher supports `*`, `**`, and exact paths.

UI test plan:
1. Sign in as Business workspace admin.
2. /settings/routes → create rule "URL /store/* → Shopping Agent".
3. /settings/routes → create rule "URL /docs/* → Docs Agent" priority
   higher.
4. Embed widget on `/store/page1` → confirm Shopping Agent loads.
5. Navigate to `/docs/intro` (SPA route change) → confirm Docs
   Agent active in widget header pill.
6. /settings/routes → disable both rules → reload → confirm fallback
   agent active.

---

## Rollout

1. Phase 1 (3 days): schema migration, init endpoint changes, server
   rule engine, switch-agent endpoint. Tests.
2. Phase 2 (2 days): widget router + navigation hooks + admin
   settings page (Wayfinder + Inertia).
3. Phase 3 (1 day): docs + nav + troubleshooting page.
4. Canary: enable on Pitchbar's demo with 2 routes (English vs
   Spanish; or marketing vs docs).

---

## Risks / open questions

- **Conversation context loss on switch.** Adjacent agents may have
  vastly different knowledge bases. The "previous turns as context"
  fragment helps, but the new agent might still hallucinate based on
  the previous agent's domain. Mitigate with a short transition
  message: "Looks like you're now on the docs page — I'm a different
  bot trained on the technical docs. Feel free to repeat your last
  question if needed."
- **Rule evaluation drift between server and client.** Server
  evaluates on init; widget re-evaluates on navigation. If the two
  matchers diverge (e.g. URL glob semantics), navigation could
  produce inconsistent behavior. Solution: ship one TypeScript-port
  of the PHP matcher; integration tests run both implementations on
  the same fixtures.
- **Plan-gate counts.** Free tier = 1 agent allowed; multi-agent
  routing requires 2+ agents, so Free is implicitly blocked. Pro
  tier could allow 2 routes; Business unlimited. Decide before ship.

---

## Why now

- 2 separate buyer asks from medium-to-large CodeCanyon customers in
  the last 60 days.
- Engineering scope clean: 1 new table, 2 new endpoints, 1 widget
  module, 1 admin page. No hot-path risk.
- Unlocks larger workspaces / agencies who manage multiple sites or
  brands under one Pitchbar workspace — meaningful expansion of TAM.
- 9th proposal in Q3 roadmap (~47 days total engineering across 9
  proposals).
