---
title: 事件与触发
description: Gem 的流程(SOP)在什么时候开始运行:手动调用、定时、平台事件三种触发方式的写法、输入、限制和排查方法。
section: dev
order: 60
updated: 2026-11-05
---
# 事件与触发
触发器回答一个问题:gem 里的一段流程(SOP)**什么时候开始运行**。一共三种方式:
| 方式 | `trigger` 的写法 | 什么时候运行 | 输入从哪来 |
| --- | --- | --- | --- |
| [手动](#手动触发) | `{ "type": "manual" }` | 有人把它当工具调用时 | 调用方传的参数 |
| [定时](#定时触发) | `{ "type": "cron", "expr": "0 8 * * *" }` | 到了 cron 表达式指定的时间 | 写在触发器里的固定参数 `args` |
| [平台事件](#事件触发) | `{ "type": "event", "source": "email", "name": "onNewMessage" }` | 平台发布了这个事件 | 事件的内容 |
**gem 之间不能直接互相触发。** 事件只有平台自己发布的那几种(见[可用的事件](#可用的事件));gem 不能发布事件,也不能订阅另一个 gem 的活动。
## 在哪里声明
触发器写在 SOP 的声明上,每个 SOP 有且只有一个。
在 Gem Studio 的草稿里,是 `manifest.json` 中 `kind` 为 `sop` 的那一项:
```json
{
"name": "digest",
"kind": "sop",
"entry": "workflows/digest.js",
"description": "每天早上汇总新邮件。",
"input_schema": { "type": "object", "properties": { "limit": { "type": "number" } } },
"trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } }
}
```
打包后的清单(`notochord.json`)里是 `workflows[]` 的一项,字段含义相同,只是 `input_schema` 写作 `inputSchema`:
```json
"workflows": [
{
"name": "digest",
"entry": "workflows/digest.js",
"description": "每天早上汇总新邮件。",
"inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } },
"trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } }
}
]
```
不写 `trigger` 等于 `{ "type": "manual" }`。
不管声明的是哪一种,SOP 的写法都一样——一个函数,第一个参数就是这次触发带来的输入:
```js
export default async function (input, ctx) {
// input:手动调用的参数 / 定时触发的 args / 事件的内容
const mail = await ctx.call('tool.list_new_mail', { limit: input.limit ?? 20 })
return { processed: mail.messages.length }
}
```
## 手动触发
每个 SOP 都会被注册成一个工具,名字是 `gem..`,参数就是它的 `input_schema`。`manual` 的意思是「只有这一种启动方式」;声明了定时或事件触发的 SOP 同样可以被手动调用。
- **谁能调用**:聊天里的 agent。这个工具不在会话的工具白名单里时,第一次调用会先问用户是否允许。
- **返回什么**:SOP 的返回值;运行留下了文件时是 `{ "output": <返回值>, "folder": "runs/…" }`,`folder` 是它在对话工作目录里的[运行文件夹](/zh/dev/permissions.md#运行文件夹)。如果流程中途挂起了(等待用户审批,或一次较长的等待),调用方拿到的是 `{ "status": "suspended", "run_id": "…", "folder": "runs/…" }`,流程之后在后台继续,结果仍然写进这个文件夹。失败或被取消时调用方收到错误。
- **文件和文件夹**:路径是普通字符串,工具用 `workspace:` 前缀在这次对话的文件夹里读写,见[权限模型](/zh/dev/permissions.md#对话文件夹)。
- **用谁的权限运行**:SOP 自己的——安装时记录下来的那一份,加上用户在 gem 页面上打开的。调用它的会话有什么权限,和它能做什么无关。
- 一个 SOP 不能调用另一个 SOP,也不能调用别的 gem 的工具。
## 定时触发
```json
"trigger": { "type": "cron", "expr": "30 7 * * 1-5", "args": { "limit": 20 } }
```
`expr` 是标准的 5 段 cron 表达式,依次是:分、时、日、月、星期。
| 写法 | 含义 | 例子 |
| --- | --- | --- |
| `*` | 任意值 | `* * * * *` 每分钟 |
| 数字 | 这个值 | `0 8 * * *` 每天 8:00 |
| `a-b` | 范围 | `0 9-17 * * *` 9 点到 17 点的每个整点 |
| `a,b,c` | 列表 | `0 0 1,15 * *` 每月 1 号和 15 号的 0:00 |
| `*/n`、`a-b/n` | 步长 | `*/15 * * * *` 每 15 分钟 |
要注意的地方:
- **按电脑的本地时间算**,不是 UTC。用户换了时区,触发时刻跟着变。
- **最小粒度是一分钟**。调度器每分钟检查一次,所以实际开始时间可能比表达式晚不到一分钟。
- **星期用 0–6,0 是星期日**。不支持 `7`,不支持 `MON` 这样的名字,也不支持 `@daily` 这类简写。
- **「日」和「星期」同时写了具体值时,两个都要满足**(`0 8 13 * 5` 是「既是 13 号又是星期五」的 8:00)。这一点和有些 cron 实现不同。
- 调度器读不懂的表达式,或者永远不会到点的表达式(比如 2 月 31 日),**在打包和安装时就会报错**。
**输入**:到点时没有人在场提供参数,所以输入是触发器里写死的 `args`(不写就是 `{}`)。`input_schema` 里的必填项必须都能在 `args` 里找到,否则打包和安装时报错。这种 SOP 没有对话:它的工具用 `workspace:` 读写的是用户在 gem 页面上给这个 gem 选的工作文件夹,没选就没有(见[权限模型](/zh/dev/permissions.md#定时和事件触发的-sop))。事件触发的 SOP 也一样。
**应用没开着的时候**不会运行。重新打开后:
- 只错过了一次(下一次还没到点)——立刻补跑这一次;
- 已经错过了不止一次——不补跑,等下一次到点。
所以「每天 8:00」的任务,电脑 10 点才开机,会在 10 点跑一次;关机三天再开,不会连跑三次,而是等到下一个 8:00。
## 事件触发
```json
"trigger": { "type": "event", "source": "email", "name": "onNewMessage" }
```
事件由平台发布,订阅了它的 SOP 各自开始一次运行,事件的内容就是 SOP 的 `input`。
### 可用的事件
| `source` | `name` | 什么时候发布 |
| --- | --- | --- |
| `email` | `onNewMessage` | 用户的邮箱收到一封新邮件 |
这一版只有这一个。写了表里没有的事件——包括拼错的名字,和 `"source": "gem:<别的 gem>"` 这种指向另一个 gem 的写法——**打包和安装时会报错**,不会装上以后悄悄不触发。
这一版**不发布**的:
- **知识库的变化**(`knowledge.document.changed`)。要处理用户新加的内容,用[定时触发](#定时触发),在运行时读:`ctx.knowledge.documents.list(collection)` 每项带 `updatedAt`,自己记下处理到哪里。见[知识库 API](/zh/dev/knowledge.md)。
- **gem 被卸载**(`gem.uninstalling`)。gem 之间不共享私有数据;要给别的 gem 用的信息,写进知识库。
### email.onNewMessage
用户添加的邮箱账号每 5 分钟同步一次收件箱;每同步到一封新邮件,在它存好之后发布一次。
```json
{
"accountId": "3f6c…",
"accountEmail": "me@example.com",
"id": "9d2e…",
"messageId": "",
"fromAddr": "alice@example.com",
"toAddrs": ["me@example.com"],
"subject": "周五的会议",
"date": 1760256000000
}
```
| 字段 | 说明 |
| --- | --- |
| `accountId` | 收到这封信的账号在应用内部的 id |
| `accountEmail` | 收到这封信的账号自己的地址(不是从信的收件人里取的) |
| `id` | 这封信在应用里的 id,读正文时和 `accountId` 一起交给 `ctx.email.getMessage` |
| `messageId` | 邮件的 Message-ID;没有时是服务器上的 UID |
| `fromAddr`、`toAddrs`、`subject` | 发件人、收件人列表、主题 |
| `date` | 邮件的日期,毫秒时间戳 |
事件里**没有正文**,只有信封。
- **只有收件箱**(INBOX)里的新邮件会触发。
- **从收到到触发最多隔 5 分钟**(同步间隔)。应用没开着时到的邮件,下次打开同步到时再触发。
- **新添加账号时的历史邮件不触发**。账号第一次同步会回填一段历史,这期间一封也不发布。
- **只收到用户允许的账号**。用户在 gem 页面的「设置 › 它能读的邮箱」里选;没被选中的账号来的邮件不会触发这个 gem。**装好时一个账号都没有**,所以用户选之前这个触发不会发生。用户也可以选「所有账号」(以后新添加的也算)。改了以后,下一封邮件就按新的设置来。
- **要正文,由工具去读**。SOP 本身不能读邮件:给一个工具申请 `email` 权限(gem 级 `"email": true`),SOP 把事件里的 `accountId` 和 `id` 交给这个工具,工具调用 `ctx.email.getMessage({ accountId, id })` 拿到纯文本正文和附件列表。和别处一样,只能读用户在 gem 页面上允许的账号。见[工具的 `ctx.email`](/zh/dev/tools.md#ctxemail)。
一个完整的例子,每来一封新邮件,先由工具读出正文,再交给 gem 自己的 agent 处理:
清单里的相关部分(`agent.assistant` 是同一个 gem 里的一个 agent,这里省略了它的声明):
```json
"permissions": { "email": { "read": true } },
"tools": [
{
"name": "read_email",
"entry": "tools/read_email.js",
"description": "Read one email in full from the user's mailbox. Returns { subject, fromAddr, body }.",
"inputSchema": {
"type": "object",
"required": ["accountId", "id"],
"properties": {
"accountId": { "type": "string" },
"id": { "type": "string", "description": "The message id the email.onNewMessage event gives." }
}
},
"permissions": { "email": { "read": true } },
"sideEffect": "read"
}
],
"workflows": [
{
"name": "on_new_message",
"entry": "workflows/on_new_message.js",
"description": "Runs the assistant agent against every newly received email.",
"inputSchema": {
"type": "object",
"required": ["accountId", "id", "messageId"],
"properties": {
"accountId": { "type": "string" },
"id": { "type": "string" },
"messageId": { "type": "string" },
"subject": { "type": "string" },
"fromAddr": { "type": "string" }
}
},
"trigger": { "type": "event", "source": "email", "name": "onNewMessage" }
}
]
```
在 Gem Studio 的草稿里,`permissions.email` 写成 `manifest.json` 顶层的 `"email": true`。
`tools/read_email.js`:
```js
export default async function (input, ctx) {
const message = await ctx.email.getMessage({ accountId: input.accountId, id: input.id })
return { subject: message.subject ?? '', fromAddr: message.fromAddr ?? '', body: message.body }
}
```
`workflows/on_new_message.js`:
```js
export default async function (input, ctx) {
const email = await ctx.call('tool.read_email', { accountId: input.accountId, id: input.id })
await ctx.call('agent.assistant', {
messageId: input.messageId,
subject: email.subject,
fromAddr: email.fromAddr,
body: email.body
})
return { ok: true, messageId: input.messageId }
}
```
## 触发之后:一次运行
三种方式启动的都是同一种东西——SOP 的一次运行。
- **每次触发是独立的一次运行**,有自己的记录。一封邮件一次、一个定时点一次;同一个事件有多个 gem 订阅时,各跑各的。
- **输入先过 `input_schema`**。不满足时这次运行不会开始:手动调用的调用方收到错误;定时和事件触发则只在日志里留一条记录。给事件触发的 SOP 写 `input_schema` 时,只把真正要用的字段列为必填。
- **有自己的文件夹**。从聊天里调用的,放在那个对话的工作目录里;定时、事件触发的,放在 gem 自己的数据文件夹里。这次运行调用的工具和 agent 都在这里读写,见[运行文件夹](/zh/dev/permissions.md#运行文件夹)。
- **数据通过工具读写**。SOP 没有 `ctx.data`:要读写 gem 的集合、Log、文档、Blob,调用 gem 自己的工具(`ctx.call('tool.save_note', …)`)。`storage.kv.get`、`storage.kv.set`、`storage.sql.query`、`storage.sql.insert` 可以直接调用,不用声明。见 [SOP 怎么访问数据](/zh/dev/storage.md#sop-怎么访问数据)。
- **能做什么在安装时就定了**。SOP 里每个 `ctx.call('…')` 的目标在安装时被扫描出来、记录下来(列在 gem 的页面上);运行时调用不在这份清单里的目标会被拒绝。
- **有上限**。默认一次运行最多活跃 10 分钟、最多 500 次 `ctx.call`,可以用 `limits` 调整:`"limits": { "maxDurationMs": 600000, "maxCalls": 500 }`。挂起等待的时间不算在内。
- **可以挂起**。`ctx.call('wait.approval', …)` 会停下来等用户批准,`ctx.call('wait.sleep', …)` 可以等一段时间;应用重启后挂起的运行会接着跑。
- **同时运行的 SOP 有上限**(目前是 20 个),超出的排队。
- **在哪看**:gem 页面的「运行记录」列出这个 gem 每次运行和它的每一步;应用首页的「运行记录」列出所有 gem 的运行。等待审批的在那里批准或拒绝,运行中的可以取消,失败或取消的可以重试——重试时已经成功的步骤不重跑,从失败的那一步接着做。
## 送达
- **至少一次,不是恰好一次。** 有两种情况会重复:
- 事件发布后先落盘再分发。应用在分发途中崩溃,重启后这个事件会被重新分发,订阅它的 SOP 可能为同一个事件**开始第二次运行**。要防这种重复,用事件里自带的标识自己去重(邮件用 `messageId`)。
- 一次运行中途崩溃,恢复时**正在执行的那一步会重跑**(已经完成的步骤不重跑,直接取用上次的结果)。有外部副作用的工具应该用 `ctx.idempotencyKey` 去重——同一次运行的同一步,重跑时这个值不变。
- **不保证顺序。** 两封几乎同时到的邮件,对应的两次运行谁先谁后没有保证。
- **不会连锁。** gem 之间不能互相触发,gem 的运行也不会引出新的事件。
## 安装和升级时用户看到什么
- gem 的页面会逐个列出 SOP:它怎么被触发,以及会调用哪些工具、agent 和平台服务。
- 订阅了邮件事件(或有工具申请了 `email` 权限)时,卡片上多一个账号选择框。
- 升级时如果新版本多了定时或事件触发器,卡片会提示这次更新要的权限比已安装的版本多。
- 触发器在安装(或升级)完成时就生效,不需要重启应用。
## 排查
**装不上,报 `E_WORKFLOW_INVALID`**
| 报错里的话 | 原因 |
| --- | --- |
| `is not an event the platform publishes` | 事件的 `source` / `name` 不在[可用的事件](#可用的事件)里,多半是拼写 |
| `a gem cannot be triggered by another gem` | 触发器指向了另一个 gem |
| `has a cron trigger that cannot be scheduled` | cron 表达式写法不支持,或永远不会到点 |
**报 `E_INPUT_INVALID`**:定时触发器的 `args` 不满足 `input_schema`。
**装上了但没有触发**
1. 在首页的「运行记录」里看有没有这次运行。有而且失败了,点开看是哪一步。
2. 邮件事件:这个账号是不是用户现在允许的账号之一;邮件是不是在收件箱里;是不是刚添加账号、还在回填历史。
3. 定时:按本地时间算;应用当时是否开着。
4. `input_schema` 是否把事件里没有的字段列成了必填。