How an event reaches an agent, and how a second event about the same thing reaches the same agent instead of a fresh one.
The automation service turns triggers into agent runs. A run is a sandbox holding an agent server, into which the service uploads a tarball and starts an entrypoint. Everything upstream of that is about deciding whether to start one.
A run is created by the scheduler when a cron automation is due, by a person calling the API, or by an event arriving from a provider. The first two always create a run. The third is the interesting one, because an event is often about something that already has a conversation: a Slack thread somebody is still replying in, a pull request that keeps collecting comments.
The change described here gives a trigger a second destination. Instead of creating a run, a matched event can be delivered as another turn on the conversation that earlier events about the same subject already opened.
A transport owns acquisition, authentication and interpretation. It then hands the service an AcceptedEvent: a source, an event key such as issue_comment.created, the raw payload, the provider's delivery id, and optionally the subject the event is about. Nothing downstream knows how the event arrived.
Two transports exist today. The HTTP endpoint at POST /v1/events/{org}/{source} verifies a signature before doing anything else, with the scheme resolved per source from a provider registry. Built-in sources use a hex HMAC over the raw body; a custom webhook picks its own scheme and signature header, and Slack's and Standard Webhooks' schemes sign a timestamp, so they reject deliveries older than five minutes.
The second is a supervised socket connection. One task per source is held open for the life of the process, with the supervisor catching every exception so a failing provider cannot take down the scheduler, the dispatcher or HTTP handling. A source that is configured wrongly raises a config error and stops being supervised, because retrying cannot fix it. The Slack socket transport is the one that names its own subject, since it reads the envelope directly.
Every transport funnels into a single function, which records the event and resolves every matched automation before committing once.
Typed events keep their model shape when stored, so a GitHub run reads the parsed event; everything else stores the raw payload.
A subject is the external thing an event is about, named by a string. Slack's is the team, channel and thread timestamp. GitHub's is the repository and the issue or pull request number, with both reading to one key because GitHub numbers them from a single sequence. An event about nothing numbered, such as a push, has no subject, and that is a real answer rather than a gap.
The conversation is not looked up. It is computed:
conversation_id = uuid5(NAMESPACE, f"{org_id}/{automation_id}/{source}/{subject_key}")
The namespace is pinned permanently. Changing it re-keys every live thread at once, which readers experience as every conversation losing its memory on a deploy.
The automation id is in the key deliberately. A conversation belongs to the automation whose script created it, with its prompt, its tools and its tarball. Two automations in one organization watching the same channel would otherwise share a conversation, and the second would send turns into one that cannot answer them. It also means editing an automation re-keys its threads rather than pinning a thread to whichever agent saw the first event.
Built-in extractors cover the two sources that have one. A trigger can supply its own expression instead, which is how a custom webhook, or Slack arriving over the older webhook bridge, opts in. The expression is compiled at creation time with the same check the filter uses, because a typo would otherwise look exactly like the feature not being switched on. A result that is not a scalar is refused: a dictionary is not an identity, and a boolean would collapse every event onto one subject.
| Trigger field | Default | Effect |
|---|---|---|
| destination | dispatch_run | Set to continue_conversation to opt this trigger into threading. Per trigger, not per automation, because the trigger is where the event shape is already known. |
| subject_key_expr | null | A JMESPath expression naming the subject, overriding the provider's extractor. Must evaluate to a scalar. |
| turn_text_expr | null | A JMESPath expression producing the text of the delivered turn. |
| wake_agent | true | Whether the delivered turn also starts the agent. False leaves the message in history for the current turn to pick up. |
For each matched automation whose trigger asks to continue, the service takes the subject's lock, finds the most recent run holding that subject, and then picks one of three outcomes.
If the run has started, the turn is posted to the agent server holding it. The composed text is the trigger's expression if it has one, then the message the payload obviously carries, and only a shape nothing recognises falls back to the verbatim JSON. Dumping the payload unconditionally buried the one line a human wrote under kilobytes of webhook metadata, every single turn.
The request appends a user message and starts the agent loop only if one is not already running, so a message arriving mid-turn is queued into the live loop rather than starting a second one.
If the run exists but has no start timestamp, there is no agent server to talk to yet, but that run will open the same derived conversation and has not read its payload. So the turn is parked on the run's payload under a private key, capped at twenty. The dispatcher lifts those back out at boot and the script replays them as extra messages. This is what stops a burst of messages on a brand-new subject from becoming a burst of runs.
No run, or a run whose sandbox is gone, means a run gets created and the subject is written onto it. That column is not a mapping to a conversation. The conversation id is derived and never stored. The column exists because the sandbox holding a conversation can only be named by the server.
Row locks alone cannot order two events that both find nothing, and that is exactly the burst that leaves one subject with two runs. So every event for a subject first takes a Postgres advisory lock keyed on a hash of the automation and the subject. It is transaction-scoped, so the caller's commit releases it, which means it also covers the run that event may go on to create. Because the lock lives in the database it orders events across pods, not just across tasks in one process.
The subject's run row is then selected for update, so a second event sees the first event's decision instead of racing it. Locks are taken in automation id order, which is why candidate automations are loaded ordered by id: without that, two events on one thread can lock the same rows in opposite orders and deadlock.
SQLite skips both, on the assumption of a single process, exactly as the dispatcher and scheduler do.
Run creation and run execution are separate. A poller claims pending runs with a skip-locked select, marks each one running, and commits. Each run is then dispatched as its own background task so a long automation cannot block the loop.
The service does not watch the run to completion. The script inside reports back.
Routing addresses a conversation the service never creates. The first run must therefore open its conversation under that same id, or every follow-up would address a conversation that does not exist and silently start a fresh thread instead.
Both shipped presets read the environment variable and pass it as the conversation id. The SDK's remote conversation then fetches that id and, on a 404, posts a create request carrying the id in the body. The agent server uses a client-supplied id when it is given one and generates a random one only otherwise. A conversation that exists but was started with a different agent kind raises rather than attaching, which is why re-keying on edit is the wanted behaviour rather than a compromise.
A custom tarball that ignores the variable gets a fresh conversation per event. That is documented, and it is the one part of the contract the service cannot enforce.
On completion the script posts back the conversation id, the accumulated cost and any run metadata.
Ordinarily the completion callback deletes the run's sandbox. Deleting the sandbox of a threaded run would destroy the conversation the next event is meant to continue, which would make threading a no-op in cloud mode. Rather than have every deletion site re-derive that, the question is settled once at creation: an automation whose trigger continues a conversation is required to keep its sandbox alive, and asking for the opposite is rejected outright. Every cleanup path then reads that one flag. The runtime's own reaper still collects the sandbox eventually, and that reaper is the real expiry policy. There is no mapping to go stale.
The watchdog reads the same flag when it terminalises a stuck run, and it is also the service's only periodic janitor, pruning recorded events on the same loop.
Cancellation is the exception. It is explicit, so the sandbox goes even when the run owns a subject, and the subject is released with it. Otherwise the next event would pick that run and pay a lookup for a sandbox that was just deleted. Released is not erased: the key stays on the row as the record of what the run was about, and a second timestamp column marks the moment it stopped routing.
Every failure to deliver a turn degrades to creating a run. Nothing raises, because a reaped sandbox is ordinary rather than exceptional.
Before giving up, two kinds of not-yet are waited out. A sandbox that is merely paused is resumed and polled for up to twenty seconds, since the conversation survives a pause and only the sandbox has to come back. A sandbox that answers but returns a 404 on the append is taken to mean the script has not opened the conversation yet, and the append is retried for up to ten seconds. That second wait applies only while the run is unfinished; once it has completed, a 404 means the conversation is gone for good and waiting would buy nothing.
If the run is finished and still unreachable, its subject is released, which stops every later event paying the timeout to rediscover the same reaped sandbox. A run that has not finished keeps its subject, because it may not have recorded its sandbox yet and releasing it would orphan the subject.
That last distinction is also where the remaining gap lives.
The poller marks a run running and commits before the sandbox exists, and the sandbox id is written only after the entrypoint launches. In that window there is no agent server to resolve, so the turn is not delivered and a second run is created. The shorter window that used to follow it, where the sandbox is reachable but the script has not opened the conversation, is now waited out by the retry described above.
The subject is not released, because the run has not finished, so this is a fork rather than an orphan. Follow-ups after the conversation exists all land correctly on one thread.
The obvious fix does not work. The continuation path holds the run's row lock for the length of its attempt, and the dispatcher needs that same lock to write the sandbox id, so waiting inside the lock prevents the very write it waits for. Closing this properly means recording the sandbox id as soon as the sandbox exists rather than after the entrypoint starts, or parking the event in the queue this design does not adopt.
Two events arriving on a brand-new subject before either has a run at all will produce two runs. Dropping the loser would silently discard somebody's message, and holding it would need the pending-event queue this design deliberately does not adopt. Every follow-up after that lands on one conversation.
The only schema change threading adds is two nullable columns on the run. Their shared index is partial, covering only the runs still routing for a subject.
| Run column | Written by | Read for |
|---|---|---|
| subject_key | Ingest, at run creation | Finding the sandbox that holds a subject's conversation. Never erased, so it doubles as the record of what a run was about |
| subject_released_at | Cancellation, or a failed turn to a finished run | Excluding a run from the lookup once it stops routing, without losing its key |
| sandbox_id | Dispatcher, after the entrypoint starts | Resolving the agent server, verification, cleanup |
| conversation_id | Completion callback | Display. Routing never reads it, since the id is derived |
| started_at | Poller, at the transition to running | Deciding between folding a turn and delivering one |
| timeout_at | Poller, then reset at shell start | The watchdog's indexed staleness scan |
| event_payload | Ingest, plus folded turns | The script's view of what triggered it |
| Constant | Value | Governs |
|---|---|---|
| TURN_TIMEOUT_SECONDS | 15 | The whole attempt to deliver one turn, including resolving the agent server |
| RESUME_WAIT_SECONDS | 20 | How long a paused sandbox is given to come back before falling back to a run |
| RESUME_POLL_SECONDS | 2 | Polling interval while waiting for that sandbox |
| CONVERSATION_WAIT_SECONDS | 10 | How long an append is retried against a 404 while the run is still opening its conversation |
| CONVERSATION_POLL_SECONDS | 2 | Polling interval for that retry |
| MAX_COALESCED_TURNS | 20 | Turns parked on one queued run. A burst is a handful of messages, not a mailbox |
| MAX_SUBJECT_KEY_LENGTH | 500 | Matches the column. Truncating would merge two subjects |
There is no lifecycle state owned by the service, no idle timeout, and no session table. A subject is a routing key, not a managed entity.
Nothing buffers events that arrive before a sandbox is ready or while one is gone, beyond the turns folded onto a queued run. There is no acknowledgement protocol.
The script does not pull pending events or acknowledge them. The service formats the turn and delivers it, which costs the script some control.
A dead sandbox always degrades to a new run. There is no configurable choice between queueing, restarting and dropping.
Each of those is additive. Nothing here forecloses building them, and the derived id survives all four, because it never depended on stored state in the first place.