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 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):
{
"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) |
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 — 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).
| 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 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 |
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, 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. They are included only in the JSON format (--format json), and are dropped when the output must be coarsened to fit the token budget.