eve 中文教程 · 第 14 章 通道
通道决定 agent 在哪里和用户见面,也决定「谁在说话」。同一份 agent 代码可以同时挂到 HTTP、Slack、Discord、Telegram。这一章讲默认 HTTP 通道的鉴权,以及怎么接 Slack、写自定义通道。
14.1 通道解决两件事
- 投递:平台消息进来,转成 turn;回复出去,转成平台格式。
- 身份:把平台用户映射成 eve 的 principal,让工具能按人/租户隔离。
第二件比第一件重要。没有身份映射,所有「按用户」的逻辑都会退化成一个共享账号,记忆、审批、配额全部串号。
14.2 选哪个通道
| 你要的 | 用 |
|---|---|
| 网页聊天 | eve 通道 + useEveAgent(第 17 章) |
本地工具、SDK、curl |
默认 eve HTTP 通道 |
| Slack 的 @、DM、按钮 | channel/slack |
| Discord 斜杠命令、组件 | channel/discord |
| Teams 消息 + Adaptive Cards | channel/teams |
| Telegram bot | channel/telegram |
| 短信 / 电话转写 | channel/twilio、channel/linq |
| GitHub issue / PR | channel/github |
| Linear issue 委派 | channel/linear |
| MCP 客户端把 agent 当工具用 | channel/mcp |
| 内部 webhook、WebSocket | 自定义通道 |
14.3 默认 HTTP 通道
agent/channels/eve.ts 配置 /eve/v1 整个表面。不写这个文件,框架用默认源:[vercelOidc(), localDev(), placeholderAuth()]。
// agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc } from "eve/channels/auth";
export default eveChannel({
auth: [vercelOidc(), localDev()],
});
eveChannel 的字段(只有 auth 必填):
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
auth |
AuthFn \| AuthFn[] |
必填 | 路由鉴权链,按顺序尝试 |
audience |
ChannelAudience \| (input) => ChannelAudience |
按调用者推导 | 追踪内容的可见性分类 |
turnPolicy |
"steer" \| "queue" |
"steer" |
turn 进行中收到新消息怎么办 |
uploadPolicy |
UploadPolicyInput \| "disabled" |
25 MB、所有类型 | 入站附件的大小和类型 |
fetchFile |
(url, ctx) => bytes \| { bytes, … } \| null |
无 | 把 URL 形式的附件取回;返回 null 表示「不是我的,你去下载」 |
cors |
boolean \| { origin, methods, … } |
不处理 | 浏览器 CORS |
onMessage |
(ctx, message) => { auth, context?, title? } |
用路由鉴权结果 | 派发前改写入站消息 |
events |
事件处理器表 | 无 | 流事件钩子,参数是 (event, channel, ctx) |
trustedForwarders |
谓词 | 无 | 允许哪些上游转发用户身份;不配就一律 403 |
鉴权链怎么走
auth 收一个 AuthFn 或数组,按顺序尝试。每个条目三种结果:
| 返回 | 结果 |
|---|---|
一个 SessionAuthContext |
接受,停止遍历 |
null / undefined |
跳过,试下一个 |
| 抛异常 | 用特定状态码拒绝(UnauthenticatedError → 401,ForbiddenError → 403) |
全部跳过 → 401,WWW-Authenticate 头会列出配置里声明过的方案(httpBasic() 声明 Basic,token 类声明 Bearer)。
helper 一览
| Helper | 作用 |
|---|---|
localDev() |
只在 eve dev / vercel dev 进程里放行(看部署环境,不看请求头) |
vercelOidc() |
验证 Vercel OIDC bearer,本地 CLI 和同团队部署互调靠它 |
none() |
显式接受匿名流量,只放最后一位,只用于公开演示 |
httpBasic({ username, password }, { realm }) |
共享用户名密码,最省事的内部工具方案 |
jwtHmac(...) / jwtEcdsa(...) / oidc(...) |
自签 JWT / 非对称 JWT / 任意 OIDC issuer |
placeholderAuth() |
生产一律 401,脚手架占位 |
verifyHttpBasic(header, { username, password }) |
低层校验,返回 { ok, sessionAuth } |
withAuthChallenges(fn, challenges) |
给自定义 AuthFn 声明 401 的方案 |
localDev() 和 vercelOidc() 都不放行生产环境的浏览器用户和外部客户端。面向公众的应用必须接自己的鉴权。
鉴权函数是运行时入口:里面读 process.env 是安全的,值不会进编译产物,进程启动时从源码重新取。反过来说,别把密码写成模块顶层的常量——那是编译期求值。细节见附录 C。
一个可用的单用户方案
// agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { httpBasic, localDev, vercelOidc } from "eve/channels/auth";
const username = process.env.OPS_USERNAME;
const password = process.env.OPS_PASSWORD;
export default eveChannel({
auth: [
// 配置缺失就不挂这条,让请求落到后面的条目并最终 401
...(username && password
? [httpBasic({ username, password }, { realm: "opsbot" })]
: []),
vercelOidc(),
localDev(),
],
});
需要自己控制校验细节(同源检查、注入 attributes)时,用低层组合:
// agent/lib/auth.ts
import { verifyHttpBasic, withAuthChallenges } from "eve/channels/auth";
export const appAuth = withAuthChallenges(
(request: Request) => {
const username = process.env.OPS_USERNAME;
const password = process.env.OPS_PASSWORD;
if (!username || !password) return null; // 配置缺失 → 关闭
// 浏览器发起的跨站请求一律拒绝
if (request.headers.has("origin") &&
request.headers.get("sec-fetch-site") !== "same-origin") return null;
const result = verifyHttpBasic(request.headers.get("authorization"), { username, password });
if (!result.ok) return null;
return {
...result.sessionAuth,
attributes: { team: "ops" },
issuer: "opsbot",
};
},
[{ scheme: "Basic", parameters: { realm: "opsbot", charset: "UTF-8" } }],
);
要点:缺环境变量、缺凭据、凭据错误,全部 fail closed(返回 null,最终 401)。共享密码不提供每人独立身份,多人使用要换真的会话鉴权。
CORS
默认不管 CORS。浏览器直连时才开:
export default eveChannel({
auth: [...],
cors: {
origin: "https://app.example.com",
methods: ["GET", "POST"],
allowedHeaders: ["authorization", "content-type"],
},
});
上传
export default eveChannel({
auth: [...],
uploadPolicy: { maxBytes: 100 * 1024 * 1024 },
async fetchFile(url) {
if (!url.startsWith("https://uploads.example.com/")) return null;
const r = await fetch(url, {
headers: { authorization: `Bearer ${process.env.UPLOADS_TOKEN}` },
});
if (!r.ok) throw new Error(`上传取回失败 ${r.status}`);
return Buffer.from(await r.arrayBuffer());
},
});
- 默认上限 25 MB、所有类型。超出返回
413,类型不允许返回415。 - 内联 base64 还受宿主请求体限制。Vercel Functions 是 4.5 MB,所以 base64 文件大约 3.3 MB 就到顶。
- 大文件先传到自己的存储,把 URL 放进 file part,再用
fetchFile在 workflow step 内取回——字节不经过请求体。 uploadPolicy覆盖 eve 为这个通道 staged 的所有文件,包括它自己下载的 URL。
uploadPolicy 只有两个字段(可以只给一个,其余用默认值):
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
maxBytes |
number |
25 MB | 解码后的大小上限。0 等于拒绝所有附件 |
allowedMediaTypes |
string[] \| "*" |
"*" |
精确值或通配(image/*)。不在列表里返回 415 |
整个值也可以直接给 "disabled",拒绝一切附件。
请求级上下文
import { defaultEveAuth, eveChannel } from "eve/channels/eve";
export default eveChannel({
auth: [...],
onMessage(ctx, message) {
return {
auth: defaultEveAuth(ctx),
context: [`HTTP 调用者 ${ctx.eve.caller?.principalId ?? "anonymous"} 说:${message}`],
};
},
events: {
"message.completed"(eventData, _channel, ctx) {
console.log("回复完成", { sessionId: ctx.session.id });
},
},
});
context字符串会变成持久的用户消息进历史。- 只想影响本轮、不写历史,返回
clientContext。它替换请求自带的clientContext,进入这个 turn 的每次模型调用,但不进会话历史。 ctx.eve.clientContext是客户端原样发来的那份,没发就是undefined。用它来校验或纠正客户端状态:
async onMessage(ctx) {
const auth = defaultEveAuth(ctx);
const plan = await getVerifiedPlan(auth); // 以服务端为准
return { auth, clientContext: [`用户的套餐是 ${plan}。`] };
},
clientContext 的形状和请求里的一样(ClientContextValue:字符串、字符串数组或 JSON 对象)。返回 [] 表示丢掉请求带来的上下文,不返回则保留。eve 给客户端提供的内容打 Client context: 标签,onMessage 返回的不打标签。onMessage 只对带消息的请求跑,纯输入应答沿用请求自己的上下文。
- onMessage 必须返回 auth 结果。
audience
通道创建会话时给一个可观测性分类,决定追踪能不能记录内容:
| 会话创建者 | 默认 audience |
|---|---|
| 匿名 | unknown |
user / service / runtime principal |
private |
| 其它 principal 类型 | unknown |
audience 不影响谁能访问(那是 auth 的事),只影响内容捕获。公开演示站可以设 audience: "public"。
14.4 Slack
eve add channel/slack
推荐走 Vercel Connect,它托管 bot token、请求验签、token 轮换和多工作区安装:
// agent/channels/slack.ts
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";
export default slackChannel({
credentials: connectSlackCredentials("slack/opsbot"),
});
不想用 Connect 就自己管凭据:在 Slack app 里加 app_mentions:read、chat:write(DM 还要 im:history,附件要 files:read,长回复转摘要要 files:write),把 Request URL 指向 https://<你的域名>/eve/v1/slack。
Slack 通道自动处理:
@mention、DM、slash command、快捷方式、交互回调- 在线程里回复、打字指示器
- 把审批和提问渲染成按钮和下拉
- 任务卡片(子代理/长任务的进度)
- 长回复自动转成 snippet
文本型通道(Twilio、GitHub、Linear、Chat SDK 系)只能一次显示一个请求,答完再显示下一个;Slack 这类有按钮的通道会把所有待答请求都摆出来。
排查
| 现象 | 先查什么 |
|---|---|
| @ 了没反应 | 事件订阅没开 app_mention,或 Request URL 指错,或部署在 Deployment Protection 后面 |
| 审批按钮点不动 | Interactivity 的 Request URL 没配 |
| DM 或线程回复不工作 | 加 message.im + im:history,重装 app |
| 长回复发不出 | 加 files:write,重装 app |
| 预览分支收不到 | 分支域名要能绕过 Deployment Protection,或加 x-vercel-protection-bypass |
14.5 其它平台
| 通道 | 命令 | 备注 |
|---|---|---|
| Discord | eve add channel/discord |
服务器/频道 |
| Telegram | eve add channel/telegram |
bot |
| Teams | eve add channel/teams |
Adaptive Cards |
| GitHub | eve add channel/github |
issue/PR,可 checkout 代码 |
| Linear | eve add channel/linear |
issue 委派、Agent Sessions |
| Twilio | eve add channel/twilio |
SMS/语音 |
| MCP | eve add channel/mcp |
把 agent 的工具发布成 MCP server |
| Web Chat | eve add channel/web |
Next.js / TanStack Start 前端 |
配置文件都在 agent/channels/<name>.ts,配置项按平台不同。共同点:都会把平台用户映射成 principal。
14.6 自定义通道
自定义通道要处理两个方向:平台消息进来(入站)、agent 回复出去(出站)。
flowchart TD
P["Slack / 内部系统"] -->|"webhook"| RT["路由<br/>POST /threads/:id/messages"]
RT -->|"from(address).send()"| SE["会话"]
SE -->|"事件流"| AD["通道适配器"]
AD -->|"平台 API 回帖"| P
RT -.->|"resolveSession / attachSession"| SE
SE -.->|"to(channel, target)"| O["其它通道"]
入站用地址句柄(from(address)),因为它要跟随「谁现在拥有这个线程」;出站由事件流驱动,通道把事件翻译成平台格式。attachSession 只在你明确要钉住某个会话 ID 时用。
平台不在列表里,或要接内部系统时,写 defineChannel。路由是数组,用 GET / POST / WS 构造:
// agent/channels/internal.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();
if (body.message === "/new") {
return Response.json(await from(params.threadId).reset({ reason: "用户要求重开" }));
}
const session = await from(params.threadId).send(body.message, {
auth: {
authenticator: "internal",
principalId: body.userId,
principalType: "user",
},
});
return Response.json({ sessionId: session.id });
}),
POST("/threads/:threadId/cancel", async (_request, { from, params }) =>
Response.json(await from(params.threadId).cancel()),
),
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);
},
},
});
注意:路由的 path 就是应用 URL,不会自动加通道名前缀。上面第一条回答的是 POST /threads/:threadId/messages,不是 /internal/threads/...。也不要占用 /eve/v1/*。
defineChannel 的字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
routes |
RouteDefinition[] |
是 | 用 GET / POST / WS 构造的路由表 |
state |
任意可序列化对象 | 否 | 通道自己的适配器状态,跨请求保留 |
metadata |
(state) => object |
否 | 把 state 投影成可观测元数据,只返回想暴露的字段 |
context |
(state, session) => ctx |
否 | 构造传给 events / deliver 的通道上下文 |
events |
事件处理器表 | 否 | 流事件钩子,参数 (event, channel, ctx) |
receive |
(input, ctx) => Session |
否 | 跨通道投递的接收端实现 |
deliver |
(payload, ctx) => StepInput |
否 | 自定义投递逻辑(默认走事件流) |
turnPolicy |
"steer" \| "queue" |
否 | 该通道消息的默认策略 |
cors |
boolean \| options |
否 | 该通道路由的 CORS |
audience |
(input) => ChannelAudience |
否 | 会话受众分类 |
fetchFile |
同 eveChannel |
否 | 取回 URL 形式的附件 |
uploadPolicy |
同 eveChannel |
否 | 附件大小和类型 |
路由处理器的第二个参数:
| 参数 | 类型 | 说明 |
|---|---|---|
from |
(address) => 地址句柄 |
按通道地址寻址,send() 才会建会话 |
resolveSession |
(address) => Session \| null |
查当前持有该地址的会话快照 |
attachSession |
(sessionId) => Session |
钉死一个会话 ID,不做 IO |
to |
(channel, target) => 句柄 |
往别的通道投递 |
params |
Record<string, string> |
路径参数(:threadId 等) |
waitUntil |
(p: Promise) => void |
延长请求寿命,做后台工作 |
requestIp |
string \| null |
宿主机报告的最佳猜测 IP |
describe |
() => Promise<AgentDescription> |
agent 名称、描述、可在会话外跑的工具、技能清单 |
listSkillFiles |
(skill) => Promise<…> |
列出一个技能的普通文件 |
readSkill |
(skill, path?) => Promise<Uint8Array> |
读技能文件,默认 SKILL.md,单文件上限 512 KiB |
invokeTool |
(name, input, opts) => Promise<…> |
以指定身份在会话外跑一个工具 |
三种句柄,别混
| 句柄 | 寻址方式 | 会建会话吗 |
|---|---|---|
from(address) |
通道自己的地址(线程、频道…) | 只有 send() 会 |
resolveSession(address) |
查当前拥有该地址的会话快照 | 不会 |
attachSession(sessionId) |
固定的会话 ID | 不会 |
const source = from(threadId);
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 current = await resolveSession(threadId); // 当前持有者
const fixed = attachSession(sessionId); // 钉死一个 ID
地址句柄是动态的:每次调用都指向当前拥有该地址的会话。attachSession 是固定的:永远指向那一个 ID,不会跟随、不会重建。
地址竞争与 stranded
- 冷启动时两个请求同时发到同一个地址:输的那个把消息转发给赢家,然后退出。不会出现两个进程同时服务一个线程。
- 地址上的会话 stranded(被别的 eve 版本构建)时:
send()结束它并在同一地址开新会话;respond()抛SessionStrandedError(新会话没有那个请求可答)。
元数据
通道可以把适配器状态投影成 metadata,给 instrumentation、动态工具/技能/指令解析器读:
export default defineChannel({
state: {
topic: null as string | null,
internalCounter: 0,
},
metadata(state) {
return { topic: state.topic }; // 只暴露想暴露的
},
routes: [
POST("/start", async (req, { from }) => {
const body = await req.json();
await from(body.token).send(body.message, {
auth: null,
state: { topic: body.topic, internalCounter: 0 },
});
return new Response("ok");
}),
],
});
state 里的字段不会自动暴露——metadata(state) 返回什么,解析器才能看到什么。
WebSocket
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() 每个升级请求跑一次,返回生命周期钩子(upgrade / open / message / close / error)。参数和 HTTP 路由一样(from、to、attachSession、params、waitUntil、requestIp)。
跨通道投递
await to(slack, { channelId: "C0123ABC" }).send("日报已生成", { auth: appAuth });
to(channel, target) 返回的句柄和路由里的 from(address) 契约一致。跨通道投递会新建会话(除非目标是已有对话,比如某个 Slack 线程)。
14.7 MCP 通道:把 agent 的工具发布出去
反过来,让别的 MCP 客户端把你的 agent 当工具用:
// agent/channels/mcp.ts
import { localDev } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";
export default mcpChannel({
auth: localDev(),
tools: true,
});
三个开关:
| 开关 | 默认 | 作用 |
|---|---|---|
agent |
开 | 发布 agent_start / agent_get / agent_update / agent_cancel 四个会话工具 |
tools |
关 | 额外发布 agent 能在会话外直接跑的工具 |
skills |
开 | 发布技能 |
tools: true 的边界要说清楚:框架工具、workflow / provider 工具、没有 execute 的工具、动态解析器和子代理加的工具都不发布。三个开关全关会抛错(什么都发布不了)。
每个 tools/call 用路由认证的调用者身份执行,不经过模型。除非加入 tool session,每次调用是独立会话:
defineState从初始值开始,改动不保留;- 调用打开的沙箱在结束时删除;
- 调用可以并行,丢响应后的重试会真的再跑一次。
这意味着能调这些工具的调用者可以绕过 agent 的指令直接操作。开 tools: true 之前先看一遍会暴露什么。
14.8 通道 vs 连接
容易混:
| 通道 | 连接 | |
|---|---|---|
| 方向 | 用户 → agent | agent → 服务 |
| 例子 | Slack、HTTP、Telegram | Linear MCP、CRM OpenAPI |
| 模型感知 | 不感知,通道在模型之外 | 感知,工具出现在目录里 |
一个 agent 可以同时有多个通道和多个连接,互不影响。
14.9 常见坑
坑:上线后发现谁都能访问。
脚手架带 placeholderAuth(),它在生产返回 401。但如果你删掉了 channels/eve.ts 或改成了 localDev(),生产就裸奔了。上线前用无凭据的 curl 打一次 POST /eve/v1/session,必须 401。
坑:Slack 里 @ 了但没反应。
事件订阅没开 app_mention,或 Request URL 指错,或部署在 Deployment Protection 后面而 Slack 过不去。
坑:Slack 里审批按钮点不动。 Interactivity 的 Request URL 没配。审批按钮走的是交互回调。
坑:把 bot token 写进代码。 走环境变量或 Vercel Connect。
坑:自定义通道里 from(address) 用错地址粒度。
地址粒度决定「什么算同一段对话」。Slack 用线程级,Discord 用频道级,选错会让不同人的对话串在一起。
坑:用 attachSession 处理平台消息。
它是固定 ID 句柄,不会跟随地址变化。平台消息应该用 from(address)。
坑:以为通道鉴权能保护工具。
通道鉴权只决定「谁能发消息」。工具级权限要用 approval 和工具内的 ctx.session.auth 判断。
坑:metadata(state) 返回整个 state。
内部计数器、临时字段都会泄漏给解析器。只返回需要暴露的字段。
14.10 练习
- 用 HTTP Basic 给 opsbot 加鉴权,用
curl验证无凭据 401、有凭据 200。 - 接 Slack,在频道里 @ 它,走一遍审批按钮。
- 写一个自定义通道,暴露
POST /threads/:id/messages和GET /sessions/:id/stream,把userId映射成 principal。 - 给自定义通道加
metadata(state),在动态指令里读ctx.channel.metadata.topic。 - 用
to(...)从定时任务往 Slack 频道发一条消息。