--- title: Events and Triggers description: When a gem's workflow (SOP) starts running — how to write the three trigger types (manual, scheduled, platform event), their inputs, limits, and how to troubleshoot. section: dev order: 60 updated: 2026-11-05 translation_of: zh source_sha: b929393ac401 --- # Events and Triggers A trigger answers one question: **when does a flow (SOP) in a gem start running**. There are three ways: | Type | How to write `trigger` | When it runs | Where the input comes from | | --- | --- | --- | --- | | [Manual](#manual-trigger) | `{ "type": "manual" }` | When someone calls it as a tool | The caller's arguments | | [Scheduled](#scheduled-trigger) | `{ "type": "cron", "expr": "0 8 * * *" }` | At the time the cron expression specifies | The fixed `args` written in the trigger | | [Platform event](#event-trigger) | `{ "type": "event", "source": "email", "name": "onNewMessage" }` | When the platform publishes this event | The content of the event | **Gems cannot trigger each other directly.** The only events are the ones the platform publishes itself (see [Available events](#available-events)); a gem cannot publish events or subscribe to another gem's activity. ## Where to declare it The trigger is written on the SOP's declaration. Each SOP has exactly one. In a Gem Studio draft, it is the entry in `manifest.json` whose `kind` is `sop`: ```json { "name": "digest", "kind": "sop", "entry": "workflows/digest.js", "description": "Summarize new email every morning.", "input_schema": { "type": "object", "properties": { "limit": { "type": "number" } } }, "trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } } } ``` In the packaged manifest (`notochord.json`) it is an entry of `workflows[]`. The fields mean the same thing, except that `input_schema` is written `inputSchema`: ```json "workflows": [ { "name": "digest", "entry": "workflows/digest.js", "description": "Summarize new email every morning.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } }, "trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } } } ] ``` Omitting `trigger` is the same as `{ "type": "manual" }`. Whichever type is declared, the SOP is written the same way: a function whose first parameter is the input that this trigger brings: ```js export default async function (input, ctx) { // input: the arguments of a manual call / the args of a scheduled trigger / the content of an event const mail = await ctx.call('tool.list_new_mail', { limit: input.limit ?? 20 }) return { processed: mail.messages.length } } ``` ## Manual trigger Every SOP is registered as a tool named `gem.<gem id>.<SOP name>`, and its parameters are its `input_schema`. `manual` means "this is the only way to start it"; a SOP that declares a scheduled or event trigger can also be called manually. - **Who can call it**: the agent in chat. If this tool is not in the session's tool allowlist, the first call asks the user for permission first. - **What it returns**: the SOP's return value; when the run left files, it is `{ "output": <return value>, "folder": "runs/…" }`, where `folder` is its [run folder](/en/dev/permissions.md#run-folder) in the conversation's working directory. If the flow is suspended midway (waiting for user approval, or for a longer wait), the caller gets `{ "status": "suspended", "run_id": "…", "folder": "runs/…" }`; the flow then continues in the background and the result is still written into this folder. If it fails or is cancelled, the caller receives an error. - **Files and folders**: paths are plain strings, and tools read and write in this conversation's folder with the `workspace:` prefix; see [Permission Model](/en/dev/permissions.md#conversation-folder). - **Whose permissions it runs with**: the SOP's own — the set recorded at install time, plus whatever the user turned on in the gem's page. What permissions the calling session has is unrelated to what the SOP can do. - A SOP cannot call another SOP, and cannot call another gem's tools. ## Scheduled trigger ```json "trigger": { "type": "cron", "expr": "30 7 * * 1-5", "args": { "limit": 20 } } ``` `expr` is a standard 5-field cron expression: minute, hour, day of month, month, day of week. | Syntax | Meaning | Example | | --- | --- | --- | | `*` | Any value | `* * * * *` every minute | | number | That value | `0 8 * * *` every day at 8:00 | | `a-b` | Range | `0 9-17 * * *` every hour on the hour from 9 to 17 | | `a,b,c` | List | `0 0 1,15 * *` 0:00 on the 1st and 15th of each month | | `*/n`, `a-b/n` | Step | `*/15 * * * *` every 15 minutes | Things to note: - **It uses the computer's local time**, not UTC. If the user changes time zone, the trigger moment moves with it. - **The finest granularity is one minute.** The scheduler checks once a minute, so the actual start may be less than a minute after the expression says. - **Day of week is 0–6, with 0 as Sunday.** `7` is not supported, names like `MON` are not supported, and shorthands such as `@daily` are not supported. - **When both "day of month" and "day of week" have specific values, both must match** (`0 8 13 * 5` means 8:00 on a day that is both the 13th and a Friday). This differs from some cron implementations. - An expression the scheduler cannot parse, or one that never comes due (such as February 31), **fails at packaging and install time**. **Input**: when the time comes there is nobody present to provide arguments, so the input is the `args` hard-coded in the trigger (`{}` if omitted). Every required field in `input_schema` must be found in `args`, otherwise packaging and install fail. Such a SOP has no conversation: the working folder its tools read and write with `workspace:` is the one the user picked for this gem in the gem's page, and if none was picked there is none (see [Permission Model](/en/dev/permissions.md#scheduled-and-event-triggered-sops)). The same goes for event-triggered SOPs. **When the app is not open** it does not run. After the app is reopened: - Missed once only (the next time has not come yet) — the missed run is made up immediately; - Missed more than once — no make-up runs; it waits for the next scheduled time. So a "daily at 8:00" task, with the computer booted only at 10, runs once at 10; after the computer is off for three days and then turned on, it does not run three times in a row but waits for the next 8:00. ## Event trigger ```json "trigger": { "type": "event", "source": "email", "name": "onNewMessage" } ``` Events are published by the platform. Each SOP that subscribes to one starts its own run, and the content of the event is the SOP's `input`. ### Available events | `source` | `name` | When it is published | | --- | --- | --- | | `email` | `onNewMessage` | The user's mailbox receives a new email | This version has only this one. Writing an event that is not in the table — including a misspelled name, and forms that point at another gem such as `"source": "gem:<another gem>"` — **fails at packaging and install time**; it is not installed and then silently never triggered. What this version does **not** publish: - **Changes in knowledge** (`knowledge.document.changed`). To process content the user has newly added, use a [scheduled trigger](#scheduled-trigger) and read at run time: each item of `ctx.knowledge.documents.list(collection)` carries `updatedAt`, and you record how far you have processed. See the [Knowledge API](/en/dev/knowledge.md). - **A gem being uninstalled** (`gem.uninstalling`). Gems do not share private data; information meant for another gem goes into knowledge. ### email.onNewMessage The email accounts the user has added sync their inboxes every 5 minutes; for each new email synced, the event is published once, after the email has been stored. ```json { "accountId": "3f6c…", "accountEmail": "me@example.com", "id": "9d2e…", "messageId": "<CAF…@mail.example.com>", "fromAddr": "alice@example.com", "toAddrs": ["me@example.com"], "subject": "Friday's meeting", "date": 1760256000000 } ``` | Field | Description | | --- | --- | | `accountId` | The app's internal id of the account that received this email | | `accountEmail` | The address of the account that received this email (not taken from the email's recipients) | | `id` | This email's id in the app; to read the body, pass it together with `accountId` to `ctx.email.getMessage` | | `messageId` | The email's Message-ID; if absent, the UID on the server | | `fromAddr`, `toAddrs`, `subject` | Sender, recipient list, subject | | `date` | The email's date, as a millisecond timestamp | The event has **no body**, only the envelope. - **Only new emails in the inbox** (INBOX) trigger it. - **Up to 5 minutes pass between arrival and trigger** (the sync interval). An email that arrived while the app was not open triggers the next time the app is opened and syncs it. - **Historical emails from a newly added account do not trigger.** The first sync of an account backfills a stretch of history, and none of it is published. - **Only accounts the user allows.** The user chooses them in the gem's page under "Settings › Mailboxes it can read"; mail from an account that is not selected does not trigger this gem. **There are no accounts when the gem is installed**, so until the user chooses, this trigger does not happen. The user can also choose "All accounts" (accounts added later count too). After a change, the next email follows the new setting. - **To get the body, have a tool read it.** A SOP cannot read email itself: give a tool the `email` permission (gem-level `"email": true`), the SOP hands the event's `accountId` and `id` to that tool, and the tool calls `ctx.email.getMessage({ accountId, id })` to get the plain-text body and the attachment list. As elsewhere, it can read only the accounts the user allows in the gem's page. See [`ctx.email` in tools](/en/dev/tools.md#ctxemail). A complete example: for each new email, a tool first reads the body, and then hands it to the gem's own agent to process: The relevant part of the manifest (`agent.assistant` is an agent in the same gem; its declaration is omitted here): ```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" } } ] ``` In a Gem Studio draft, `permissions.email` is written as a top-level `"email": true` in `manifest.json`. `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 } } ``` ## After the trigger: a run All three types start the same thing — one run of the SOP. - **Each trigger is an independent run** with its own record. One email is one run, one scheduled time is one run; when several gems subscribe to the same event, each runs on its own. - **The input first goes through `input_schema`.** If it does not satisfy it, the run does not start: for a manual call the caller receives an error; for scheduled and event triggers only a log entry is left. When writing `input_schema` for an event-triggered SOP, list as required only the fields you really use. - **It has its own folder.** For a call from chat, the folder is in that conversation's working directory; for scheduled and event triggers, it is in the gem's own data folder. The tools and agents this run calls read and write here; see [Run folder](/en/dev/permissions.md#run-folder). - **Data is read and written through tools.** A SOP has no `ctx.data`: to read or write the gem's collections, logs, documents, and blobs, call the gem's own tools (`ctx.call('tool.save_note', …)`). `storage.kv.get`, `storage.kv.set`, `storage.sql.query`, and `storage.sql.insert` can be called directly without declaring them. See [How SOPs access data](/en/dev/storage.md#how-sops-access-data). - **What it can do is fixed at install time.** The target of every `ctx.call('…')` in the SOP is scanned and recorded at install time (and listed in the gem's page); at run time, a call to a target not in this list is refused. - **It has limits.** By default a run is active for at most 10 minutes and makes at most 500 `ctx.call`s; both can be adjusted with `limits`: `"limits": { "maxDurationMs": 600000, "maxCalls": 500 }`. Time spent suspended is not counted. - **It can suspend.** `ctx.call('wait.approval', …)` stops and waits for the user to approve, and `ctx.call('wait.sleep', …)` can wait for a period of time; suspended runs continue after the app restarts. - **There is a limit on SOPs running at the same time** (currently 20); the excess queue. - **Where to look**: the "Run history" on the gem's page lists each run of this gem and each of its steps; the "Run history" on the app's home page lists the runs of all gems. Runs waiting for approval are approved or rejected there, running ones can be cancelled, and failed or cancelled ones can be retried — on retry, steps that already succeeded are not rerun, and the flow continues from the failed step. ## Delivery - **At least once, not exactly once.** Duplicates happen in two cases: - An event is written to disk first and then dispatched. If the app crashes during dispatch, the event is dispatched again after restart, and a subscribing SOP may **start a second run** for the same event. To guard against this, deduplicate yourself with the identifier the event carries (`messageId` for email). - If a run crashes midway, on recovery **the step that was executing is rerun** (steps already completed are not rerun; their previous results are used directly). Tools with external side effects should deduplicate with `ctx.idempotencyKey` — for the same step of the same run, this value does not change on rerun. - **No ordering guarantee.** For two emails that arrive almost at the same time, there is no guarantee which of the two runs goes first. - **No chaining.** Gems cannot trigger each other, and a gem's run does not cause new events. ## What the user sees on install and upgrade - The gem's page lists the SOPs one by one: how each is triggered, and which tools, agents, and platform services it will call. - When it subscribes to the email event (or a tool requests the `email` permission), the card gains an account selector. - On upgrade, if the new version adds a scheduled or event trigger, the card notes that this update needs more permissions than the installed version. - Triggers take effect as soon as install (or upgrade) completes; the app does not need to be restarted. ## Troubleshooting **Install fails with `E_WORKFLOW_INVALID`** | Wording in the error | Cause | | --- | --- | | `is not an event the platform publishes` | The event's `source` / `name` is not in [Available events](#available-events), most likely a typo | | `a gem cannot be triggered by another gem` | The trigger points at another gem | | `has a cron trigger that cannot be scheduled` | The cron expression uses unsupported syntax, or never comes due | **`E_INPUT_INVALID`**: the scheduled trigger's `args` do not satisfy `input_schema`. **Installed but not triggering** 1. Check "Run history" on the home page for this run. If it is there and failed, open it to see which step. 2. Email event: whether this account is one of the accounts the user currently allows; whether the email is in the inbox; whether the account was just added and is still backfilling history. 3. Scheduled: it is computed in local time; whether the app was open at the time. 4. Whether `input_schema` lists as required a field that the event does not have.
Notochord
简体中文English