---
title: Custom Channels
description: Author custom HTTP and WebSocket channels with routes, events, metadata, continuation tokens, and file uploads.
---

# Custom Channels



When eve doesn't ship a channel for your surface, you build one. Custom channels expose HTTP or WebSocket endpoints, parse incoming requests, start or resume sessions, observe runtime events, and own delivery back to your platform.

## File location and identity

Custom channels live in `agent/channels/` at the root agent or in an [extension](../extensions) mounted there. Local subagents do not declare channels today.

The channel file stem becomes the channel id, so `agent/channels/internal-webhook.ts` is addressed as `internal-webhook`. Export the channel definition as the module's default export.

## Define a channel

Pass the platform's conversation identity to each operation:

```ts title="agent/channels/support.ts"
import { defineChannel, GET, POST } from "eve/channels";

export default defineChannel({
  routes: [
    POST("/threads/:threadId/messages", async (request, { from, params }) => {
      const body = await request.json();
      const source = from(params.threadId);

      if (body.message === "/new") {
        return Response.json(await source.reset({ reason: "User requested /new" }));
      }

      const session = await source.send(body.message, { auth: null });
      return Response.json({ sessionId: session.id });
    }),

    POST("/threads/:threadId/cancel", async (_request, { from, params }) =>
      Response.json(await from(params.threadId).cancel()),
    ),
    POST("/threads/:threadId/compact", async (_request, { from, params }) =>
      Response.json(await from(params.threadId).compact()),
    ),
    POST("/threads/:threadId/clear", async (_request, { from, params }) =>
      Response.json(await from(params.threadId).clear()),
    ),

    GET("/sessions/:sessionId/stream", async (_request, { attachSession, params }) => {
      const stream = await attachSession(params.sessionId).getEventStream();
      return new Response(stream, {
        headers: { "content-type": "application/x-ndjson; charset=utf-8" },
      });
    }),
  ],

  events: {
    "message.completed"(event, _channel, ctx) {
      console.log(ctx.session.id, event.message);
    },
  },
});
```

A route's `path` is its app URL; the channel filename does not prefix it. The route above answers at `POST /threads/:threadId/messages`, not `/support/threads/:threadId/messages`. Use a unique method-and-path pair for each route, and do not use the framework-owned `/eve/v1/*` namespace.

Each route receives these operation surfaces:

* `from(address)` binds `send`, `respond`, `cancel`, `compact`, `clear`, and `reset` to a channel-local continuation address.
* `resolveSession(address)` snapshots the session currently owning a channel-local continuation address.
* `attachSession(sessionId)` creates an I/O-free handle pinned to one durable session ID.
* `to(channel, target).send(message, options)` hands work to another authored channel.
* `params`, `waitUntil`, and `requestIp` provide request metadata and lifetime control.

Event handlers receive `(eventData, channel, ctx)`. `ctx.session.id` identifies
the exact session, while `channel.continuation` exposes the current address and
`rekey()` when this channel needs to move it. `session.failed` receives only
`(eventData, channel)` because it runs outside session context; its event data
contains `sessionId` directly.

`channel.continuation.token` is always the channel-local address accepted by
`from()`, `resolveSession()`, and `rekey()`. Framework namespace prefixes are not
part of the authored channel API.

## Channel operations and session handles

Channel operations are dynamic: every call targets whichever session currently
owns the address. Only `send()` can create a session when the address is unowned.
Cold-start sends return their accepted candidate without waiting for address
ownership. If simultaneous sends race, eve forwards the losing candidate's
message to the winner; resolve the address after startup when you need a fixed
handle to the canonical owner.

```ts
const source = from(threadId);

const session = await source.send("Hello", { auth });
await source.respond(inputResponses, { auth });
await source.cancel({ turnId });
await source.compact();
await source.clear();
await source.reset({ reason: "Start over" });

const currentSession = await resolveSession(threadId);
```

`Session` is fixed: every call targets exactly one durable ID. It never creates,
follows, or resolves a replacement.

```ts
const session = attachSession(sessionId);

await session.send("Follow up", { auth });
await session.respond(inputResponses, { auth });
await session.cancel({ turnId });
await session.compact();
await session.clear();
await session.reset({ reason: "Retire this session" });
await session.getEventStream({ startIndex: 12 });
```

`respond()` accepts exact response literals directly. If responses have already
been widened to `InputResponse[]`—for example, after decoding a platform
payload—validate them with eve's strict schema before delivery. The validated
type preserves that proof across channel wrappers:

```ts
import { parseInputResponses } from "eve/client";

const inputResponses = parseInputResponses(decodedResponses);
await source.respond(inputResponses, { auth });
```

Message sends use the channel's `turnPolicy`, which defaults to `"steer"`. An accepted message arriving during an active turn is durably buffered before eve cancels that turn and starts the message as a replacement turn. Configure `turnPolicy: "queue"` on `defineChannel(...)` when active turns should finish in order, or override one send:

```ts
export default defineChannel({
  turnPolicy: "queue",
  routes: [
    POST("/messages", async (request, { from }) => {
      const body = await request.json();
      await from(body.threadId).send(body.message, {
        auth: null,
        turnPolicy: "steer",
      });
      return new Response(null, { status: 202 });
    }),
  ],
});
```

The same override is available on fixed `Session.send(...)` and cross-channel `to(...).send(...)`. `respond(...)` never steers: it delivers only addressed input responses. Use `cancel()` when you need to stop work without a replacement message.

Attaching does no lookup. The first operation reports whether the ID is active.
Call `resolveSession(address)` only when you explicitly need to snapshot an
address's current owner as a fixed handle.

## Operation semantics

* `cancel` cooperatively stops the active turn. Confirm it with `turn.cancelled`
  followed by `session.waiting`; the session accepts another message afterward.
* `compact` summarizes model context without adding a synthetic user message. A
  success emits `compaction.requested`, `compaction.completed`, then `session.waiting`.
* `clear` removes model-message history in place. It preserves the system prompt,
  skills, tools, durable state, limits, address ownership, and session sandbox.
* `reset` terminally retires the current session. A later address `send()` creates
  a fresh session; a fixed `Session` handle remains pinned to the retired ID.

Control operations never create a session. Unknown or inactive targets return a
benign no-active status. Authenticate and deduplicate command webhooks before
calling `reset`, because a delayed duplicate can retire a newer address owner.

## CORS

Custom HTTP channels leave CORS untouched unless you opt in. Pass `cors: true`
for permissive browser access with preflight handling, or pass a serializable
CORS options object to narrow origins, methods, and headers:

```ts
import { defineChannel, POST } from "eve/channels";

export default defineChannel({
  cors: {
    origin: ["https://app.example.com"],
    methods: ["POST"],
    allowHeaders: ["authorization", "content-type"],
  },
  routes: [POST("/message", async () => new Response("ok"))],
});
```

## WebSocket routes

Use `WS()` when a custom channel needs a WebSocket endpoint. The route handler runs once per upgrade request and returns lifecycle hooks for that connection:

```ts
import { defineChannel, WS } from "eve/channels";

export default defineChannel({
  routes: [
    WS("/voice/ws", async (_req, { from }) => ({
      async message(_peer, message) {
        await from("voice-demo").send(message.text(), { auth: null });
      },
    })),
  ],
});
```

`WS()` handlers receive the same `from`, `to`, and `attachSession` operations,
`params`, `waitUntil`, and `requestIp` arguments as HTTP route handlers. The
returned hooks are eve-owned structural types compatible with Nitro/H3 websocket
routing, including `upgrade`, `open`, `message`, `close`, and `error`.

### Node upgrade server escape hatch

Prefer the `WS()` lifecycle hooks above when you own the websocket behavior. eve also exposes `createWebSocketUpgradeServer()` for the narrower case where a third-party SDK or framework expects to bind directly to a Node `http.Server` with `server.on("upgrade", ...)`.

```ts
import { defineChannel, WS, createWebSocketUpgradeServer } from "eve/channels";

const bridge = createWebSocketUpgradeServer();

thirdPartySdk.attach(bridge.server);

export default defineChannel({
  routes: [WS("/vendor/ws", bridge.route)],
});
```

The bridge server does not listen on its own port. It receives only upgrade events that matched the eve route, and only on hosts where Nitro exposes the raw Node upgrade request, socket, and head. Treat it as a compatibility adapter for libraries with server-binding APIs, not the primary way to build websocket channels in eve.

## Cross-channel hand-off

Route handlers can start or resume an agent session on a different channel via `ctx.to(channel, target).send(message, options)`. This is an agent hand-off: the message becomes turn input and invokes the model on the destination channel. Use it when an inbound request should pivot the conversation, such as an incident webhook that opens an investigation thread in Slack.

To post a provider notification without starting an agent turn, call the destination provider's API instead. If that notification must survive process failures, use an application-owned outbox; see [Durable cross-channel notifications](../patterns/durable-cross-channel-notifications).

```ts
import { defineChannel, POST } from "eve/channels";
import slack from "./slack";

export default defineChannel({
  routes: [
    POST("/incident", async (req, ctx) => {
      const incident = await req.json();

      ctx.waitUntil(
        ctx
          .to(slack, { channelId: "C0123ABC" })
          .send(`Investigate ${incident.reference}: ${incident.title}`, {
            auth: {
              authenticator: "incidentio",
              principalType: "service",
              principalId: incident.actor.id,
              attributes: { reference: incident.reference, severity: incident.severity },
            },
          }),
      );

      return new Response("ok");
    }),
  ],
});
```

Semantics:

* The target channel's authored `receive(input, { from })` hook owns the continuation-token format and initial state. Callers supply the target to `to(...)`, then the message and auth to `send(...)`.
* `auth` flows through to `session.auth.initiator` so the target's event handlers and the agent's tools can read who started the session.
* Calling `ctx.to(...).send(...)` does not also start a session on the current channel. The inbound channel's response is whatever the route handler returns explicitly.
* `send(...)` is not a direct provider-message API. It supplies input to the agent on the destination channel.
* The first argument is the target channel module's default export. Import it directly from `agent/channels/<name>.ts`. Identity is matched by reference.

## Channel metadata

A channel can project a subset of its adapter state as metadata, available to instrumentation resolvers, dynamic tool resolvers, and dynamic skill or instruction resolvers. Define a `metadata(state)` function on the channel config:

```ts
import { defineChannel, POST } from "eve/channels";

export default defineChannel({
  state: {
    topic: null as string | null,
    contextMessages: [] as string[],
    internalCounter: 0,
  },

  metadata(state) {
    return {
      topic: state.topic,
      contextMessages: state.contextMessages,
    };
  },

  routes: [
    POST("/start", async (req, { from }) => {
      const body = await req.json();
      await from(body.token).send(body.message, {
        auth: null,
        state: { topic: body.topic, contextMessages: body.context, internalCounter: 0 },
      });

      return new Response("ok");
    }),
  ],
  events: {
    "turn.started"(eventData, channel) {
      channel.state.internalCounter += 1;
    },
  },
});
```

The projection is re-evaluated whenever adapter state changes after channel event handlers run. Dynamic tool resolvers read it via `ctx.channel.metadata` and narrow it with `isChannel`. See [Dynamic capabilities](../guides/dynamic-capabilities) for the full consumption pattern.

### Conversation audience

Use `audience(input)` to classify who can observe the conversation. The hook runs after route authentication and before the session is created:

```ts
import { defineChannel, POST } from "eve/channels";

export default defineChannel({
  state: { visibility: "workspace" as "workspace" | "private" | "unknown" },
  audience({ state, caller }) {
    if (state.visibility === "workspace") return "public";
    if (caller.type === "principal" && caller.principal.kind === "user") return "private";
    return "unknown";
  },
  routes: [POST("/webhook", async () => new Response("ok"))],
});
```

The input contains the channel's `state`, `caller`, `channel`, `mode`, and `environment`. A principal caller omits `principalId`, `issuer`, and `subject`, but its `attributes` come from route auth and can still contain identifying values. Return `"public"`, `"private"`, or `"unknown"`. Return `"unknown"` when the channel does not have enough evidence to classify confidently; consumers treat it as non-public. A missing, throwing, asynchronous, or malformed classifier also fails closed to `"unknown"`.

When you explicitly annotate a callback, use `AudienceContext<TState>`. `AudienceInput<TState>` remains supported for existing callbacks while you migrate.

Existing callbacks can continue using `auth?.principalType` while you migrate:

```ts
audience({ auth }) {
  return auth?.principalType === "user" ? "private" : "unknown";
}
```

Classification is fixed when the session is created. Continuation turns from a different caller do not reclassify an existing session. When a parent dispatches a local subagent, the child inherits the parent's audience while the framework forwards the parent's custom channel metadata separately.

`metadata(state)` remains a channel-owned projection of custom observability fields. It no longer carries a reserved `audience` key. Channel extensions built before channel epoch 20 can still use that key; eve accepts it as a deprecated fallback and warns once, then authors should move classification to `audience(input)`.

## Continuation tokens

Each channel operation accepts a channel-local token. The framework prepends the channel name, derived from the file stem under `agent/channels/`, before handing the token to the runtime.

```ts
import { slackContinuationToken } from "eve/channels/slack";
import { twilioContinuationToken } from "eve/channels/twilio";

slackContinuationToken("C0123ABC", "1800000000.001234"); // "C0123ABC:1800000000.001234"
twilioContinuationToken("+15551234567", "+15557654321"); // "+15551234567:+15557654321"
```

Custom channels write their own function that joins the identity fields. The framework derives nothing for you; the channel owns its token format.

When the identity that should address a session is not known until later, re-key the live address with `channel.continuation?.rekey(rawToken)`. The runtime preserves the current channel namespace.

Re-keying changes the address of the current session. `reset` is different: it terminally retires the current session and makes its existing address available to a later `send()`. `cancel` is narrower still: it stops only the active turn and leaves the session, history, and continuation-token ownership intact.

The `context(state, session)` config option builds the per-step `channel` argument handed to every event handler. It receives the channel's live adapter `state` and a `SessionHandle`, and returns the channel-owned context (thread handles, API clients, late-bound callbacks). The framework injects [`ChannelContinuationOps`](#define-a-channel) and passes the result as the second positional argument to each handler. Closing over `session` lets the factory register callbacks that re-key the address later. State mutations made through the returned context are written back to adapter state.

```ts
import { defineChannel } from "eve/channels";

import { mintRef } from "./refs";

defineChannel<{ ref: string | null }>({
  state: { ref: null },
  context(state, session) {
    return {
      state,
      registerAnchor(ref: string) {
        state.ref = ref;
        session.continuation?.rekey(ref);
      },
    };
  },
  events: {
    "message.completed"(eventData, channel) {
      if (!channel.state.ref) channel.registerAnchor(mintRef());
    },
  },
  routes: [/* ... */],
});
```

At the next workflow boundary, the runtime claims the new park hook before releasing the old token. If another active session already owns the new token, the re-keying session fails instead of taking it over. After a successful re-key, inbound deliveries still addressed to the old token are dropped, so coordinate with your senders to use the new token.

## File uploads

`from(address).send()` accepts a `message` containing `string | UserContent`, while
`Session.send()` accepts `string | UserContent` directly. To include file
attachments, pass a `UserContent` array mixing text and file parts:

```ts
await from(continuationToken).send(
  [
    { type: "text", text: body.message },
    { type: "file", data: imageBytes, mediaType: "image/png" },
  ],
  { auth },
);
```

For platforms like Slack where files sit behind authenticated URLs, put a `URL` object in `FilePart.data` and declare `fetchFile` on the channel config:

```ts
defineChannel({
  fetchFile(url) {
    if (!url.startsWith("https://files.slack.com/")) return null;
    return fetch(url, { headers: { authorization: `Bearer ${token}` } })
      .then((r) => r.arrayBuffer())
      .then((b) => ({ bytes: Buffer.from(b) }));
  },

  routes: [
    POST("/webhook", async (req, { from }) => {
      await from(continuationToken).send(
        [
          { type: "text", text: message.text },
          ...message.attachments.map((a) => ({
            type: "file" as const,
            data: new URL(a.url),
            mediaType: a.mediaType,
          })),
        ],
        {
          auth,
          state,
        },
      );
    }),
  ],
});
```

If `fetchFile` throws, eve replaces that attachment with a model-visible error note and continues the turn. Built-in channels include safe details such as the upstream HTTP status. Custom channel errors use a generic note, while eve keeps the original error in operator logs.

The `URL` object survives the queue boundary as a string and is reconstituted inside the workflow step. The staging pipeline calls `fetchFile` with the URL serialized as a string (the URL's `href`), which is why the example matches on `url.startsWith(...)`. Return bytes to stage the file to the sandbox, or `null` to let the URL pass through to the model provider.

The framework handles staging bytes to the sandbox, enforcing upload policy, hydrating files for the model call, and reconstituting `URL` objects after queue serialization. See [Inbound attachments](../sandbox#inbound-attachments) for the shared storage, size, and provider-input behavior.

## What to read next

* [Channels overview](./overview)
* [Dynamic capabilities](../guides/dynamic-capabilities)
* [Auth & route protection](../guides/auth-and-route-protection)
* [Durable cross-channel notifications](../patterns/durable-cross-channel-notifications): deliver a provider message without starting an agent turn
* [Universal Commerce Protocol (UCP)](../protocols/ucp): serve a UCP profile from a custom channel


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)