---
title: "Events"
description: "The append-only log, long-poll waits that replace polling, and replay."
canonical_url: "https://www.agent-identity.dev/docs/concepts/events"
markdown_url: "https://www.agent-identity.dev/docs/concepts/events.md"
last_updated: "2018-10-20"
agent:
  tokenBudget: 1100
  task: "Consume the durable event log correctly — wait for a specific event, page the history, or replay one to webhook subscribers."
  outcome: "A wait returns the intended event without a race, history paging terminates, and replay re-delivers exactly the requested event."
  appliesTo:
    package:
      - "@aid/api"
      - "@agentidentity/sdk"
  prerequisites:
    - "An API key for the organization whose log is being read."
    - "For waits, knowledge of the event type string; filters are exact-match, not prefix or glob."
  files:
    - "apps/api/src/services/events.ts"
    - "packages/sdk-ts/src/client.ts"
  commands:
    - run: "aid events tail --type mail.received --agent <identity-id>"
      description: "Follow matching events from the CLI, re-arming the wait after each one."
  sideEffects:
    - "Replay re-enqueues delivery to every matching webhook endpoint; non-idempotent consumers will act twice."
  verification:
    - description: "Start a wait, then cause the event."
      expect: "The wait returns 200 with timed_out false and the matching event, before the timeout elapses."
    - description: "Call a wait for a type nothing emits."
      expect: "200 with {\"event\": null, \"timed_out\": true} after timeout_ms, not an error status."
  rollback:
    - "Events are append-only; there is no delete. A replay cannot be un-sent — disable the webhook endpoint first if a replay was a mistake."
  failureModes:
    - symptom: "A wait issued right after the triggering call returns timed_out true."
      resolution: "Without `since`, the wait's window starts when the request is handled, so an event appended microseconds earlier is missed. Capture a timestamp before the action and pass it as `since`, or start the wait first."
    - symptom: "timeout_ms above 55000 returns 400."
      resolution: "The maximum is 55000 ms, chosen to stay inside typical proxy idle timeouts. Re-arm the wait in a loop for longer waits."
    - symptom: "A wait never matches although the event appears in GET /v1/events."
      resolution: "Filters are ANDed and exact. identity_id, aggregate_type, aggregate_id, and thread_id must all match the appended event's own values; thread_id only exists on mail events."
---

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Consume the durable event log correctly — wait for a specific event, page the history, or replay one to webhook subscribers.
Outcome: A wait returns the intended event without a race, history paging terminates, and replay re-delivers exactly the requested event.

### Applies To

- Package: `@aid/api`, `@agentidentity/sdk`

### Prerequisites

- An API key for the organization whose log is being read.
- For waits, knowledge of the event type string; filters are exact-match, not prefix or glob.

### Files

- `apps/api/src/services/events.ts`
- `packages/sdk-ts/src/client.ts`

### Commands

- `aid events tail --type mail.received --agent <identity-id>` — Follow matching events from the CLI, re-arming the wait after each one.

### Side Effects

- Replay re-enqueues delivery to every matching webhook endpoint; non-idempotent consumers will act twice.

### Verification

- Start a wait, then cause the event.
  - Expected: The wait returns 200 with timed_out false and the matching event, before the timeout elapses.
- Call a wait for a type nothing emits.
  - Expected: 200 with {"event": null, "timed_out": true} after timeout_ms, not an error status.

### Rollback

- Events are append-only; there is no delete. A replay cannot be un-sent — disable the webhook endpoint first if a replay was a mistake.

### Failure Modes

- A wait issued right after the triggering call returns timed_out true. — Recovery: Without `since`, the wait's window starts when the request is handled, so an event appended microseconds earlier is missed. Capture a timestamp before the action and pass it as `since`, or start the wait first.
- timeout_ms above 55000 returns 400. — Recovery: The maximum is 55000 ms, chosen to stay inside typical proxy idle timeouts. Re-arm the wait in a loop for longer waits.
- A wait never matches although the event appears in GET /v1/events. — Recovery: Filters are ANDed and exact. identity_id, aggregate_type, aggregate_id, and thread_id must all match the appended event's own values; thread_id only exists on mail events.
<!-- farming-labs:agent-contract:end -->

# Events — machine contract

Authoritative schema: `GET {API_URL}/v1/openapi.json`. This file is the operational
contract; prefer it over the prose page when implementing.

## Wait

`GET /v1/events/wait` — long poll. One request returns at most one event.

Query parameters (Zod, `waitForEventQuerySchema` in `apps/api/src/app.ts`):

| Name | Type | Required | Constraint |
| --- | --- | --- | --- |
| `type` | string | yes | `min(1)`, exact match |
| `identity_id` | string | no | |
| `aggregate_type` | string | no | |
| `aggregate_id` | string | no | |
| `thread_id` | string | no | mail events only |
| `since` | string | no | RFC 3339 datetime |
| `timeout_ms` | number | no | coerced int, `0`–`55000`, default `25000` |

All supplied filters are ANDed and compared for exact equality. There is no prefix,
wildcard, or multi-type match; to watch several types, run several waits.

Response is always `200`:

```json
{ "event": { "id": "…", "type": "…", "identity_id": null, "aggregate_type": null, "aggregate_id": null, "payload": {}, "created_at": "…" }, "timed_out": false }
```

or

```json
{ "event": null, "timed_out": true }
```

Branch on `timed_out`. A timeout is not `204` and not an error.

### The race, stated precisely

`apps/api/src/services/events.ts` sets `after = input.since ?? new Date()` at the top of
the handler, subscribes to the notification listener, and then does one immediate
`findNextEvent` recheck. An event appended before the request reached the handler is
therefore outside the window and will never match, even though the wait is still open.

Two correct patterns:

1. **Cursor.** Capture `const since = new Date()` *before* the triggering call, pass it as
   `since`. Then advance `since` to the returned event's `created_at` for the next wait.
2. **Concurrent.** Start the wait promise first, then perform the triggering call, then
   await the wait.

A bare sequential `await send(); await wait();` without `since` is a race. Do not write it.

### Lossless follow loop

```ts
let since = new Date();
for (;;) {
  const { event, timedOut } = await client.events.wait({ type, identityId, since, timeoutMs: 55_000 });
  if (timedOut) continue;
  since = new Date(event.createdAt);
  await handle(event);
}
```

`timeout_ms` is capped at 55000 so the response lands inside common proxy idle timeouts.
Longer waits are built by re-arming, not by raising the value — `timeout_ms > 55000` is a
`400`.

## History

`GET /v1/events` — `limit` 1–200 (default 50), `before` an RFC 3339 datetime, paging
backwards. Page until fewer than `limit` rows come back.

## Replay

`POST /v1/events/:id/replay` → `{ "id": "<same id>", "replayed": true }`.

Re-delivers the existing event to matching webhook endpoints. It does **not** append a new
event and does **not** re-execute the original side effect. Consumers must be idempotent:
webhook deliveries carry the original `x-aid-event-id`, so a replay is indistinguishable
from a retry on the receiving end — which is the intended property.

A `404` means no event with that id exists in this organization. Event ids are not
guessable across organizations; a foreign id reads as absent.

## Event types and payloads

| Type | `aggregate_type` | `payload` keys |
| --- | --- | --- |
| `mail.sent` | `mail_message` | `messageId`, `threadId` |
| `mail.received` | `mail_message` | `messageId`, `threadId`, `from`, `subject` |
| `mail.delivered` | `mail_message` | `messageId`, `threadId`, optional `detail` |
| `mail.bounced` | `mail_message` | `messageId`, `threadId`, optional `detail` |
| `mail.complained` | `mail_message` | `messageId`, `threadId`, optional `detail` |
| `a2a.task.created` | `a2a_task` | `taskId`, `contextId`, `state`, `callerIdentityId`, `caller`, `message`, `messageId`, `parts` |
| `a2a.task.message` | `a2a_task` | `taskId`, `contextId`, `state`, `caller`, `messageId`, `parts` |
| `a2a.task.canceled` | `a2a_task` | `taskId`, `contextId`, `state`, `callerIdentityId`, `caller` |
| `a2a.sent_task.updated` | `a2a_task` | `taskId`, `contextId`, `state`, `result`, `worker`, `messageId`, `parts` |
| `a2a.task.updated` | `a2a_task` | `taskId`, `contextId`, `state`, `result` |
| `a2a.contact_rule.changed` | `a2a_contact_rule` | `change`, `ruleId`, `identityId`, `handle`, `action`, `direction`, `previous?`, `actor`, `via?` |
| `a2a.settings.updated` | `a2a_settings` | `identityId`, `changes` (from/to per field), `actor` |
| `a2a.invitation.created` · `.accepted` · `.declined` · `.revoked` | `a2a_invitation` | `invitationId`, `change`, `status`, `fromIdentityId`, `fromHandle`, `toIdentityId`, `toHandle`, `actor` |

`mail.delivered` / `mail.bounced` / `mail.complained` are emitted from the provider's
delivery-status webhook and are matched to the outbound message by
`provider_message_id`. If the provider reports a status for a message this system did not
send, it is counted as unmatched and no event is appended.

`identity_id` is nullable on the event row. For delivery-status events it is populated
only when the sending mailbox can still be resolved from the message's `from` address.
Do not require it to be non-null.

## Related

- `/docs/concepts/webhooks.md`
- `/docs/mail/receiving.md`
- `/docs/sdk.md`

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
