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 runsTransportWhat it needs
On a server, in a sandbox with a public URL, as a Claude Code Routinewebhook: a signed POSTAn https address
On a laptop, in a VM behind NAT, anywhere with no addresspoll: a request your agent holds openAn API key
In an MCP client that supports the draft MCP Events extensionmcpEventsThe 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

EventWhen
item.createdAn item was created on one of your lists: a task, a note, a row
item.updatedAn item's title, due date, reminder, a property value or its archived flag changed
item.statusUpdatedA task's status changed (todo, inProgress, completed)
item.movedAn item moved onto another list
item.deleted, item.restoredAn item went into the trash, or came back
list.created, list.updated, list.deleted, list.restoredThe same for a list
message.postedYou 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.completedAn 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:

text
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 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.

text
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.