Skip to content

Triggers

Starting a turn when nobody is typing.

Schedules, webhooks and email are built, as three kinds of one model (One model for every trigger): one pipeline, one ceiling, one list of an agent's triggers. Still to come are one form for making every kind, email by IMAP, and attachments on mail.

Why this matters more than it looks

A turn begins because somebody sent a message. That makes an agent a thing you visit, and a workspace is not sitting in the UI most of the time -- their day is spent in the systems the agent is supposed to be helping with.

Everything else a workspace might want an agent to react to has no way in. A booking arrives and the welcome note should be drafted. It is Monday and last week's figures should be summarized. A customer replies to a thread and somebody should decide whether it needs a person. None of that is a message anyone will type.

One mechanism, three front doors

All three reduce to the same two steps: put a message in a session, enqueue a turn against it. That machinery exists and is unchanged -- ChatTurnPayload already says user_id is "absent on turns nobody sent -- a schedule, a webhook", written before anything could produce one.

What differs is what arrives and who it is acting for. So the shared design is below, and each front door is only the part that is its own.

Who a triggered turn is acting as

Two labels, not one, because two different questions are being asked.

The owner is the person who set the trigger up. Durable, recorded on the trigger rather than the turn, and the answer to "who is accountable for this happening". A schedule somebody created at 3am on a Sunday is still theirs at 3am on a Sunday.

The sender is who is waiting for the reply, and for a triggered turn there is nobody: user_id stays null. That is what the usage ledger already expects, and it is what keeps a triggered turn from clearing a stopped session's latch -- worker::inhibited requires "a prompt carrying a real user_id", precisely so an agent cannot restart itself.

Conflating them would break one or the other. Putting the owner in user_id makes a schedule look interactive, lets it lift a latch nobody lifted, and attributes to a person a turn they were asleep for. Leaving both null loses the audit trail, and a platform that cannot say who set a thing running is one nobody should give to tenants.

A triggered turn runs with the agent's own scopes, which are workspace-level and not the owner's. That is deliberate: an agent should not gain reach because an admin happened to schedule it.

Which session

A new session per firing, for schedules. A weekly summary is not a conversation that has been going for a year; it is a fresh piece of work that happens weekly. One long session would carry a year of history into every prompt and pay for it on every round.

A shared session is the wrong fix for "the agent should remember last week". What that wants is something durable and narrow, and the shape of it is not settled -- an agent writing notes for its future self is a judgment about what mattered, which is nearer to carry-over than to user-declared memory, and compaction.md is explicit that those must not share a store. It is also, as much as anything, an evaluation problem: knowing what was worth keeping means knowing what went wrong without it. Left out of this design on purpose.

The same for every other trigger. A hook that fires per booking was once going to want one session per booking, so a later event about it landed in the conversation that discussed it -- a mapping from the sender's id to a session. Decided against: a trigger is work done on arrival, each firing its own, and keeping a thread together is what a channel does (Triggers are not channels). What an agent needs from earlier firings it reads, through history access, rather than carrying in every prompt.

What runs away, and what notices

A trigger is work nobody is watching, which is exactly the condition under which a loop runs for a long time before anyone finds out.

Three shapes, and they fail differently:

  • A schedule that fires faster than its turns finish. The session's serial key holds turns in order, so they queue rather than overlap -- but the queue grows without bound, and with a new session per firing there is no serial key joining them at all.
  • A hook fired a thousand times, by a misconfigured sender or a malicious one.
  • A cycle: an agent's action causes an event that triggers the agent.

There is no per-workspace fairness in the queue, so one workspace's runaway is everyone's. That is stated in design-notes.md as an accepted risk for human traffic, where a burst is bounded by how fast people type. A trigger is not bounded by anything.

So each trigger carries a ceiling -- a maximum concurrent and a maximum per hour -- enforced when the turn is enqueued rather than when it runs, because refusing early is what keeps a runaway out of the queue instead of merely out of the runtime.

Repeated failure is the signal worth surfacing. A schedule whose turns fail every time is a broken integration nobody will notice, because nobody was waiting. It belongs on the dashboard and in notifications, neither of which exists yet -- see What this needs that does not exist below.

Who reads the reply

A triggered turn produces a reply nobody asked for. The transcript holds it, which is enough for somebody who goes looking and useless for somebody who does not.

That is the gap that decides whether triggers are useful. An agent that drafts a welcome note every morning into a session nobody opens has done nothing.

The answer is notifications, which do not exist. Until they do, a triggered turn's output is reachable two ways that do: the session is listed like any other, and an agent that writes its result to workspace/ scope leaves something a person will find where they already look. Neither is a substitute.

The fixture this needs

Webhooks are the first thing here where a test against the mounted app is not enough. Signature verification, refusals and body limits are all testable in-process, and should be -- but the thing webhooks exist for is a round trip: an event arrives, an agent works, and something outside the platform is changed as a result. None of that is exercised by a request built in a test.

So a fixture service, beside outturn-mockllm and for the same stated reason: a test against an in-process shortcut measures the shortcut, and the parts most likely to break are the ones a shortcut skips.

A small booking system is the shape to build, because it is the worked example these documents already use. It emits a webhook when a booking is made, exposes a REST API for availability and reservations, and can be asked what it holds so a test can assert the agent's work landed. Deployed at zero replicas like the mock provider, scaled up when something needs it.

Three things earn their keep at once, which is why this is worth building rather than mocking:

  • Triggers, end to end: it POSTs a signed delivery and the turn that follows is a real turn.
  • Integrations (integrations.md): the agent reaches back through fetch_url, so the egress rule, the credential and the gateway are all in the path rather than assumed.
  • The OpenAPI wizard (openapi-wizard.md) and evaluation: it serves its own specification, so the wizard has a real document to read and the skill that results has a real service to be judged against.

It must be obviously fake from the outside, the way the mock provider's x-outturn-mock header makes a pretend transcript identifiable later by somebody who does not know it exists.

What this needs that does not exist

Recorded here because triggers are what make the absence matter, not because they are triggers' to build.

Notifications. Both the runaway alarm and the reply nobody is waiting for land here. Undesigned: what is notified, to whom, through what -- in-app, email, webhook out -- and how a workspace says what it wants to hear about. A dashboard panel is the smallest version and probably the first.

A dashboard for agent health. Triggered turns failing repeatedly is the first thing that needs saying out loud, and /v1/usage already carries the per- turn rows a panel would read.

Schedules

The first one, and the cheapest, because the queue was built for it.

jobs.run_after schedules work into the future, and the claim reads priority before it, so a backlog of scheduled turns cannot put itself in front of somebody waiting. Both already exist and are already tested.

A schedule is a row: workspace, agent, owner, a cron-like expression, a timezone, a prompt, whether it is enabled. Firing it means creating a session and a message, enqueuing a turn at background priority, and writing the next occurrence.

The timezone belongs to the schedule. "Every weekday at 9" means nine where the person who set it is, and a turn's clock already works this way -- current-time takes the zone from the turn rather than the machine. A schedule in UTC would fire an hour wrong for half the year.

The next occurrence is computed after the firing, not before. A schedule that computed a whole series ahead would drift when it was edited and would need a sweep to correct it; one that writes its successor as it fires has no series to correct. It also means a schedule that is disabled stops by simply not writing another.

A missed firing does not stack. A deployment that was down for a day should not wake to twenty-four hourly turns queued. When the next occurrence is computed, anything already in the past is skipped to the next one that is not, and the skip is recorded so somebody can see it happened.

The prompt

A schedule carries the text the turn begins with, which is the whole of what distinguishes "summarize last week's bookings" from "check for unpaid invoices". It is stored as an ordinary message, so the transcript reads the same as any other conversation and the agent needs no notion of having been triggered.

What it must not do is pretend a person said it. The message is stored with no user_id, and the UI draws it as what it is: the schedule's own words, not somebody's.

The UI

A list of schedules per agent, and an editor. A cron expression is the storage format and not the interface -- it is precise and nobody reads it correctly -- so the editor should offer the ordinary shapes (every day, every weekday, weekly on a day, monthly on a date) and a raw expression for what those do not cover, with the next few firings shown as plain dates in the schedule's own zone. Showing when it will actually run is what catches a wrong expression before it is saved rather than after.

The list lives on the agent's settings page, beside its webhooks, because a schedule means nothing without the agent it starts. The dashboard gathers them again under "Running on their own" (see usage.md) so that one failing is noticed without opening the agent it belongs to.

Webhooks

An inbound endpoint per trigger, at an unguessable path, accepting a POST from outside the platform. That is a different security posture from anything here today -- every existing endpoint is authenticated, and this one is reachable by whoever has the URL.

This is core rather than an add-on, and the reasoning is worth recording because it looked like the other way round. A deployment with users already exposes the API, so one more route is not a new posture. And an agent that cannot be reached by the systems it is meant to help with can only be polled or waited on by a person -- which is not the product. What an operator eventually pays for around webhooks is the operational surround: delivery history, replay of failures, alerting on a trigger that has stopped receiving. The mechanism itself has to work in the box or the box does not work.

Authentication

A trigger declares one scheme and the platform enforces exactly that one. Not a list of things it will accept: a request that fails the declared check is refused, never retried against a weaker one.

hmac is the scheme to prefer. HMAC-SHA256 over the raw body with a per-trigger secret; the sender sends the digest and a timestamp, the platform recomputes over the bytes as received, compares in constant time, and refuses a timestamp outside a few minutes so a captured request cannot be replayed indefinitely. Over the raw bytes rather than a parsed body, because two JSON documents that mean the same thing have different bytes, and a signature over a reserialized body verifies something the sender never sent.

shared_secret exists because the alternative is refusing real senders. Some of them do not sign at all -- a common recommendation among those is a bearer credential in a header plus IP allowlisting -- and inbound email providers are well represented in that group. A platform that accepts only signatures cannot receive from them, so the choice is not between strong and weak but between working and not. It carries a per-trigger credential in a header, compared in constant time.

This is deliberately not "support both and let people pick". The scheme is a property of the sender rather than a preference, and the difference matters:

  • The credential travels on every request rather than a proof of it, so any proxy or log that records headers holds it. Anything that reads webhook_triggers must therefore be as careful with it as with an egress credential.
  • It does not bind the body, so a request that was captured can be replayed with a different payload.
  • It gets no replay record, because there is nothing time-bound to key one on and nothing a record would defend against. See "Replay is refused, under hmac" below.

So a trigger using it should say so where somebody will see it, and the operator's mitigations are the sender's own: restrict by source address where the sender publishes its ranges, and rotate the secret on a schedule. Neither is the platform's to enforce, and both are worth telling somebody about at the moment they choose the scheme.

What neither scheme permits is an unauthenticated request. An unguessable path is not authentication and must not be treated as any: a URL leaks into logs, browser history, screenshots and support tickets, and a secret that travels in the path is a secret sent to everything in between.

What the agent is asked

The trigger owns a prompt template and the body fills it. Not the raw body as the prompt: that makes the entire instruction attacker-controlled text, with nothing saying what it is or what to do with it.

A template puts the workspace's own framing around untrusted content -- "A booking notification arrived. Summarize it and check availability: {{body}}" -- so the model reads the payload as data inside an instruction rather than as the instruction. That is not a defense against prompt injection and must not be described as one. It is the difference between a model that has been told what it is looking at and one that has not.

The real bound is elsewhere, and it is the same one as everywhere else here: a webhook trigger names the agent it starts, and that agent's scopes and egress rules are what a compromised payload can reach. An agent fed by a public hook should be configured as though its input were hostile, because it is.

Which session

A new session per delivery, as for schedules. Each event is independent and the simplest thing is right for a first version.

The alternative -- keying deliveries by a field in the body so events about one booking land in one conversation -- is the thing a real deployment will ask for, and it is deliberately not built. It needs a mapping from external identity to session, a decision about when a thread is finished, and a story for two deliveries racing into one session. A nullable key column added later costs less than guessing at those now.

Replay is refused, under hmac

A signature stays valid until its own timestamp ages out, so the window alone is not a replay defense: it bounds how long a captured request works and does not stop it working. Said plainly because the earlier wording was not -- "refuses a timestamp outside a few minutes so a captured request cannot be replayed indefinitely" is true and reads as a defense it never was.

So the signature is recorded. webhook_deliveries holds one row per delivery, keyed on the trigger and a hash of the signature, and the insert is the check: a primary key means the second presentation cannot be stored, and on conflict do nothing turns that into an answer rather than an error. One statement, no read-then-write race. A replay is answered 409, not the quiet 404 an unverified caller gets -- it holds the credential, so telling it that this delivery already landed is telling it something it may act on.

Replay rejection, not idempotency: what is remembered is that a delivery happened, not what it produced. Handing back the first result is what idempotency.md is about, and it is a larger promise than an inbound hook needs.

The record is anchored to the signed timestamp plus the tolerance, not to arrival, and the difference is load-bearing. The window is checked with an absolute value, so a signature stamped in the future verifies now and keeps verifying after an arrival-anchored record would have been swept -- which is the gap a replay walks through. It is released again on any path that does not start a turn: a delivery the ceiling refuses, or one whose turn fails to start, has claimed a slot it never used, and a 429 or a 500 is precisely the response that asks a sender to send the same bytes again. Refusing that retry as a replay would lose the event silently, which is worse than the duplicate it prevents.

Rows are swept once they can no longer be replayed, from the worker's tick rather than a loop of its own, so the table stays the size of a few minutes of traffic.

shared_secret gets none of this, and the exemption is deliberate. It carries no signature and binds no time, so the only thing to key a record on is the token and the body -- which makes a heartbeat, a retry, or the same event reported twice indistinguishable from a replay, and the second of them refused. It would also buy nothing: there is no captured request to replay when the credential travels on every delivery and never expires, so whoever captured one mints fresh requests rather than resending an old one. Refusing real traffic to defend against nothing is the wrong trade. A sender on this scheme should still be treated as able to deliver the same event twice, and a trigger whose agent acts on the world should be written so that acting twice is survivable.

shared_secret is worse and the difference is worth stating: it binds no body, so a captured request can be replayed with a payload of the attacker's choosing until the secret is rotated.

Managing them

Webhooks are made on the agent's settings page, in a section beside its schedules, since both answer what the agent does when nobody is talking to it. Each is a name, the prompt its deliveries become -- with {{body}} where the request's body goes -- a scheme, an optional account label -- whose conversations these are, as the dashboard groups usage -- and an hourly ceiling.

The full address is composed in the browser, from its own origin and the path the API returns. The API cannot know the host it is publicly reached at, and the browser is reaching it at that host already. When that host is clearly not reachable from the internet -- loopback, a private address, a name with no dot, or a suffix such as .local reserved never to resolve publicly -- the page notes that a sender outside the network needs a public address. Only the certain cases raise that note: a private name under a real domain is not caught, because a warning that is sometimes wrong teaches people to ignore it.

The secret is shown once, after the webhook is created or its secret rotated, alongside what a sender needs to prove it holds it: under hmac, the timestamp and signature headers and what is signed; under shared_secret, the header that carries it. The API never returns it again, so a lost secret is replaced rather than recovered, and rotating one stops the sender until they are given the new one.

A request with a wrong signature or secret is answered 404, as though the address did not exist, and is not counted anywhere but the log: it is refused before the sender has proved anything, and recording it would let a probe of the address create work. The refused count beside each webhook is only what the hourly ceiling turned away.

What is still to settle

The concurrency half of the ceiling. Built (2026-10-20): max_concurrent on every trigger, four by default, counting its turns still pending, running or parked. Checked with the hourly half under one lock on the trigger, so two firings arriving together cannot each miss the other, and a firing it refuses is not counted against the hour.

The ceiling for schedules. Both halves now apply to schedules too, through the one pipeline. Less urgent, because a schedule fires only as often as its own expression says, where a hook fires as often as whoever holds the URL chooses.

Per-agent narrowing on trigger creation. Fixed. Creating a trigger is a way to make an agent run turns, and sessions:create is narrowed per agent (user_agent_scopes) while agents:update deliberately is not. So somebody narrowed away from an agent could cause it to run by giving it a schedule or a hook. Both endpoints now check require_for_agent with sessions:create, as sessions::create_session does.

One model for every trigger

Built (2026-10-20), but for the single form (The API and the page). Schedules and webhooks were built one after the other, and what they share was pulled out only at the last step: trigger::start, which makes the session and queues the turn, became shared after a copy of it lost three of its steps. Everything before that step is still written twice -- two tables, two stores, two management APIs (schedules.rs, webhooks.rs), two editors (AgentSchedules.tsx, AgentWebhooks.tsx) -- and a third kind would make it three times. Email is that third kind, which is the moment to stop.

What a trigger is

Two halves, and the split is the design. A source is how a firing arrives: a time comes due, a request is received, a message is found in a mailbox. A firing is what every source hands on, the same shape whatever produced it:

text         what arrived: a webhook's body, an email's text, nothing for a schedule
external_id  what the sender calls it: a delivery's id, an email's Message-ID
sender       who it claims to be from: an address, a signing key's holder
metadata     anything else worth recording on the message

Then one pipeline, in this order, for every kind:

  1. Dedupe on (trigger, external_id), recorded in the same transaction that queues the turn -- so a crash between the two redoes the record, never the turn. A schedule's external id is the time it was due, which makes a tick that fired twice one firing.
  2. Ceiling: the hourly maximum and the concurrent maximum, refused before the queue. Webhooks have the first today and nothing has the second (What is still to settle); one place to enforce both is how schedules get them.
  3. Sender policy: whether this sender may start this agent at all. Nothing for a schedule; the signature or shared secret for a webhook, already checked by its source; an allowlist and the mail server's own verdict for email.
  4. Session: a new one per firing, for every kind. A trigger is work done on arrival -- classify this email, route it, draft the welcome note -- and each firing is its own piece of it, as each schedule run already is. Keeping an email thread or a booking's events in one session is a conversation, which is a channel's business (below), not a trigger's. An agent that needs what earlier firings found reads them, through history access (history-access.md), rather than having them in every prompt.
  5. Prompt: the trigger's template around the text, as webhooks do now.
  6. trigger::start, unchanged.

A source is then small: produce firings, and say what its own sender check is. Schedules are a clock, webhooks an HTTP route, email a mailbox.

Replay is not dedupe

Two records that look alike and are not. webhook_deliveries refuses a captured request presented twice (Replay is refused), keyed on a signature and released when its turn never started, so the sender's retry gets through. Dedupe refuses an event delivered twice, keyed on what the sender calls it, and is not released: the second delivery of a booking is the same booking. A webhook source keeps its replay check, before the pipeline; dedupe is the pipeline's, after it. A sender that names its deliveries -- an id header -- gets both, and one that does not gets replay protection alone, as today.

The table

One triggers table for what every kind has -- workspace, agent, name, prompt template, enabled, account, owner, the ceiling and its window, the last firing's time, status and error, who made it and when -- with kind and a config for what one kind has alone: a schedule's expression, zone and next run; a webhook's path, scheme and sealed secret; a mailbox's address and cursor. trigger_firings holds the dedupe keys, each for thirty days from its firing, which no sender's retries outlast. Migrated from schedules and webhook_triggers keeping their ids, so a webhook's address and anything linking a schedule do not change.

The API and the page

/v1/agents/{id}/triggers, one list for an agent with each row's kind, and a form whose common half is the same for every kind and whose other half is the kind's own. Built (2026-10-20): the list, and one Triggers section on the agent's page whose New trigger asks the kind first. Making and editing still go through each kind's routes and form; one form with a common half is the part not yet done. The old routes stay as aliases for a release. On the agent's page, one Triggers section where there are now two, each row with its kind's icon, as now; adding one asks which kind first.

Order of work

  1. The table, the migration and the pipeline, with schedules and webhooks as its first two sources -- behavior unchanged, which the existing tests say.
  2. The concurrent ceiling, for both, since the pipeline is where it goes.
  3. One API and one page.
  4. Email, as a third source (below).

Triggers are not channels

A channel is a conversation over another medium -- Slack, email, SMS -- where the agent's reply goes back the way the message came, and replies in one thread stay one conversation. A trigger is one-way: something arrives, the agent works, and what it produces goes wherever its tools send it. A channel would use a trigger source for its inbound half, and add what a trigger deliberately has not: sending the reply, a thread kept as one session, and a sender mapped to a person. It also reopens a decision this platform has taken -- seats for the people who run a deployment and for a tenant's staff, and none for a tenant's own customers -- since an SMS channel is a tenant's customer talking to the agent directly. Undesigned; on the roadmap.

Email

Inbound parse is built; IMAP is designed and unbuilt. Four ways in, and they trade differently:

For Against
IMAP -- poll, or IDLE for push Any mailbox, as it is: no DNS change, mail stays where it lives. Gmail and Microsoft 365 increasingly want OAuth rather than a password, which is setup per provider; IDLE holds a connection per mailbox, which stateless pods do not keep without one pod owning each; the gateway learns a protocol that is not HTTP.
An inbound-parse service -- mail forwarded or delivered to a provider that POSTs it, parsed Push, no mailbox credential held anywhere, MIME already taken apart; it is the webhook path. A forwarding rule or a DNS change, and a third party reading the mail.
A mail provider's own API -- Gmail push, Microsoft Graph notifications Push, scoped OAuth, threads and labels as the provider knows them. Code per provider, an application to register and have verified, subscriptions to renew.
A mail server of our own -- "forward to agent@" The simplest thing to tell somebody. Running inbound mail: spam, abuse, deliverability. Not worth it.

First, inbound parse: it is a webhook source with a parser, so nearly all of it is built once the model above is. Built (2026-10-20), as a third kind, email, sharing the hooks' paths, ceiling, dedupe and pipeline:

  • The message arrives raw, RFC 822 as the service delivers it, and is taken apart here -- one format every inbound service and mail-routing worker can send, so no code per provider. The agent is given its sender, subject and text (HTML made plain where there is no text part), in place of {{body}}. Attachments are not given yet.
  • How the service proves itself is whatever it can send, since none signs a request this platform's way: the long unguessable path alone, a token header, or basic authentication in the URL it is given.
  • The sender policy: an allowlist of addresses, @domains, or *, and -- unless turned off on purpose -- the receiving server's word that the domain in From: sent it, read from the topmost Authentication-Results only, since any further down came with the message. What counts is dmarc=pass, or dkim=pass whose header.d is the From: domain or a parent of it, or spf=pass whose smtp.mailfrom domain is. A pass for any other domain counts for nothing: SPF and DKIM vouch for the domain they checked, and a sender passing for its own can write anybody's address into From:, which is what the allowlist reads. Mail failing either is answered 202 and acted on not at all: a refusal would only have the service deliver it again.
  • Dedupe on Message-ID, with a hash of date, sender and subject standing in where it is missing; a message delivered twice is acknowledged 200 and run once.

Made through /v1/webhook-triggers with "kind": "email", and on the agent's page as New trigger → Email. Then IMAP, for whoever needs an existing shared mailbox watched, with what it needs worked out already:

  • The cursor is IMAP's own: the folder's UIDVALIDITY and the highest UID taken. Not the \Seen flag, which is the people's -- a receptionist reading a message first would hide it from the agent, and the agent marking messages read would hide new mail from the receptionist. What the agent took is shown with a keyword of its own ($Outturn) or a move to a folder.
  • Dedupe on Message-ID, through the pipeline, for when the cursor resets -- UIDVALIDITY changes -- or a mark did not reach the server. Missing or reused ids are not rare, so a hash of date, sender and subject stands in.
  • The gateway holds the mailbox credential and does the fetching, as it makes every other outbound call, under an egress rule and a sealed credential bound to the mail host; the API's worker says when to poll.

Either way, one thing is email's own: the sender. anybody can send email to an address, so it is not merely untrusted but unauthenticated in a way a signed webhook is not. An allowlist of sending addresses, and the receiving server's verdict on the From: domain from its Authentication-Results header, is enough for a long time. The body is the least trusted text an agent will read: the platform preamble's rule that instructions come from the system prompt alone (prompt-contributors.md) is written for exactly this, and what bounds a message that gets past it is, as everywhere, what the agent may reach.