> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Routing and Source connections

> Let a Source connect your integration once for all its teams, route each event to the right team, and keep every team inside its own records.

Usually each team connects your integration with its own account: one Zendesk per team. Some customers run **one** vendor account for many teams instead. A lender serving several banks has one Zendesk, and each bank is a team in Ekanos. A ticket from Bank A must reach Bank A's agents, and Bank A's agent must never read Bank B's tickets.

Three declarations make that work:

* **`routing`** names the keys an event can be routed to a team by, such as the Zendesk organization.
* **`routeKeys`** on each agent event gives the event's value for those keys.
* **`sourceConnection`** says a Source admin may connect your integration once for the whole Source, and promises your tools stay inside the records routed to each team.

> [!NOTE]
>
> A Source connection is used **only by provided agents**: agents a Source
> publishes from a template that marks your product as Source-owned. A team
> member's widgets, assistant and workflows always use the team's own
> connection.

## Declare routing keys

```ts theme={null}
import { defineIntegration } from '@ekanos/sdk/integration';

export const integration = defineIntegration({
  // ...
  routing: {
    keys: [
      {
        // snake_case. Stored on the Source's mappings: never rename it once shipped.
        id: 'organization',
        label: 'Organization',
        description: 'The Zendesk organization a ticket belongs to.',

        // Optional: the values a Source admin can map, read through the Source
        // connection, so the CLI and the app offer a list instead of raw ids.
        async options(ctx) {
          const response = await ctx.fetch(
            `${await baseUrl(ctx)}/api/v2/organizations.json`,
            {
              headers: await authHeaders(ctx),
            },
          );
          const { organizations } = (await response.json()) as {
            organizations: Array<{ id: number; name: string }>;
          };
          return organizations.map((org) => ({
            value: String(org.id),
            label: org.name,
          }));
        },
      },
    ],
  },
});
```

An integration declares one to four keys. A Source routes by one of them.

## Give each event its route value

`routeKeys` maps each routing key id to a pure function of the validated payload, like `conversation`:

```ts theme={null}
import { defineAgentEvent } from '@ekanos/sdk/integration';

export const ticketCreated = defineAgentEvent({
  id: 'acme-helpdesk.ticket_created',
  label: 'A ticket was created',
  description: 'A customer opened a ticket.',
  payloadSchema: TicketPayload,
  instructions: 'Answer the ticket with acme_helpdesk_reply.',
  conversation: (event) => ({
    key: `ticket:${event.ticket.id}`,
    target: { ticketId: event.ticket.id },
  }),
  routeKeys: {
    organization: (event) =>
      event.ticket.organization
        ? {
            value: String(event.ticket.organization.id),
            label: event.ticket.organization.name,
          }
        : null,
  },
});
```

Return `{ value, label? }`: `value` is your own id for the thing, matched exactly; `label` is shown to the admin and never matched. Return `null` when the event carries no value. A value that is empty, not a string, or longer than 200 characters counts as no value.

A `routeKeys` key must be one of `routing.keys`, and an event cannot declare `routeKeys` unless the integration declares `routing`. `defineIntegration`, `ekanos validate` and the host all refuse it.

## What the host does with an event

When your webhook is delivered to the **Source's** endpoint (rather than a team's):

1. Your handler runs on the Source connection: `ctx.connection.owner` is `'source'` and there is no `ctx.routing`, because no team is known yet. Read what you need from the vendor and raise events as usual.
2. The host validates each raised event, records it once per `dedupeKey` for the Source, and reads its value for the Source's routing key.
3. **Mapped:** the event is recorded and dispatched as the mapped team's, so that team's triggers, runs, approvals and permissions apply unchanged.
4. **Unmapped, or no value:** the event is **unrouted**. No team sees it and no agent starts. There is no default team. The Source admin sees it in the unrouted list, maps the value, and can replay it.

An event delivered to a team's own endpoint is already that team's: `routeKeys` is ignored there.

## The unrouted hook

To tell people an unrouted event is waiting, declare `hooks.unrouted` on your channel. It posts one fixed internal note where a reply would have gone:

```ts theme={null}
import type { PartnerAgentChannelDeclaration } from '@ekanos/sdk/integration';

export const agentChannel: PartnerAgentChannelDeclaration = {
  // replyTool, deliver ...
  hooks: {
    async unrouted(ctx, target, event) {
      // ctx is the Source connection's, with no ctx.routing.
      // event: { eventId, payload, route: { key, value, label? } }
      await postInternalNote(
        ctx,
        target.ticketId,
        'Not assigned to a team yet. A coordinator will route this ticket.',
      );
    },
  },
};
```

* `target` is the event's `conversation()` target. The host calls the hook only for an event that has one, at most once per event.
* The note is fixed text from your code. It is never written by a model.
* Like every channel hook it is best-effort: a hook that throws is logged and ignored.
* It needs `routing`: a channel declaring `unrouted` without it is refused.

The hook sits on the **channel**, not on the webhook declaration. An event is routed after your handler returns, one raised event at a time, and a single delivery can raise several. The note goes to the same place a reply goes, through the same vendor calls as `deliver`, so it belongs beside them. There is no run, so unlike the other hooks it receives no `run`.

## Offer a Source connection

```ts theme={null}
export const integration = defineIntegration({
  // ...
  routing: { keys: [/* ... */] },
  sourceConnection: { supported: true, scopedTools: true },
});
```

Both flags must be `true`, and `sourceConnection` requires `routing`. The host refuses to make your product Source-owned in a template unless you declare it.

**`scopedTools: true` is a promise.** A Source connection's tokens reach every team's records, so your tools are the boundary. On a Source connection, `ctx.routing` is `{ key, values }`: the routing key and the values routed to the team the call is made for. Every tool must read and write only records carrying one of `ctx.routing.values`:

```ts theme={null}
async run(ctx, args) {
  const ticket = await getTicket(ctx, String(args.ticketId));
  if (ctx.routing && !ctx.routing.values.includes(String(ticket.organization_id))) {
    // Same answer as a ticket that doesn't exist: never confirm another team's record.
    return { error: 'not_found' };
  }
  return ticket;
}
```

* **Searches** add the scope to the query (Zendesk: `organization:<id>` per value), never filter after fetching.
* **Reads and writes** of one record fetch it first and refuse one outside the scope.
* **Empty `values`** means the team is mapped to nothing, so nothing is in scope.
* On the team's own connection `ctx.routing` is absent and everything the connection reaches is the team's.

## The context on a Source connection

| Where your code runs | `ctx.connection.owner` | `ctx.routing` |
| - | - | - |
| A team's own connection (every other call) | `'team'` or absent | absent |
| A provided agent's tool call or channel hook | `'source'` | the team's routed values |
| A webhook delivered to the Source endpoint | `'source'` | absent: no team is known yet |
| `routing.keys[].options`, `hooks.unrouted` | `'source'` | absent |

`ctx.connection` is read-only and set by the host. A host that predates Source connections leaves it unset, which means `'team'`. On a Source connection `ctx.secrets` and `ctx.storage` are the Source's: the tokens your `onTokens` stores there are shared by every team's provided agents, so never keep per-team state there. In a call with no team (the last two rows), don't rely on `ctx.accountId`: the host sets it to an empty string, which names no team, and `ctx.storage.user` throws there.

## Test it

Run a tool, a webhook or a channel hook on a Source connection with `createMockContext({ connection, routing })`, and check route values and the hooks with `invokeRouteKeys`, `invokeUnrouted` and `invokeHandoff` (see [Testing](https://sdk.ekanos.dev/sdk/testing.md#test-on-a-source-connection)). In the harness, `triggerMocks.connection` and `triggerMocks.routing` run the Triggers surface the same way, and a delivered webhook shows the value each raised event would be routed by (see [Triggers](https://sdk.ekanos.dev/harness/triggers.md#connection-and-routing)).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.