Events URL
An events URL gets everything that happens in an agent's sessions: each logged event, in order, and the model's text as it streams. Use it to show a session in your own app, to store it, or to act on it.
A worker decides what happens next. An events URL tells you what happened. One program can serve both, at two paths.
[worker.app]
url = "https://app.example.com/agent"
[events.app]
url = "https://app.example.com/events"
[agent.bot]
worker = "app"
events = "app"| Direction | What | Your app answers |
|---|---|---|
| Engine → worker URL | "What next?" | Actions |
| Engine → events URL | "This happened", and live text | 2xx |
| Your app → machine API | Messages, approvals, reads | – |
Which sessions send to it
Every session of an agent that names the block. A subagent's session sends to its own agent's events URL. If its agent names none, it sends to its parent's.
A session keeps the events URL it started with. Changing events on an agent
applies to new sessions.
Logged events
The engine POSTs batches of events. Each session's events arrive in order, one batch at a time.
{
"type": "events",
"session_id": "chat:room42",
"agent_id": "bodi",
"metadata": { "room": "room42" },
"events": [
{ "seq": 41, "occurred_at": "…", "turn_id": "t9", "payload": { "type": "turn.started", "turn_id": "t9" } },
{ "seq": 42, "occurred_at": "…", "turn_id": "t9", "payload": { "type": "message.new", "message": { … } } }
]
}The top of the POST holds what never changes in a session: session_id,
agent_id, the metadata that the submit set, and ancestry for a subagent's
session. Use metadata to know where the session lives. Each event holds its
seq, its time, its turn, and its payload. Skip the events
you do not need.
| Answer | What the engine does |
|---|---|
2xx | The batch is delivered. The next one follows. |
Anything else, or no answer within timeout_secs (10 by default, 15 at most) | It sends the same batch again later, with backoff. It writes delivery.failed to the session log. |
A Retry-After header in seconds sets the wait. The engine tries for up to
three days. Then it skips the event and writes delivery.skipped. While the
URL stays down, each later event gets one try and is skipped if it fails. The
first 2xx ends this and writes delivery.recovered.
The same batch can arrive twice, for example after a crash before the engine
saved your answer. Use session_id and seq to drop a repeat.
A slow or failing events URL does not hold up model calls, workers, or other sessions.
Live text
While the model writes, the engine also POSTs its text, about four times a second.
{
"type": "live",
"session_id": "chat:room42",
"agent_id": "bodi",
"metadata": { "room": "room42" },
"turn_id": "t9",
"call_id": "llm-3",
"attempt": 0,
"chunks": [
{ "seq": 7, "text": "Logged 12" },
{ "seq": 8, "text": "0ml." }
],
"done": false
}- Apply a chunk only if its
seqis higher than the last one you applied. A chunk that you did not confirm with a2xxcomes again in the next POST. done: truecomes with the last chunks of the call.- A new
attemptstarts over at the first chunk. Replace the old text. reasoningcarries the model's thinking the same way astext.- A gap in
seqmeans some text was lost.turn.completedholds the final reply.
Live text is best effort. It is not written to the log and is not sent again after a restart. It can arrive before the logged events around it.
Verify the signature
Each POST carries X-Substructure-Signature: sha256=<hex>, an HMAC-SHA256 of
the body, the same as a worker's request. Print the secret:
subs secret events.appMap places to sessions
Your app picks the session. Send your own session_id on the submit. The same
id reaches the same session.
| You want | session_id |
|---|---|
| One session per thread | slack:T1:C9:1700.1 |
| One session per room | chat:room42 |
| One session per person | wa:+15551234 |
Do not use / in the id. It appears in URL paths.
Put where the session lives in identity.metadata. The engine keeps it for the
life of the session, sends it with every event, and copies it to subagent
sessions.
Two more inputs help a chat app:
queue: "merge"puts the messages that arrive during a turn into one next turn.client.stateonclient.appendcarries data for the turn, such as the message it answers. It arrives on the first decision of the turn and on itsdecision.queuedevent.
Chat apps
Each one follows the same shape: the platform's webhook reaches your app, your app submits, and your events URL posts back to the platform.
| Session id | Live text | Approvals | |
|---|---|---|---|
| Slack | the thread, or the DM | edit or stream one message | block buttons |
| the person | none: send at turn.completed | reply buttons or a list | |
| Telegram | the chat or topic | edit one message | inline keyboard |
| Discord | the thread or channel | edit one message | buttons |
| SMS | the person | none: send at turn.completed | "reply YES or NO" |
For an approval, session.interrupted arrives with its interrupt_id and
options. Show them, then send interrupt.resume when a person picks one.
On a platform without threads, keep one session per person and reset it from the worker after a quiet hour. See Reset or compact.
Next steps
- Workers: the other half of an app.
- Events: every event an events URL can get.
- Conversations: merge messages into one turn, reset, and compact.