Event reference
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 schema is stable by design: 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):
{
"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": null,
"data": { },
"truncated": false,
"redaction": { "applied": true, "rules": ["email"] }
}
| Field | Description |
|---|---|
v |
Minimum envelope version required to interpret this event type and payload. Existing event types use 1; retained legacy content.snapshot events use 2; current content.snapshot events use 3 |
id |
ULID-based event ID, unique and time-sortable |
ts |
Wall-clock timestamp (RFC3339, millisecond precision) |
mono_ns |
Monotonic clock in nanoseconds, for 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) |
app |
App name, bundle ID, and PID. An unattributed clipboard.copy uses name: "Unknown", bundle_id: null, and pid: null |
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 |
truncated |
true when normalize removed at least one oversized text, URL, or title field |
redaction |
Which transformations were applied: configured privacy rules (email / credit_card / token) and the always-on size_limit safety rule. Secure fields leave no trace — they are excluded before capture |
Apps that do not expose an Accessibility window number, including Chromium and Electron apps, are matched to the window ID by on-screen bounds.
This envelope is also published as a machine-readable JSON Schema at /schema/event.schema.json. It is the single contract all surfaces share, and the file the Rust core’s types are tested against.
Field size limits
Zanei measures these limits in UTF-8 bytes during normalization. A value at the limit is retained; a value one byte over it is replaced with null, while other metadata such as ui.value.value_len remains unchanged. The event then has truncated: true and the always-on size_limit transformation in redaction.rules.
| Limit | Fields | Rationale |
|---|---|---|
| 32 KiB (32,768 bytes) | content.snapshot.data.text at collection time |
Bounds one Accessibility snapshot while preserving useful visible text; a snapshot stopped by this limit has data.cutoff: "bytes" |
| 64 KiB (65,536 bytes) | element.value; every data.text, including the independent safety check for content.snapshot |
Bounds per-field redaction, serialization, and in-memory batch cost. Snapshot collection stops earlier at 32 KiB, so this is only a core safety valve for that type |
| 4 KiB (4,096 bytes) | window.title; element.title; data.prev_title for window.title; data.url and data.tab_title for browser.navigate |
URLs and titles are context metadata; normal values fit comfortably while abnormal OS or application payloads are rejected |
The same limits are checked again after key-event coalescing and privacy redaction, because concatenation and replacement markers can increase the byte length. A browser event whose URL was removed cannot be evaluated against website filters, so it is discarded rather than bypassing those filters.
Event taxonomy
The current event types. The Permission column shows what must be granted for the type to be captured (see the permissions guide).
| 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 | Accessibility + Input Monitoring |
input.scroll |
macos.eventtap |
Scrolling | Accessibility + Input Monitoring |
browser.navigate |
macos.applescript |
URL / tab change. Chrome and Safari; Chrome Incognito excluded | Accessibility + Automation |
clipboard.copy / clipboard.paste |
macos.eventtap |
Clipboard use; content is opt-in | Accessibility + Input Monitoring |
content.snapshot |
macos.ax |
Accessibility text shown in the visible part of the frontmost window; separate opt-in | Accessibility |
In --types filters, a trailing wildcard selects a whole family: browser.*, ui.*, content.*, and so on.
type is a closed enum for each envelope version. Unknown types are rejected; adding an event type requires a new v and a matching store migration.
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 as per-type conditions.
| Type | window |
element |
data |
|---|---|---|---|
app.activate |
✓ / — | — | empty; window context is included when available; derive the transition from consecutive activation events |
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; it is null in Chrome Incognito and website-filtered windows. 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 (null only after size limiting), tab_title, mode ("normal" or "unknown"), transition (navigate/tab_switch/null) |
clipboard.copy |
✓ / — | — | origin (copy_shortcut/unknown), content_kind (text/image/file/other), size_bytes, text. Unknown origin has no app/window attribution or body |
clipboard.paste |
✓ | — | content_kind, size_bytes, text, and field_kind of the paste target |
content.snapshot |
✓ | — | Current v3: text (string or null after the core size safety rule), chars (pre-redaction character count after the collector limit), cutoff (time, nodes, bytes, or stopped when traversal stops early; null when it completes), and trigger (settle, refresh, or focus_out). Retained legacy v2 events instead carry complete and do not identify the cut-off reason. Source is macos.ax |
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. Text bodies require Accessibility to confirm a known non-secure input field; failure to confirm leaves input.key.text and pasted text null.
ui.value in detail
For a free-text field, data.text contains only the difference added after an authorized input, and only when text_content is enabled. A keystroke or paste that Zanei recorded (input.key, clipboard.paste) authorizes value changes in the same app and focused element for the next 3 seconds; several value-change notifications may follow one input, and all of them are covered. Input that Zanei rejected (secure input, an excluded window, an unknown or unreadable field) authorizes nothing. The field’s complete value is never stored in element.value.
Value changes are batched while typing continues and recorded after a 1-second pause or at most every 5 seconds, and when focus moves or the recorder stops. A batch can end in the middle of an IME composition, in which case part of the uncommitted reading appears in one event and the committed text in the next.
Because each event carries only the difference, a run of ui.value events does not reconstruct what was typed, and concatenating their data.text can produce wording the user never entered. An input method rewrites the tail of the value when it commits, so a later difference replaces part of an earlier one instead of continuing it. Read these events as evidence of what someone was writing about, not as a verbatim record. For the exact wording, look for a content.snapshot of the same window shortly afterwards: once the text is entered or sent, the app usually renders it in the visible part of the window, and that snapshot is the accurate thing to quote. It is available only while capture.content_snapshot is enabled and the app is inside its filter scope, a visit short enough to end before a snapshot trigger fires leaves none, and a snapshot covers only what was visible.
A value change with no authorizing input (a different app, a different focused element, more than 3 seconds after the last input, or a Chrome window whose text bodies are suppressed) is not recorded as text: data.text is null and only value_len describes the resulting value. Deletion likewise has text: null. Voice input does not itself open an authorization window because it has no recorded keystroke or paste trigger. If voice text is inserted while a 3-second window opened by a preceding recorded keystroke or paste for the same app and focused-element generation is still active, that text can be included in the authorized difference.
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 rather than content, so combo is recorded without opt-in; shortcuts such as saves, commits, and tab switches are a primary input to the timeline. input.key.text also requires inactive Secure Input, a known non-secure Accessibility field, and an eligible Chrome window when Chrome is active.
Clipboard attribution
clipboard.copy has body content only when a pasteboard change matches a Command-C observed from the same process within 500 milliseconds. Other changes remain as origin: "unknown" with an unattributed app envelope and null body. Copy and paste bodies require inactive Secure Input; paste additionally requires a known non-secure Accessibility field. Chrome Incognito and website-filtered windows keep both bodies null.
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 observation idle time or 5s maximum hold, whichever comes first | one event for the final value in each batch; text is the added difference between that value and the pre-batch 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 at the earlier of 1 second after its latest observation or 5 seconds after its first pending observation, and is also flushed on focus change and collector stop.
The SQLite writer flushes a batch at the first of the configured interval, 512 events, or 4 MiB (4,194,304 bytes) of serialized event data. The byte limit bounds transaction and retained-memory cost even when a batch contains unusually large but valid events.
events_dropped counts only events intended for recording but actually lost to backpressure, a full queue, disconnection, or a similar delivery failure. Inputs outside the recording scope because they cannot be attributed to an app or window are not counted.
Pause, stop, and handled termination signals (SIGTERM / SIGINT) drain the in-memory channel and flush coalescing state and the SQLite batch. A process crash or SIGKILL bypasses that shutdown path, so data still in those buffers is lost and is not included in events_dropped. With a healthy store, the SQLite batch portion of this loss window is bounded by the first flush condition above; during write backoff, a retained batch can remain in memory longer than batch_interval_s.
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 unless Accessibility confirms a known non-secure field and Secure Input is inactive; also null for keyboard input modes, unknown input sources, and ineligible Chrome windows |
input.key.combo (shortcuts) |
recorded | recorded |
clipboard.copy.text / size_bytes |
always null | recorded only for an authorized Command-C correlation (after redaction); unknown origin, Secure Input, and ineligible Chrome windows keep them null |
clipboard.paste.text / size_bytes |
always null | recorded only for a known non-secure target while Secure Input is inactive (after redaction); ineligible Chrome windows keep them null |
ui.value.value_len |
recorded | recorded |
ui.value.data.text |
always null | the difference added within 3 seconds after a recorded keystroke or paste in the same app and focused element (after redaction); Chrome windows with suppressed text bodies keep it null |
window.title / tab_title / url |
recorded (after redaction) | recorded |
Schema versioning
The envelope version is per event type and payload, not a global output version. The existing 13 types require v: 1; current content.snapshot events require v: 3. Retained legacy content.snapshot events with v: 2 remain valid, and query, export, and MCP query_events return them losslessly with their complete field rather than inventing a cut-off reason. A consumer that understands only v: 2 does not need to accept a v: 3 event. The envelope, event taxonomy, contexts, and per-version payloads are closed: content.snapshot with v: 1, v2 with cutoff, v3 with complete, an existing non-snapshot type with v2 or v3, unknown fields, and unknown event types are invalid. Adding a type requires a new minimum v and a matching store migration.
Store readers skip rows whose event type they do not recognize instead of failing the whole read. CLI query and export keep their JSON array shapes and warn with the skipped count on stderr; --quiet suppresses the warning. MCP query_events, timeline --format json, and structured MCP get_timeline report the count as skipped_unknown_types in their existing result objects.
Reading content snapshots
query and MCP query_events do not return content.* when no type filter is supplied. Request content.snapshot or content.* explicitly, preferably with a narrow time range and small limit. Other event families may be requested alongside it. export is a backup surface and includes every type in every format by default; use --types to narrow it.
Timeline output never includes snapshot bodies. Each session reports how many snapshots belong to it: Markdown adds Content snapshots: N after activity lines and omits the line for zero, while JSON always includes content_snapshots, including zero.
Browser URL capture
browser.navigate (URL capture) supports Chrome and Safari. The same browser capture path is used for standalone and embedded recording. Chromium exposes each window’s mode (normal / incognito) via AppleScript. Incognito windows produce no URL events, and their text-content-derived bodies remain null without a configuration knob. The shared event contract also permits mode: "unknown" for browser observations whose privacy mode cannot be determined; such observations are never treated as normal.
Navigations are observed when the tab, window, title, or page load changes. An in-page navigation that changes neither the title nor focus may not produce browser.navigate.
| Browser | URL capture (current) | Private windows |
|---|---|---|
| Google Chrome | ✅ Supported | mode suppresses Incognito URL events and text-content bodies |
| 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 | ✅ Supported | mode: "unknown"; private-window exclusion is not guaranteed |
| Firefox | ❌ Not supported | AppleScript has no URL API |
| Arc | ❌ Not supported (unverified) | Chromium-based but limited AppleScript support |
Safari URL capture and website filtering do not require an embedding-specific configuration. App and site exclusions apply to Safari, and its default text-content and snapshot exclusions remain in place. Enabling Safari content capture can include private-window text because Safari does not expose a reliable private-mode signal. URL capture requires Safari Automation permission for the recorder process. Firefox URL capture remains unsupported.
Two layers of data
- Raw event layer — complete and machine-readable; what
query,record, andexportreturn. - Timeline layer — human/LLM-readable; session splitting, dedup, coalescing, token-budgeted serialization. What
timelineandget_timelinereturn.
Timeline sessions carry event_ids back-references to the underlying raw events. JSON output includes at most 100 IDs per session and sets event_ids_truncated: true when more exist; the field is always present, including when false. If the token budget is still exceeded, all event_ids are omitted before sessions are removed.