Skip to content
Zanei
English
Esc
↑↓navigate↵open⌘Jpreview
On this page

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, and export return.
  • Timeline layer — human/LLM-readable; session splitting, dedup, coalescing, token-budgeted serialization. What timeline and get_timeline return.

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.

Was this page helpful?