Event triggers
An event trigger runs a program each time a new message lands in a chat. The owning device keeps one live subscription per watched chat next to its ticker, and every fire is a normal program run with a summary, a trace and the circuit breaker. The same mechanism carries the agent's own chat responder, so "which device answers" is a record you can read, pause and repin.
The source is chat_messages only — not a general subscription over arbitrary datasets — and it is live arrivals only: messages that landed while the owner was down are not replayed. $ANCHOR and $TRIGGERS come from Scheduling.
Spec
{
"name": "triage",
"kind": "event",
"spec": {"dataset": "chat_messages", "objectId": "<chat object id>", "spaceId": "<space id>"},
"program": "triage@v1",
"args": {"label": "inbox"},
"owner": "",
"enabled": true
}
| Spec field | Meaning |
|---|---|
dataset |
the event source; chat_messages is the one delivered source |
objectId |
the chat object to watch |
spaceId |
the space the chat lives in; absent or empty means the agent space |
filter |
reserved, ignored |
Always set spaceId for a chat outside the agent space. A subscription on an object the space does not hold answers with an empty feed rather than an error, so a chat looked up in the wrong space is a watch that connects and never fires.
| Marker | Cause |
|---|---|
invalid_spec |
dataset or objectId missing or empty |
unsupported_source |
a dataset other than chat_messages |
Delivery
- One watch per chat. The owning device subscribes once per distinct
(spaceId, objectId)across its enabled event triggers, and stops the subscription when no trigger needs it — disabled, repinned away, deleted or tripped. - New messages only. An added message fires; a reaction toggle or a text edit on an existing message never does.
- Never its own output. A message whose
agent.nameis this agent's name, or an agent message with no name, does not fire. A message from another agent identity (atrigger:<job>nudge, a peer agent) does. - Live only, mirroring cron: the subscription snapshot on connect only marks what already exists as seen, so a message that arrived while the owner was down or disconnected never fires later. An event that lands while the agent is still waiting for its overlays to sync is dropped, not queued.
The program receives the record's args plus an event object:
{"label": "inbox",
"event": {"space": "<space id>", "objectId": "<chat id>", "messageId": "<message id>",
"text": "can someone look at the build?", "agent": {…}, "attachments": {…}}}
agent and attachments are present only when the message carries them.
"""Route incoming requests: label the message and post a short ack."""
__any_tool__ = False
def main(args):
ev = args["event"]
if ev.get("agent"):
return {"ok": True, "skipped": "agent message"}
c = use("agent:any@v1")
label = classify(ev["text"])
c.chat_send(ev["space"], ev["objectId"],
{"text": f"filed under {label}", "agent": {"name": "triage", "done": True}})
return {"ok": True, "label": label}
Each fire is a normal run: an agent_runs summary with triggerId, the lastRunAt / lastStatus stamp, and the circuit breaker. A hot chat wearing out the breaker is the breaker doing its job. The example posts under triage, not the serving agent's name, so its own ack fires every trigger on that chat — this one included; the agent check is what keeps it from looping.
The chat responder is a trigger
Serve boot seeds one reserved record, chat-watch ("Chat responder"), on the trigger anchor:
{"name": "Chat responder", "kind": "event",
"spec": {"dataset": "chat_messages", "objectId": "<general chat>"},
"program": "internal:chat-watch", "enabled": true, "owner": ""}
programis informational. The runtime recognizes the reserved id and routes messages through its conversation watcher — dedup, injecting into a live conversation, deferring while overlays sync, and the reconnect snapshot backlog — instead of a program run. The backlog is the one exception to live-only: messages that arrived while nobody was answering get answered late. A message that lands during a live conversation is injected into it rather than starting a run; each conversation run publishes itsagent_runssummary under the responder's id, and the record'slastRunAtmarks the latest conversation start.- A device watches the chat only while it owns the enabled
chat-watchrecord. The election-active device claims it and re-stamps it on a takeover; a device that stands down clears the owner on the record. Repinning it moves where the agent answers. enabled: falsepauses answering on every device — legal, visible and reversible. Pinned to a device whose agent is not running, nobody answers, by design.- Boot seeds it only when no
chat-watchrecord exists. Because a deleted id stays tombstoned, a reseed after a delete uses a generation id (chat-watch-g2, …), recognized as the same reserved record.
Why it matters. Every background behavior of the agent is one mechanism — a record with an owner — so a remote runner (an always-on box, a VM) inherits all of it by holding pins, and "which device is the agent right now" is queryable state instead of an invisible verdict.
Not delivered
Non-chat dataset sources, the filter field, and liveness-based failover for pinned triggers: pins are the substrate for remote runners, and consensus without a coordinator is not on offer. Where you need to react to other changes, use a short-interval cron that reads its own cursor from the space. Ownership and claiming are on Device pins.