# Events and triggers

Roma can wake your agent the moment something on your lists changes, instead of the agent finding out on its next scheduled run. You make a **trigger**: a change on your lists to listen for, narrowed by a filter, and where to send it. Each time it fires, your agent gets an **event**: one JSON object with the item after the change, what changed, and who did it. A **delivery** is one attempt to hand an event over; a webhook's failed attempts are retried.

Three ways a trigger delivers:

| Your agent runs | Transport | What it needs |
|---|---|---|
| On a server, in a sandbox with a public URL, as a Claude Code Routine | `webhook`: a signed POST | An https address |
| On a laptop, in a VM behind NAT, anywhere with no address | `poll`: a request your agent holds open | An API key |
| In an MCP client that supports the draft MCP Events extension | `mcpEvents` | The client subscribes by itself |

Your triggers live on the Automations page, beside your automations: New automation, then **On a change** as the answer to when it should run. The page of a trigger shows when it runs, what it does and every delivery with the data sent.

## The events

| Event | When |
|---|---|
| `item.created` | An item was created on one of your lists: a task, a note, a row |
| `item.updated` | An item's title, due date, reminder, a property value or its archived flag changed |
| `item.statusUpdated` | A task's status changed (todo, inProgress, completed) |
| `item.moved` | An item moved onto another list |
| `item.deleted`, `item.restored` | An item went into the trash, or came back |
| `list.created`, `list.updated`, `list.deleted`, `list.restored` | The same for a list |
| `message.posted` | You replied to an agent's post in an item's chat (Reply on its row). A message typed without Reply goes to the operator and never fires it; an agent's own post never fires it |
| `automation.completed` | An automation's run finished, with its summary |

## The filter

Every part is optional; an absent part matches everything.

- `listIds`: only items on these lists.
- `fields`: for `item.updated`, `item.statusUpdated` and `item.moved`, only when one of these changed: `title`, `status`, `dueAt`, `archived`, `listId`, or a property by its name.
- `equals`: only when the item, after the change, has these values: `{ "status": "inProgress" }`, `{ "Stage": "ready to go" }`. A property by its name, an option by its label.
- `agentName`: the agent this trigger wakes, by the name it posts under with `post_chat_message`. Your reply to one of its posts in a task chat then fires this trigger too, whatever else it watches, and the chat offers you Reply on its posts. An agent with no trigger can pass `readsReplies: true` when it posts, if it reads the chat on its next run; otherwise its post is a notification with no Reply.

An example. A trigger on `item.updated`, filter `{ "listIds": ["<properties list>"], "fields": ["Stage"], "equals": { "Stage": "ready to go" } }`, fires once when a property's Stage is set to ready to go, and stays quiet for every other edit on that list.

## Making one

On the Automations page (New automation, On a change); or with your API key; or your agent makes its own with the `manage_triggers` tool (`action: "create"`). Ten per account. Over the API:

```
curl -X POST https://api.roma.app/api/v1/triggers \
  -H "Authorization: Bearer roma_…" -H "Content-Type: application/json" \
  -d '{
    "name": "Claude Code on the Mac",
    "events": ["item.statusUpdated", "message.posted"],
    "filter": { "equals": { "status": "inProgress" } },
    "transport": "poll"
  }'
```

The answer is the trigger with its `id`. For a webhook, pass `"transport": "webhook"` and `"url"`; the answer then carries the `whsec_` signing secret, this once. `GET /api/v1/triggers` lists yours with each one's last delivery, `PATCH /api/v1/triggers/{id}` renames one, changes its events, filter, address or headers, pauses or resumes it, `DELETE` removes it. The [REST API](/developers/api) page has every field; over MCP the same four operations are one tool, `manage_triggers`, with `action` list, create, update or delete.

## A delivery

Whatever the transport, a delivery is the same object:

```json
{
  "eventId": "evt_…",
  "name": "item.statusUpdated",
  "timestamp": "2026-10-17T10:00:00.100Z",
  "text": ""Ship the release" moved from To do to In progress (Tobi)",
  "cursor": "…",
  "data": {
    "occurredAt": "2026-10-17T10:00:00.000Z",
    "item": { "id": "…", "title": "Ship the release", "listId": "…", "listName": "Releases", "taskStatus": "inProgress", "cells": { "Stage": "ready to go" }, "body": "…" },
    "changes": { "status": { "from": "todo", "to": "inProgress" } },
    "actor": { "kind": "user", "client": "web", "agent": null }
  }
}
```

`text` is the delivery in one sentence, the item quoted and the actor by name, for anything that shows a line or injects a message. `data.item` is the item as `get_item` returns it, so the agent can usually act without a second read. `data.changes` names each changed field with its earlier and new value. For `message.posted`, `data.message` carries the reply and `data.message.replyTo` the agent's post it answers. For `automation.completed`, `data.run` carries the run. The poll answers the same object without the outer envelope: `id`, `eventName`, `text`, `data`, `createdAt`.

## Poll: for an agent with no address

Telegram's bots do this with getUpdates; so does a self-hosted GitHub runner. Your agent calls one URL and Roma holds the request open, up to 25 seconds, answering the moment a delivery waits. Nothing on your machine listens; the deliveries queue on Roma's side until you fetch them, so a laptop that was asleep picks up where it left off.

```
GET https://api.roma.app/api/v1/triggers/{id}/deliveries?wait=25&after=<cursor>
Authorization: Bearer roma_…

→ { "deliveries": [ { "id", "eventName", "text", "data", "createdAt" } ], "cursor": "…", "waited": 13 }
```

- `wait`: how long Roma may hold the request, 0 to 25 seconds. With no deliveries in that time the answer is an empty list and the cursor you sent.
- `after`: the `cursor` of the last answer you acted on. Leave it out the first time. Sending it acknowledges everything up to it; those deliveries are never answered again.
- A delivery is not marked delivered by the answer that carried it, only by your next request's cursor. If your process died before acting on it, or the answer never reached you, the next poll answers it again. Advance the cursor only once you have acted.

The loop, in full, is this. One request at a time, the next one sent the moment the previous answered:

```sh
cursor=""
while true; do
  res=$(curl -s "https://api.roma.app/api/v1/triggers/$TRIGGER/deliveries?wait=25&after=$cursor" \
    -H "Authorization: Bearer $ROMA_API_KEY")
  echo "$res" | jq -c '.deliveries[]' | while read -r delivery; do
    echo "$delivery" | claude -p "Roma delivered an event. Read the delivery on stdin, do the work on that item with your Roma tools, and post once in its chat if something needs me." \
      --allowedTools "mcp__roma__*"
  done
  cursor=$(echo "$res" | jq -r '.cursor // empty')
done
```

Swap `claude -p` for `codex exec`, a Python script, or anything that reads JSON. The agent reaches Roma back through the MCP connection it already has. A 401, 403, 404 or 409 means stop: wrong key, closed gate, no such trigger, or a trigger that is paused or disabled.

An agent that cannot keep a process running, one that only gets a scheduled slot, does the same thing one request per slot: every minute, one request with `wait=25`, act on what came back, save the cursor. Deliveries wait between slots.

## Webhook: for anything with an address

Pass `"transport": "webhook"` and the https URL. The address must be public; it is not challenged, so a Routine's fire URL, an n8n webhook node or a webhook.site bin works as it is. A wrong address shows on Send a test or the first event as a failed delivery.

Deliveries follow Standard Webhooks: headers `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1,` plus base64 HMAC-SHA256 over `id.timestamp.body` with the `whsec_` secret). One event per request. A 2xx acknowledges; anything else is retried after 1 minute, 1 hour and 6 hours, then the trigger disables itself as failing, and Resume on its page brings it back once the address works again (a new address, saved with Edit, brings it back by itself). A 410 retires it at once. Reject timestamps older than five minutes and deduplicate on `webhook-id`.

Extra headers ride every POST when you pass `headers`; that is how a trigger fires a Claude Code Routine. At claude.ai/code/routines, give the routine an API trigger, copy its fire URL (it reads `https://api.anthropic.com/v1/claude_code/routines/trig_…/fire`) and its bearer token, and make the trigger with `"url"` the fire URL and `"headers": { "Authorization": "Bearer <token>" }`. Roma knows that endpoint's shape: the envelope travels inside the `text` field it takes, with the beta and version headers it requires, so the routine's prompt reads the delivery from its routine-fire-payload block. The routine starts within seconds of the event, and nothing of yours runs anywhere.

## MCP Events: for a client that subscribes itself

Roma's server implements the draft MCP Events extension, webhook delivery: `events/list` returns the catalogue above, `events/subscribe` registers the client's callback URL and secret, `events/unsubscribe` stops it. A subscription is a trigger of transport `mcpEvents` and shows on the Automations page like any other. ChatGPT is the one client that does this today, in a Work chat on chatgpt.com or in the desktop app with Cloud selected, on Plus and up, with Roma added on the web Plugins page; ask it to subscribe and the subscription shows there as a monitoring task.

## Testing

On a trigger's page, Send a test: a real delivery of a sample event (`item.updated` when the trigger listens for it, otherwise its first event) through the trigger's own transport. The page lists every delivery with the status code the address answered and the data sent; open one for Resend.
