---
title: Event reference
description: The event taxonomy, the JSON envelope shared by all events, and per-browser URL capture support.
---

Every event Zanei records — regardless of source — shares one OS-independent JSON envelope. The stable schema is the core asset of the project: capture backends may change per OS, but the shape your tools consume does not.

## Event envelope

One event per line (NDJSON) in raw outputs (`query --format jsonl`, `record`, `export`):

```json
{
  "v": 1,
  "id": "evt_01J...",
  "ts": "2026-08-16T12:34:56.789Z",
  "mono_ns": 128374651234,
  "source": "macos.ax",
  "type": "window.focus",
  "app":    { "name": "Safari", "bundle_id": "com.apple.Safari", "pid": 501 },
  "window": { "title": "Design doc", "id": 42 },
  "element":{ "role": "AXButton", "title": "Send", "value": null },
  "data":   { },
  "redaction": { "applied": true, "rules": ["email"] }
}
```

| Field | Description |
| --- | --- |
| `v` | Envelope schema version (currently `1`) |
| `id` | ULID-based event ID, unique and time-sortable |
| `ts` | Wall-clock timestamp (RFC3339, millisecond precision) |
| `mono_ns` | Monotonic clock in nanoseconds — reliable ordering even across wall-clock changes |
| `source` | Capture backend, e.g. `macos.ax`, `macos.workspace`, `macos.eventtap`, `macos.applescript` |
| `type` | Event type (see the [taxonomy](#event-taxonomy)) |
| `app` | App name, bundle ID, and PID |
| `window` | Window title and ID (when applicable) |
| `element` | UI element role/title/value (when applicable; `value` is limited to allowlisted known-safe non-text controls, unknown elements have `value: null`, and secure fields never appear) |
| `data` | Type-specific payload, e.g. `url` / `tab_title` / `mode` for `browser.navigate` |
| `redaction` | Whether redaction rules (`email` / `credit_card` / `token`) were applied to this event, and which. Secure fields leave no redaction trace — they are excluded before capture |

This envelope is also published as a machine-readable JSON Schema at [`/schema/event.schema.json`](/schema/event.schema.json) — the single contract all surfaces share, and the file the Rust core's types are tested against.

## Event taxonomy

The current event types. The **Permission** column shows what must be granted for the type to be captured (see the [permissions guide](/guides/permissions)).

| Type | Source | What it records | Permission |
| --- | --- | --- | --- |
| `app.activate` | `macos.workspace` | Frontmost app changed | None |
| `app.launch` / `app.terminate` | `macos.workspace` | App started / quit | None |
| `window.focus` | `macos.ax` | Focused window changed | Accessibility |
| `window.title` | `macos.ax` | Window title changed | Accessibility |
| `ui.focus` | `macos.ax` | Focused UI element changed | Accessibility |
| `ui.click` | `macos.ax` | Click on a UI element | Accessibility (+ Input Monitoring) |
| `ui.value` | `macos.ax` | Element value changed; newly added input is opt-in and never includes the full free-text value | Accessibility |
| `input.key` | `macos.eventtap` | Key / shortcut. Default: fact of typing + field type; content is opt-in | Input Monitoring |
| `input.scroll` | `macos.eventtap` | Scrolling | Input Monitoring |
| `browser.navigate` | `macos.applescript` | URL / tab change. **Chrome only, currently**; Incognito always excluded | Automation |
| `clipboard.copy` / `clipboard.paste` | `macos.eventtap` | Clipboard use; content is opt-in | Input Monitoring |

In `--types` filters, a trailing wildcard selects a whole family: `browser.*`, `ui.*`, and so on.

`type` is an open set, not a closed enum: future platforms and versions may add types without a version bump, and consumers must skip unknown types rather than error.

## Per-type payloads

What each type carries in `data`, and whether the envelope's `window` / `element` are present (✓) or null (—). The normative definitions live in the [JSON Schema](/schema/event.schema.json) as per-type conditions.

| Type | `window` | `element` | `data` |
| --- | --- | --- | --- |
| `app.activate` | ✓ | — | `prev_bundle_id` — previously frontmost app, null on first event |
| `app.launch` / `app.terminate` | — | — | empty |
| `window.focus` | ✓ | — | empty (the focused window is in the envelope) |
| `window.title` | ✓ | — | `prev_title` — the new title is in the envelope |
| `ui.focus` | ✓ | ✓ | `field_kind` |
| `ui.click` | ✓ | ✓ | `button` (`left`/`right`/`other`), `click_count` |
| `ui.value` | ✓ | ✓ | `field_kind`, `value_len`, `text`. `text` is the newly added authorized difference, or null; `element.value` is never recorded for free-text or unknown fields |
| `input.key` | ✓ | — | `kind`, `modifiers`, `count`, `combo`, `text`, `field_kind` (details below) |
| `input.scroll` | ✓ | — | `direction`, `amount`, `count` — coalesced totals |
| `browser.navigate` | ✓ | — | `url`, `tab_title`, `mode` (always `"normal"`), `transition` (`navigate`/`tab_switch`/null) |
| `clipboard.copy` | ✓ | — | `content_kind` (`text`/`image`/`file`/`other`), `size_bytes`, `text` |
| `clipboard.paste` | ✓ | — | same as `copy`, plus `field_kind` of the paste target |

`field_kind` classifies the focused input field: `text` / `search` / `url` / `email` / `number` / `other`, or null when focus is not on a text-like element. There is no `password` value — secure fields are excluded before an event exists.

### `ui.value` in detail

For a free-text field, `data.text` contains only the newly added difference after an authorized input. Authorization requires a keystroke or paste from the same app, for the same focused-element generation, with the value-change notification timestamp no more than 3 seconds after that input. One authorization can permit at most one emission and is consumed when used. `text_content` must also be enabled. The field's complete value is never stored in `element.value`.

A value change without valid authorization, including an app mismatch, focused-element generation mismatch, or expired authorization, is not recorded as text: `data.text` is null and only `value_len` describes the resulting value. Deletion likewise has `text: null`. Voice input has no keystroke trigger, so its text is not recorded.

### `input.key` in detail

The exact shape of "fact of typing + field type by default, content opt-in":

| `kind` | Meaning | `combo` | `text` | Coalesced |
| --- | --- | --- | --- | --- |
| `text` | Printable typing | null | characters produced directly by a keyboard-layout input source, **opt-in only**; null for keyboard input modes and when the input-source type is unknown or unavailable | yes |
| `shortcut` | Modifier combo (`cmd+s`, ...) | **always recorded** | null | no |
| `navigation` | Arrows, PageUp/Down, Home/End, Tab | null | null | yes |
| `delete` | Backspace / Delete | null | null | yes |
| `other` | Esc, F-keys, media keys | null | null | no |

A shortcut is an *action*, not *content* — so `combo` is recorded without opt-in. It is a primary timeline ingredient (saves, commits, tab switches).

## Coalescing guarantees

Events are coalesced before they reach the store, so consumers do not see raw key-repeat or scroll floods:

| Events | Grouped by | Window | Result |
| --- | --- | --- | --- |
| `input.key` (`text`/`navigation`/`delete`) | app + window + `field_kind` + `kind` | gap ≤ 2s | one event; `count` summed, `text` concatenated (opt-in) |
| `input.scroll` | app + window + `direction` | gap ≤ 1s | `amount` and `count` summed |
| `window.title` | window | 500ms debounce | only the final title is emitted |
| `ui.value` | focused element | 1s collector debounce | one event for the final value; `text` is the added difference between that value and the pre-input baseline |

Shortcuts and `ui.click` do not coalesce (one action = one event). Core coalescing buffers are flushed on pause, stop, and batch flush. The collector-side `ui.value` buffer is flushed on focus change and collector stop.

## What `text_content` changes

Only *content* (what you typed or copied) is opt-in. *Facts of use* (shortcuts, clicks, URLs, titles) are recorded by default:

| Field | Default (`false`) | Opt-in (`true`) |
| --- | --- | --- |
| `element.value` | always null | recorded only for allowlisted known-safe non-text controls (such as buttons, checkboxes, radio buttons, sliders, pop-up buttons, menu items, tabs, and short static text); null for free-text, secure, and unknown elements |
| `input.key.text` | always null | characters produced directly by a keyboard-layout input source (after redaction); null for keyboard input modes and when the input-source type is unknown or unavailable |
| `input.key.combo` (shortcuts) | **recorded** | recorded |
| `clipboard.*.text` / `size_bytes` | always null | recorded (after redaction) |
| `ui.value.value_len` | recorded | recorded |
| `ui.value.data.text` | always null | newly added authorized input difference (after redaction); null unless the same app and focused-element generation receive a value-change notification within 3 seconds of the input; authorization is consumed on emission |
| `window.title` / `tab_title` / `url` | recorded (after redaction) | recorded |

## Schema versioning

- **Additive changes** (new fields, new types, new sources) do not bump `v`. Consumers must ignore unknown fields and skip unknown types.
- **Breaking changes** (removal, rename, meaning change) bump `v`, with a store migration to match.

## Browser URL capture

`browser.navigate` (URL capture) currently supports Chrome only. Chromium exposes each window's mode (`normal` / `incognito`) deterministically via AppleScript, so Incognito windows can be excluded from URL capture without a configuration knob.

| Browser | URL capture (current) | Private windows |
| --- | --- | --- |
| **Google Chrome** | ✅ Supported | `mode` detects Incognito deterministically → always excluded |
| Brave / Edge / Vivaldi (Chromium family) | ❌ Not supported — the collector only launches for Chrome's bundle ID | No URL capture, so no private-window handling applies |
| **Safari** | ❌ Not supported | No private-mode property; menu-diff heuristics are brittle, so not attempted |
| **Firefox** | ❌ Not supported | AppleScript has no URL API |
| **Arc** | ❌ Not supported (unverified) | Chromium-based but limited AppleScript support |

:::warning[Window titles]
What the Incognito exclusion covers is **URL capture**. Browsers without URL capture — and Chrome Incognito windows themselves — still produce `window.title` events like any other app (Accessibility source), so a private window's page title can appear in a title event. To avoid that completely, exclude the browser itself with `zanei filter exclude-app` (see [filters](/guides/filters)).
:::

Safari/Firefox support may come later if a reliable private-mode detection mechanism exists.

## Two layers of data

- **Raw event layer** — complete and machine-readable; what `query`, `record`, and `export` return.
- **Timeline layer** — human/LLM-readable; session splitting, dedup, coalescing, token-budgeted serialization. What [`timeline`](/reference/cli#timeline) and [`get_timeline`](/reference/mcp#get_timeline) return.

Timeline sessions carry `event_ids` back-references to the underlying raw events. They are included only in the JSON format (`--format json`), and are dropped when the output must be coarsened to fit the token budget.
