# Durable events for AI agents: why polling and webhooks are not enough

*2026-10-02*

How an append-only event log with long-poll waits and lossless cursors lets an agent block on a reply, survive restarts, and never miss an event. Design, failure modes and code.

## The problem

An agent sends an email and needs to continue when the reply arrives, which may be seconds or days later. There are three common ways to do that, and each fails in a specific way.

- Polling the inbox burns requests, adds latency, and needs its own bookkeeping to avoid handling a message twice.
- Webhooks need a public URL, which an agent running on a laptop or inside a sandbox rarely has, and they are lost if the receiver is down at the wrong moment.
- In-memory callbacks die with the process, so a restart silently drops the conversation.

## The design: an append-only log you can wait on

Every meaningful state change appends one row to a per-organization, append-only log: mail.received, mail.sent, mail.delivered, mail.bounced, a2a.task.created and so on. Nothing is overwritten or deleted and order is preserved. Two operations sit on top of that log: reading history, and waiting for the next matching event.

```json
{
  "id": "b70c…",
  "type": "mail.received",
  "identity_id": "6f21…",
  "aggregate_type": "mail_message",
  "aggregate_id": "e133…",
  "payload": { "messageId": "e133…", "threadId": "9a04…", "from": "you@example.com", "subject": "Re: Hello" },
  "created_at": "2026-09-27T10:04:11.221Z"
}
```

## Waiting: a long poll, not a stream

GET /v1/events/wait holds the connection open until a matching event is appended or the timeout expires, and returns 200 either way: the event with timed_out false, or null with timed_out true. A long poll was chosen over a persistent stream because it works through any proxy and needs no sockets. The timeout is capped at 55 seconds to stay inside the idle timeout of typical proxies, so a long listen is a loop that re-arms the wait.

```bash
curl -sS "https://api.agent-identity.dev/v1/events/wait?type=mail.received&thread_id=9a04…&timeout_ms=55000" \
  -H "authorization: Bearer $AID_KEY"
```

## The cursor rule that makes it lossless

A wait without a since parameter opens its window when the request is handled. If you send an email and then call wait, the event may already have been appended and you will miss it. The fix is to take a timestamp before the action and pass it as since, or start the wait first. For a loop, advance the cursor from the event you just received, not from the client's clock, so an event appended while you were handling the previous one is still there when you come back.

```ts
let since = new Date();
for (;;) {
  const { event, timedOut } = await client.events.wait({
    type: "mail.received",
    identityId,
    since,
    timeoutMs: 55_000,
  });
  if (timedOut || !event) continue;   // nothing arrived, go around again
  since = new Date(event.createdAt);  // advance from the event itself, never from the wall clock
  await handle(event);
}
```

Using the event's own timestamp rather than Date.now() matters because client and server clocks drift. A cursor taken from a skewed client clock can jump past events, which is the quiet kind of data loss.

## The same loop as an async iterator

```ts
for await (const event of client.events.subscribe({ type: "mail.received", identityId })) {
  await handle(event);
}
```

subscribe() owns the cursor, re-polls transparently when the server window elapses, retries failures with exponential backoff, and stops on an AbortSignal. Payloads are typed per event type, so narrowing on event.type narrows the payload without casts. Unknown event types from a newer server arrive with a generic payload instead of throwing.

## Failure modes and how the design handles them

- Process restart: persist the last event's createdAt and pass it as since on startup. The log still has everything after it.
- Handler crash: the cursor only advances after you receive an event, so store it after handling to get at-least-once delivery, and make the handler idempotent.
- Event arrives between waits: it is in the log, and the next wait with the right since returns it immediately.
- Timeout with no event: not an error. The call returns 200 with timed_out true.
- Filters that never match: filters are ANDed and exact, so identity_id, aggregate_id and thread_id must all equal the event's own values.

## When to use webhooks instead

If you have a public endpoint and want push delivery, use webhooks: they are signed with a timestamped HMAC and can be verified in one call. The two are complementary because both read from the same log. You can also replay events from history if a webhook receiver was down.

## Read more

- Events: https://www.agent-identity.dev/docs/concepts/events
- Webhooks: https://www.agent-identity.dev/docs/concepts/webhooks
- SDK reference: https://www.agent-identity.dev/docs/sdk

---
Docs: https://www.agent-identity.dev/docs · llms.txt: https://www.agent-identity.dev/llms.txt