| title | Custom Channels |
|---|---|
| description | Author custom HTTP and WebSocket channels with routes, events, metadata, continuation tokens, and file uploads. |
Build a custom channel to connect an HTTP or WebSocket endpoint to your agent. It can host a conversation or pass incoming events to another channel.
Custom channels live in agent/channels/ at the root agent or in an extension 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.
Hooks, instrumentation, and delivery.admitted.data.source.channel identify an authored channel as channel:<name>, including channels that only declare routes. Use isChannel(ctx.channel, importedChannel) from eve/channels to match a channel in a hook.
Pass the platform's conversation identity to each operation:
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 delivery = await source.send(body.message, { auth: null });
return Response.json(delivery, { status: 202 });
}),
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 iterator = attachSession(params.sessionId)
.stream({ signal: request.signal })
[Symbol.asyncIterator]();
// This custom event-NDJSON route is not the built-in eve/client wire endpoint.
// Read before sending headers so stranded-session errors reach the route.
const first = await iterator.next();
let firstPending = true;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
const next = firstPending ? first : await iterator.next();
firstPending = false;
if (next.done) controller.close();
else controller.enqueue(encoder.encode(JSON.stringify(next.value) + "\n"));
},
async cancel() {
await iterator.return?.();
},
});
return new Response(stream, {
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
}),
],
events: {
"content.completed"(event, ctx) {
if (event.data.phase === "reply") console.log(ctx.session.id, event.data.value);
},
},
});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)bindssend,respond,cancel,compact,clear, andresetto 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, andrequestIpprovide request metadata and lifetime control. -
describe()returns the agent's name, description, the compiled tools a caller can run outside a turn, with their JSON schemas, and the compiled skills, each with itsnameanddescription, sorted by name. It reads the compiled manifest only, never a skill file, and carries no other paths, config, or inspection detail, so a channel can publish it to its callers. Tools that need a turn (framework tools, workflow and provider tools, tools withoutexecute) and tools and skills added by dynamic resolvers or subagents are left out. -
invokeTool(name, input, { auth, key })runs one of those tools asauth, outside any conversation: no model, turn, or workflow run. Authored state starts from its initial value and is not kept. Withoutkey, each call gets its own session, and a sandbox the tool opens is deleted when the call ends. Withkey(1 to 512 characters), calls from the sameauthprincipal with the same key share a session id and a sandbox, which is kept between calls; this needs Vercel Sandbox, just-bash, or a custom provider that setstoolSessions: true, and an authenticatedauth(a keyed call from the anonymous principal isdenied); see MCP tool sessions. When a trusted forwarder assertedauth, pass the principal your route verified asforwardedBy: it is part of the session, so one user reached through two forwarders gets two sessions. It returnsoutcome: "completed"with output,"failed"with an error (including invalid input), or"rejected"with a reason (including approval or sign-in requirements); a direct call cannot pause for a person.A direct call doesn't go through the AI SDK, so AI SDK telemetry integrations never see it. Conversation tool calls do: an integration that an
agent/instrumentation/*.tsmodule registers withregisterTelemetry()fromaireceivesonToolExecutionStartandonToolExecutionEndfor each of them. A direct call is traced by eve itself instead. It gets anexecute_toolspan under the caller's trace and emitscall.requestedandcall.settledto instrumentation, as a conversation's calls do. Over MCP, the caller's trace comes from the request's_metatrace context (see Trace context). -
listSkillFiles(skill)resolves to the skill's regular files, each with itspathunder the skill root and itssizein bytes, sorted by path. It rejects a skill the agent does not have. -
readSkill(skill, path?)resolves to one skill file's raw bytes as aUint8Array; decode text yourself, for example withnew TextDecoder().decode(bytes).pathis relative to the skill root, such asreferences/api.md, and defaults to the skill'sSKILL.md. It rejects absolute paths, paths with.,.., or empty segments, files the skill does not list, and files over 512 KiB.
Event handlers receive (event, ctx): the whole fact, with its type, data, and
scope, and a context. ctx.session.id identifies the exact session, ctx.position
and ctx.view locate the fact on the stream and expose an opaque snapshot of its whole commit,
and ctx.channel is the channel's own context. ctx.channel.continuation exposes the
current address and alias() when this channel needs to add another address. Handlers
run after the line is written, so they observe facts and never shape them.
Use committed(line, ctx) when you need to update a status or card once per committed line, rather than once per fact. It runs after the line's fact handlers, with line.facts, line.position, and the whole line's snapshot at ctx.view.
Handle interaction.settled to retire a rendered question or approval, whether it was answered, withdrawn, or interrupted. Match by interactionId and check its outcome.
ctx.channel.continuation.token is always the channel-local address accepted by
from(), resolveSession(), and alias(). Framework namespace prefixes are not
part of the authored channel API.
Channel operations are dynamic: every call targets whichever session currently
owns the address. create() and send() can create a session when the address is unowned.
Without an idempotency key, a new session's handle can
return before it claims the address. If another send wins the address, eve
forwards the message to that session. Call resolveSession(address) after
startup to get the winning session's handle.
const source = from(threadId);
const delivery = await source.send("Hello", { auth });
const result = await delivery.result();
await source.respond(answers, { auth });
await source.cancel({ turnId });
await source.compact();
await source.clear();
await source.reset({ reason: "Start over" });
const currentSession = await resolveSession(threadId);When the session at an address is
stranded because another eve version
built it, send() ends that session and starts a fresh one. The message becomes the fresh
session's first turn, and the fresh session can read recent text from the earlier conversation;
see Recover a stranded session. Concurrent sends to the same stranded address still
produce one fresh session. A respond() call, or a deliver() that includes input responses,
carries answers a fresh session has no request for, so it throws SessionStrandedError and leaves
the stranded session in place.
Session from eve/sessions is fixed: every call targets exactly one durable ID. It never
creates, follows, or resolves a replacement. Shared Delivery, DeliveryResult, SessionReader,
stream/read options, and SessionNotActiveError also come from eve/sessions, not eve/channels.
Accepted commands return reusable deliveries: call stream({ signal }) for events or
result({ signal }) for a settled summary with its outcome and failure error. Aborting a read
does not cancel the command or other readers.
const session = attachSession(sessionId);
await session.send("Follow up", { auth });
await session.respond(answers, { auth });
await session.cancel({ turnId });
await session.compact();
await session.clear();
await session.reset({ reason: "Retire this session" });
for await (const event of session.stream({ startIndex: 12 })) render(event);A following getEventStream() rejects before it returns a stream when the session does not exist
or its recorded history has expired, so a route can answer with an error status before it sends any
bytes.
For a stranded session, Session.send(), Session.respond(), and a following
read through Session.stream() throw SessionStrandedError from eve/channels and deliver nothing.
stream({ follow: false }) still reads the recorded history up to the current tail without
changing the session. error.owner.eveVersion names the eve release that built the session when it recorded one. Call
reset() to end the stranded session, then start a new one:
import { SessionStrandedError } from "eve/channels";
try {
await attachSession(sessionId).send(text, { auth });
} catch (error) {
if (!SessionStrandedError.is(error)) throw error;
await attachSession(sessionId).reset({ reason: "Session was stranded by an upgrade" });
}respond() accepts exact response literals directly. If responses have already
been widened to Answer[]—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:
import { parseAnswers } from "eve/client";
const answers = parseAnswers(decodedResponses);
await source.respond(answers, { auth });Message sends use the channel's turnPolicy, which defaults to "steer". Before assistant answer output or local tool execution begins, steering interrupts pending model generation and applies the correction in the same turn. Executing tools finish and checkpoint their results first. After answer output starts, steering waits for the next committed workflow boundary. Reasoning and search progress do not count as answer output, and interrupted provider-managed search may run again. The turn ID stays the same.
A sent delivery can interrupt generation before your deliver hook runs. If the hook ignores the correction, eve resumes the original request with its original auth, delivery provenance, and channel state. Filter events before send(...) when they should never interrupt generation.
Every send() and respond() delivery passes its state option to your channel's deliver(payload, channel) hook as payload.state, including deliveries to an existing session. Use it for values that belong to one delivery, such as the author of a message. The hook decides what to merge into channel.state; eve seeds channel.state from state only when send() creates the session.
The payload is ChannelDelivery<TState> from eve/channels: message, answers, context, outputSchema, and state?: Partial<TState>. Return a replacement with message, answers, context, or outputSchema to change the delivery, or return nothing to keep it unchanged. Change durable state through channel.state, not a state field on the replacement. To ignore a delivery, return {}. The hook does not return a harness input or accept arbitrary extra fields.
import { defineChannel } from "eve/channels";
export default defineChannel({
state: { author: "" },
deliver(payload) {
const author = payload.state?.author;
if (author && typeof payload.message === "string") {
return { ...payload, message: `${author}: ${payload.message}` };
}
},
routes: [/* your routes */],
});Configure turnPolicy: "queue" on defineChannel(...) when messages should wait for the active turn to finish, or override one send:
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 answers. 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.
After verifying a webhook, pass its stable event ID as idempotencyKey. Reuse the key
when retrying a failed or timed-out send:
const session = await from(conversationId).send(message, {
auth,
idempotencyKey: providerEventId,
});Channel and fixed-session send() and respond() calls accept this option,
as does the Chat SDK bridge. Keys must contain 1–256 characters and identify one
delivery. Prefix IDs with the provider or event type if they can overlap.
Reusing a retained key ignores the new input, even if the message changed.
Each session retains its last 1,024 distinct keys; reuse after eviction, after the session ends, or in another session can run again. Replicas must share the same durable Workflow World. A known duplicate returns a new receipt settled ignored, without repeating the original reply. Use result() to read a send's settlement.
Await send() before acknowledging a webhook when the provider allows it.
Acknowledging sooner can lose work. This does not make tool calls, outbound
posts, or work done before send() exactly once.
A channel value can be spread into a new object with replaced routes. eve keeps the channel's adapter, events, and build metadata (such as the Slack app manifest) on the spread copy, so { ...channel, routes } compiles and runs like the original.
The built-in Slack, Teams, and Telegram channels, and the Chat SDK bridge's send, hand every inbound message to from(address).send(message, options). A route wrapper that replaces send on the source returned by from(address) therefore sees each inbound message, and a rejected delivery reaches the wrapper as an error:
import type { RouteHandlerArgs } from "eve/channels";
import { slackChannel, type SlackChannelState } from "eve/channels/slack";
const slack = slackChannel();
function reportFailedDeliveries(
args: RouteHandlerArgs<SlackChannelState>,
): RouteHandlerArgs<SlackChannelState> {
return {
...args,
from(address) {
const source = args.from(address);
return {
...source,
async send(message, options) {
try {
return await source.send(message, options);
} catch (error) {
console.error("Slack delivery failed", { address, error });
throw error;
}
},
};
},
};
}
const channel: typeof slack = {
...slack,
routes: slack.routes.map((route) =>
route.transport === "websocket"
? route
: {
...route,
handler: (request: Request, args: RouteHandlerArgs<SlackChannelState>) =>
route.handler(request, reportFailedDeliveries(args)),
},
),
};
export default channel;Keep each route's method and path unless you mean to move the endpoint.
cancelcooperatively stops the active turn. Confirm it withturn.settledandoutcome: "cancelled"; the session accepts another message afterward.compactsummarizes model context without adding a synthetic user message. It is a context change:context.started, thencontext.settledwithkind: "compaction".clearremoves model-message history in place. It preserves the system prompt, skills, tools, durable state, limits, address ownership, and session sandbox.resetterminally retires the current session. A later addresssend()creates a fresh session; a fixedSessionhandle remains pinned to the retired ID.
Control operations never create a session. Unknown or inactive targets throw
SessionNotActiveError; an accepted no-op cancellation settles with outcome: "ignored". Authenticate and deduplicate command webhooks before
calling reset, because a delayed duplicate can retire a newer address owner.
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:
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"))],
});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:
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.
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", ...).
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.
Use ctx.to(channel, target).send(message, { auth }) in a route handler to start or resume a conversation on another channel. The agent runs and replies on the destination channel. The hand-off doesn't start a session on the source channel, which returns only its HTTP response.
This webhook filters incoming events and asks the agent to investigate in Slack. Import the destination channel's default export; verifyAndParseEvent is a helper you implement in your application.
import { defineChannel, POST } from "eve/channels";
import { verifyAndParseEvent } from "../lib/inbound-events";
import slack from "./slack";
export default defineChannel({
routes: [
POST("/intake/events", async (req, ctx) => {
const event = await verifyAndParseEvent(req);
if (!event) return new Response("unauthorized", { status: 401 });
if (event.kind !== "actionable") return new Response("ok");
ctx.waitUntil(
ctx
.to(slack, { channelId: "C0123ABC" })
.send(`Investigate ${event.reference}: ${event.summary}`, {
auth: {
authenticator: "intake",
principalType: "service",
principalId: event.source,
attributes: {},
},
idempotencyKey: `intake:${event.id}`,
}),
);
return new Response("ok");
}),
],
});Before sending:
- Verify the provider's signature and validate the payload, including size limits. Derive
event.sourcefrom the verified sender, not an untrusted payload field. - Filter out events that should not reach the agent.
- Pass the provider's stable event ID as
idempotencyKey, so a redelivered webhook doesn't deliver the message twice. See Retried deliveries for the limits.
The supplied auth identifies who started the session, available to tools and event handlers as session.auth.initiator.
The destination's receive hook defines the target fields and how they map to a session. See Slack proactive sessions for its target options. To let people continue the conversation, configure the destination's inbound handlers to accept follow-ups.
send takes the same options as from(address).send() and Session.send(): auth, context, history, idempotencyKey, outputSchema, title, and turnPolicy. The destination's receive hook gets them with the message and target, and every built-in channel passes them to the session. Keys are deduplicated per destination session, which every sender to that destination shares, and a Slack target with only a channelId uses the key to choose the session, so a retry reaches the same session. Make the key unique across all senders by prefixing it with the source, for example pagerduty:${event.id}. Two senders that pick the same key reach the same session, and the second delivery is ignored. The key still only dedupes within that session's retained keys: a retry after the key is evicted runs another turn, and a retry after the session ends starts a new one, which on Slack is a new thread. Anything a receive hook does before sending also repeats on retry, such as posting an initialMessage (Slack, Teams, Discord, GitHub, and Telegram) or creating a Linear Agent Session for an issueId or commentId target.
A custom channel's receive hook maps the target to an address. Pass the remaining input to from(address).send(), so the session gets every option the sender set:
async receive({ message, target, ...send }, { from }) {
return await from(`inbox:${target.inboxId}`).send(message, { ...send, state: { inboxId: target.inboxId } });
},Route handlers, handler-form schedules, and tools provide to(...); other platform callbacks don't.
For notifications that don't need an agent turn, use the platform's API instead. See Durable cross-channel notifications when those posts also need retries.
A channel can project a subset of its adapter state as metadata, available to instrumentation resolvers and to the select of dynamic tools, skills, instructions, and other dynamic capabilities. Define a metadata(state) function on the channel config:
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"(_event, ctx) {
ctx.channel.state.internalCounter += 1;
},
},
});The projection is re-evaluated whenever adapter state changes after channel event handlers run. A dynamic capability's select reads it via ctx.channel.metadata and narrows it with isChannel. See Dynamic capabilities for the full consumption pattern.
Use audience(input) to assign an observability audience. The audience controls
whether instrumentation may capture conversation content. The hook runs after
route authentication and before the session is created:
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, 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". A metadata() projection's audience field does not classify visibility; use audience(input).
When you explicitly annotate a callback, use AudienceContext<TState>. The retired AudienceInput, AudiencePrincipal, and auth input are not supported; classify the privacy-limited caller instead.
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.
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.
import { slackContinuationToken } from "eve/channels/slack";
import { twilioContinuationToken } from "eve/channels/twilio";
slackContinuationToken({ channelId: "C0123ABC", threadTs: "1800000000.001234" }); // "C0123ABC:1800000000.001234"
twilioContinuationToken({ from: "+15551234567", to: "+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 another identity should address a session, add it with channel.continuation?.alias(rawToken). The runtime preserves the current channel namespace and exposes the new token as channel.continuation.token for later handlers.
Aliasing adds an address to the current session. Earlier addresses continue to resolve to the same session and accept the same payloads. reset terminally retires the current session and makes all its addresses available to later send() calls. cancel stops only the active turn and leaves the session, history, and continuation-token ownership intact.
The context(state, channel) config option builds the per-step context read as ctx.channel
in event handlers. Its second argument is ChannelContext from eve/channels: live
session.id and session.auth information, plus channel-owned continuation routing. It does
not expose session commands or put aliasing on session information. Close over
channel.continuation for late-bound callbacks, or call ctx.channel.continuation?.alias(token)
from an event handler. eve adds ChannelContinuationOps to the returned context and writes
state mutations back to adapter state.
import { defineChannel } from "eve/channels";
import { mintRef } from "./refs";
defineChannel<{ ref: string | null }>({
state: { ref: null },
context(state, channel) {
return {
state,
registerAnchor(ref: string) {
state.ref = ref;
channel.continuation?.alias(ref);
},
};
},
events: {
"content.completed"(_event, ctx) {
if (!ctx.channel.state.ref) ctx.channel.registerAnchor(mintRef());
},
},
routes: [/* ... */],
});At the next workflow boundary, the runtime adds the new address to the session's merged inbox. If another active session already owns the new token, the aliasing session fails instead of taking it over. Every address claimed by the session remains active until the session ends or resets. Sessions with continuation addresses remain on their current deployment until Workflow supports atomic ownership transfer.
Tokens must be nonempty. A session supports up to 256 addresses, including its stable inbox; repeating an existing alias does not consume another address.
from(address).send() accepts a message containing MessageContent (a string, or
text and file parts), while Session.send() accepts MessageContent directly. To
include file attachments, pass an array mixing text and file parts:
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 the file part's data and declare fetchFile on the channel config:
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 note and continues the turn. To tell the model why, throw attachmentError from eve/channels:
import { attachmentError } from "eve/channels";
async function fetchUpload(url: string) {
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (response.status === 413) {
throw attachmentError("The file is over 100 MB. Ask for a smaller file.");
}
return Buffer.from(await response.arrayBuffer());
}The model then reads Attachment report.pdf could not be retrieved: The file is over 100 MB. Ask for a smaller file. The message reaches the model as written, so keep it to details the person can act on, such as a size limit or a missing permission, and never include URLs, tokens, or upstream response text. Pass the original error as attachmentError(message, { cause }) to keep it in operator logs. Any other error that fetchFile throws becomes a generic note, and eve keeps the original error in operator logs.
fetchFile receives the channel's state and the session the attachment arrived in as its second argument, so a resolver can check a file against the session's caller in session.auth.
The URL object survives the queue boundary as a string and is reconstituted inside the workflow step. A string with a URL scheme, such as https://... or myapp-file:abc, is handled the same way. 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. Return null when the URL is not one your channel resolves: eve then downloads a public https: URL itself, with the 25 MB default size cap and a 30-second timeout, and refuses private or reserved addresses. It downloads at most 10 URLs per message, one at a time. Any other URL, or a download that fails, reaches the model as a note. The model provider never fetches an attachment, so a link that expires or needs credentials can't fail later model calls.
Set uploadPolicy on defineChannel to cap what eve stages for the channel, for example uploadPolicy: { maxBytes: 10 * 1024 * 1024, allowedMediaTypes: ["image/*", "application/pdf"] }. eve checks every staged file against it, on the final bytes and the media type they prove, including URLs it downloads itself. A file that breaks the policy reaches the model as a note. Without uploadPolicy, eve checks only what your fetchFile enforces, and its own downloads stop at 25 MB.
The framework handles staging bytes to the sandbox, hydrating files for the model call, and reconstituting URL objects after queue serialization. See Inbound attachments for the shared storage, size, and provider-input behavior.
- Channels overview
- Dynamic capabilities
- Auth & route protection
- Durable cross-channel notifications: deliver a provider message without starting an agent turn
- Universal Commerce Protocol (UCP): serve a UCP profile from a custom channel