Skip to main content
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.
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

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:
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:
  • 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

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:
  • 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

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). 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).