Every event and every property, in full. Nothing here is a summary: this is the list the code is written against, so "see exactly what is shared" can mean exactly.
Listen sends anonymous usage statistics and crash reports by default, and Settings, Privacy is where you turn it off. This file is the complete dictionary: every event, every property, every bucket boundary. An event or property that is not written down here is not merely undocumented, it is dropped before it leaves your device, by a filter compiled from the same file both apps share: Sources/ListenKit/TelemetrySchema.swift.
The policy in one sentence, borrowed from the app's own activity log: events and ids, never names, questions or transcript text.
Audio, transcripts, notes, titles, speaker or contact names, tags, dictionary terms, search text, Ask questions or answers, calendar contents, recording or CloudKit identifiers, file paths, URLs, API keys, raw error messages, and the device's name. Not as a promise of restraint: the send filter strips everything outside the tables below, and the filter's source is public.
eu.i.posthog.com (PostHog Cloud, EU region). PostHog is the processor; the project is configured to discard client IP addresses at ingestion, and the GeoIP enrichment that added a city and a postal code is turned off. Your IP is not stored. What PostHog still derives from it before discarding it, and attaches to every event, is a continent, a country, a region (a state or province), a time zone, and a latitude and longitude accurate to that region rather than to you. That is more than this file used to claim, which was a country and nothing else, and it was measured rather than assumed: the check is in .agents/notes/telemetry.md.listen telemetry prints it too, for a Mac you reach over SSH. Nothing else in this file can be checked against your own machine the way that can, and somebody with two Macs has no other way to tell which install is which, because no event carries a device name.telemetryDisabled key; see docs/MANAGED.md. A forced-off Mac never runs the migration../build.sh && ./make_app.sh run by hand: only release.sh marks a build as released, by asking make_app.sh to stamp ListenReleaseBuild into Info.plist, and Telemetry.blocked requires that key. On iOS this means a build Xcode ran directly, on the Simulator or on a real phone, debug or release configuration alike: only a copy installed through TestFlight or the App Store carries an installation receipt at all, and Telemetry.blocked requires one. Both checks step aside for LISTEN_TELEMETRY_ENDPOINT, which is how verify_telemetry.sh and manual testing point telemetry at a chosen host on purpose; neither can be talked into reaching the real one.| Property | Values |
|---|---|
platform | mac or iphone |
app_build | the app version, e.g. 0.16.0 |
os_major | the OS major version, e.g. 26 |
install_age_bucket | day_0, week_1, month_1, month_2_3, over_3_months, measured from the day you opted in |
acquisition_channel | only if you answered "How did you hear about Listen?": github, homebrew, app_store, search, reddit, hacker_news, youtube_podcast, friend, other |
schema_version | this dictionary's version, currently 3 |
The SDK also stamps its own context: OS name and version, app version and build, and SDK name and version. Nothing else of its automatic context survives the filter, and $device_name is stripped explicitly.
internal is present, and true, only on installs belonging to whoever builds Listen, set by a preference that is deliberately not in Settings. It exists so those rows can be excluded from every chart without naming an install ID that goes stale the moment telemetry is switched off and on again.
installation_activatedOnce, at the moment consent first becomes yes. activation is new_install if this copy had never finished setup when telemetry came on, and existing otherwise. It exists because the one-time default-on migration fires this event for everybody who upgrades, so without it the count is a floor on the installed base rather than a count of new users, and install_age_bucket cannot tell them apart either, being measured from the opt-in day.
setup_completedOnce at the end of setup, and once if setup is closed before the end. outcome is finished or dismissed; the other properties describe whatever had been chosen by that point, so an abandoned run says how far it got. mic_granted (bool), model (model id or none), dictation_on (bool), sync_on (bool), calendar_on (bool), memory_on (bool, whether people are remembered automatically). Choices, never contents. Re-running setup from Settings sends it again, so this is not once per install.
recording_completedWhen a capture made on that device lands in its library. Only the device that made a recording counts it; one arriving over sync is never counted again.
| Property | Values |
|---|---|
kind | meeting, memo, phone_memo, import, cli |
source_app | zoom, teams, slack, facetime, discord, whatsapp, telegram, signal, webex, browser, other, none. Mapped from the call's app bundle id through a fixed table; anything unrecognised is other, never the raw id |
duration_bucket | under_1_min, 1_5_min, 5_15_min, 15_30_min, 30_60_min, 1_2_h, over_2_h |
recording_transcribedWhen a transcription run ends, on the Mac that ran it, whether it worked. Fires for phone memos too, on the transcribing Mac. Transcription failures ride this event's outcome and are deliberately not doubled into operation_failed.
| Property | Values |
|---|---|
outcome | ok, or a stable code: transcription.asr_failed, transcription.pipeline_failed, transcription.diarization_failed, network.failed, unknown |
asr_model | the id of the model the run actually used. It is what ran, not what the recording or the app would choose now |
duration_bucket | as above |
processing_bucket | transcription time as a fraction of the audio's length: under_0_1x, 0_1_to_0_25x, 0_25_to_0_5x, 0_5_to_1x, over_1x |
speaker_count | distinct diarized voices, capped at 12 |
track_layout | mic_only or mic_and_system |
kind | as above |
dictation_completedWhen a dictation lands. duration_bucket (under_5_s, 5_15_s, 15_30_s, 30_60_s, over_1_min), word_count_bucket (1_5, 6_20, 21_50, 51_100, over_100), engine (parakeet or apple). Never the words.
ask_completedOne event after one Ask question succeeds or fails. It never contains the question, answer, transcript, title, source ids, quotes, or a conversation/run identifier. The event is deliberately a performance summary rather than a trace: exact timings and fixed tool names stay in the device's unified log.
| Property | Values |
|---|---|
outcome | ok, timeout, offline, provider_error, ungrounded, invalid_evidence, too_many_rounds |
backend | openrouter, claude_code, codex, local_endpoint, remote_endpoint |
model | a model id the selected backend advertised, default, or custom. User-authored custom model text is never sent |
scope | library, recording, person, note |
latency_bucket | under_2_s, 2_5_s, 5_15_s, 15_30_s, 30_60_s, 60_90_s, over_90_s, or unknown |
round_count | provider rounds, capped at 24; absent when a CLI harness does not expose it |
retry_count | provider retries, capped at 24; absent when the harness does not expose it |
tool_call_count | tool calls, capped at 24 |
local_read_count | local library reads, capped at 24; currently available on iPhone |
request_size_bucket | under_16_kb, 16_32_kb, 32_64_kb, 64_128_kb, over_128_kb, or unknown |
prompt_tokens_bucket, completion_tokens_bucket | under_1k, 1_4k, 4_16k, 16_64k, over_64k, or unknown |
cost_bucket | under_0_001_usd, 0_001_0_005_usd, 0_005_0_02_usd, 0_02_0_10_usd, over_0_10_usd, or unknown |
reference_count_bucket | 0, 1, 2_4, 5_plus, or unknown |
zdr | bool; present for OpenRouter, whose requests require Zero Data Retention |
feature_usedA closed list, and the fact only: note_saved (never the note), sync_enabled, dictation_enabled, calendar_connected, share_export, import, iphone_capture, keep_audio_toggle, recognise_voices_toggle, speaker_named_on_phone, galaxy_opened.
recognise_voices_toggle is the iPhone's voice bank being switched on or off. speaker_named_on_phone is somebody saying who a voice is. Both are the fact and nothing else: no name, no voiceprint, no recording id, and no count of who the bank holds. The second is the event in this list that would most obviously carry a person's name, so it is worth saying twice that it does not.
galaxy_opened is the picture of the library being opened, and it is the fact alone: not how many stars were drawn, which would be a library size, and not what any of them are. It is here because the galaxy is one row in one menu, and a feature nobody finds looks exactly like a feature nobody wants.
operation_failedsubsystem (capture, model_download, sync, dictation, library, ui), code (a fixed identifier such as model_download.failed, sync.pass_failed, sync.pass_timeout, or ui.main_thread_stall.<bucket> .<phase> when the window stopped responding for more than two seconds), retryable (bool). Error text never travels: a raw error message routinely contains file names, and file names are content. Sync failures are edge-triggered, one event when a failure starts rather than one per retry.
audio_transferSent by the iPhone, once per attempt to send a recording's audio that actually began sending bytes. outcome (completed, interrupted, failed), bytes_bucket, duration_bucket, progress_bucket (how far an interrupted one got: under_10, 10_50, 50_90, 90_100, complete), retry (bool, whether this recording had been offered before), foreground (bool, whether the app was on screen).
This event exists because a 45-minute memo once sat at 9% for a day and nothing anywhere recorded that a transfer had begun, how big it was, or where it stopped. No id, no title, no duration of the recording itself: how many bytes in what shape of bucket, and where it got to.
audio_receivedSent by the Mac that takes a phone recording's audio out of iCloud. bytes_bucket, duration_bucket, wait_bucket (how long between the recording being made and a Mac having it). The far end of audio_transfer, and the pair is the only way to see whether recordings cross at all: the two devices are two installs and nothing joins them, so the question is answered by comparing counts rather than by following one recording.
recording_stalledOne event the first time a recording has been waiting more than six hours with nothing happening to it. stage (awaiting_upload, awaiting_transcript, awaiting_audio), source (iphone, mac, ...), age_bucket, holder (the kind of device that has the audio: iphone, mac, or nobody; never which one). Edge-triggered per recording, so a library having a bad day says so once rather than every two minutes.
sync_passoutcome (ok, failed, timeout, throttled), duration_bucket, moved (bool, whether anything actually changed), and reason on the outcomes that are not ok: a closed word such as quota, signed_out, offline, not_permitted, busy, too_old, conflict, missing, store or other. The reason is mapped from the error code and never from the error's text, because CloudKit's own wording varies by language and can contain identifiers.
Sent on a change of outcome and otherwise at most twice an hour. The poll runs every two minutes, so an event per pass would be 720 a day per install.
app_launchedlaunch_bucket (how long until there was a window), library_bucket (how many recordings, in a bucket), pending (how many are waiting to be transcribed, capped at 24). Sent immediately rather than batched, which also makes it the signal that an install is alive at all.
$exceptionA crash report: stack trace, app build, OS version. Captured by the PostHog SDK, only while turned on, so a crash before that is never reported. The $device_name the SDK would attach is stripped.
Dashboards that sum hours use the midpoint of each duration bucket: 0.5, 3, 10, 22.5, 45, 90 and 150 minutes respectively. Buckets are never re-cut silently; a change bumps schema_version and is noted here.
Event data is kept indefinitely. This file used to say twelve months, and that was never true: PostHog has no time-based event deletion to switch on, on any plan or through any API, and the feature request for it has been open since 2023. A promise nothing enforces is worth less than an accurate sentence, so this is the accurate one. If PostHog ships retention, twelve months is the setting to choose and this paragraph goes back to promising it.
The identity is the part that does get deleted, on your instruction and at once: turning telemetry off deletes the install ID, so the events that remain are no longer connected to each other or to a later you.
Anything published from this data (a "users transcribed X hours" style statistic) is aggregate only, says it comes from installs with telemetry on, and uses no cohort smaller than 50 installs.
Sources/ListenKit/TelemetrySchema.swift, and both apps compile it in.verify_telemetry.sh in this repository launches the built app against a local listener and asserts that a fresh install migrates itself on with no question asked, that the migration overrides even a prior no, that a no recorded after migration produces zero requests, and that off-schema events and properties never arrive.InternetAccessPolicy.plist declares the PostHog host to firewalls such as Little Snitch, with what blocking it costs: nothing but the statistics.