Skip to main content
This is a contributor reference. If you just want to know what Reticle collects and how to turn it off, read Telemetry instead; that page is written for people who use Reticle, and this one is written for people who change it. Source of truth for everything below:

The shared envelope

TelemetryEventSchema wraps every event. These fields are on all sixteen kinds. Then exactly one payload block, keyed by event. The keys are feedback, command plus flags, session, project, verification, versionChange, crash, identity, connection, init, bug, outage, instrumentation.
emit() builds its wire event from an explicit allowlist of keys. A payload block that is not on that list is dropped without an error, which is how two deliberately different MCP outages once produced byte-identical events. Adding a block means adding it to the allowlist as well.

Session scope

isSessionScoped(kind) decides whether sessionId is attached. Eleven kinds are session-scoped: daemon_started, daemon_stopped, session_progress, mcp_client_connected, app_instrumented, project_profiled, verification_completed, bug_found, tool_refused, runtime_crashed, feedback_submitted. The other six are one-shot. A per-process id on reticle status is not a session: it joins to nothing and inflates every session count that runs a distinct count over the field.

reticle_installed

Wire name: reticle_installed. Emitted from: packages/server/src/telemetry/cli-telemetry.ts:50. Session-scoped: no. Payload block: none. Fires on the first ever run on a machine. Carries installSource. installSource is declared, never detected. A channel sets RETICLE_INSTALL_SOURCE on the process that runs the install and anything unrecognised reports unknown, so unknown is expected to be the largest bucket while the marker spreads across four separately published artifacts. Read a small unknown as a marker that spread, never as success. Nothing infers it: npm_config_user_agent only says npx ran us and every route goes through npx; the presence of a plugin directory or a skill folder says a route exists rather than that it ran the install. The same field rides on init_completed. The question it answers: how many machines have installed this, and what does the new-user curve look like? There is deliberately no uninstall event, because npm 7 and pnpm run no uninstall lifecycle script. Churn is inferred server-side from inactivity instead.

cli_command_run

Wire name: cli_command_run. Emitted from: packages/server/src/telemetry/cli-telemetry.ts:52. Session-scoped: no. Payload: two envelope fields. The question it answers: what do humans actually do with the CLI? verify and gate mean something very different from status, and that ratio is the closest honest read on intent available.
Flag values are never sent. A value is a port, a URL, a file path, or in --http-token’s case a secret. Names alone answer “does anybody use --storage-state” with none of that risk.
The internal _daemon spawn is excluded. reticle mcp and reticle serve start the daemon by re-running the same binary, so counting the child doubled what a person experienced as one action, and doubled it worst on exactly the agent-driven sessions that matter most.

daemon_started

Wire name: daemon_started. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:73. Session-scoped: yes. Payload block: none. The question it answers: how many active daemon runs are there? This is the numerator of daily, weekly and monthly actives, and the first step of the install funnel.

daemon_stopped

Wire name: daemon_stopped. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:120. Session-scoped: yes. Payload block: session, a SessionSummary with final: true. The rich one. One event carries the entire session rolled up, replacing the hundreds a per-tool-call event would send. PostHog bills per ingested event and a single verification loop is a great many tool calls, so aggregation here is a design decision, not an optimisation.

SessionSummarySchema

The question it answers: what did a whole session look like, and how did it end? endReason and exit answer different questions on purpose: a daemon can exit tidily on idle while the agent’s work was abandoned mid-task.

session_progress

Wire name: session_progress. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:98. Session-scoped: yes. Payload block: session, the same SessionSummary shape with final: false and no exit. A periodic roll-up from a daemon that is still running. The question it answers: what work is happening in sessions that have not ended yet?
This used to be emitted as daemon_stopped, so an event named for an exit fired while the process was alive. Count sessions with daemon_stopped. Sum work with both. The two populations are close to opposites: a daemon that served a tool call does not idle-exit, so the flushes and the clean exits describe different kinds of session.

verification_completed

Wire name: verification_completed. Emitted from: packages/server/src/tools/invoke-tool.ts:125 and packages/server/src/telemetry/run-telemetry.ts:38. Session-scoped: yes. Payload block: verification.

VerificationSchema

The question it answers: was an app actually verified, and how often did that catch something a green test would have missed? This is the product’s reason to exist expressed as an event.

project_profiled

Wire name: project_profiled. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:87. Session-scoped: yes. Payload block: project. Once per daemon start, so it is cheap.

ProjectProfileSchema

The question it answers: are they using all of Reticle or three tools of it? That is the difference between a retention problem and an activation problem, and both look identical in a DAU chart.

version_changed

Wire name: version_changed. Emitted from: packages/server/src/update/updater.ts:35. Session-scoped: no. Payload block: versionChange. The question it answers: are people upgrading, and did our nudge do anything? Without nudged, “the nudge never fired” and “the nudge fired and nobody acted” are indistinguishable, and they need opposite responses.

runtime_crashed

Wire name: runtime_crashed. Emitted from: packages/server/src/daemon/daemon-resilience.ts:155. Session-scoped: yes. Payload block: crash.

CrashSchema

The question it answers: what is crashing, where, and can we fix it without asking the user for a repro? The first version of this carried only a fingerprint, which made crashes rankable and completely undiagnosable.

feedback_submitted

Wire name: feedback_submitted. Emitted from: packages/server/src/telemetry/feedback.ts:256 and :270. Session-scoped: yes. Payload block: feedback. The one kind that carries author-written free text, which is exactly why it is never emitted passively. It exists only because an agent called reticle_feedback or a human ran reticle feedback. It has its own kill switch.

FeedbackSchema

The question it answers: what is broken, missing or awkward, in the words of the person or agent who hit it? The three agent kinds are kept distinct on purpose: a bug is our defect, a gap is a thing we cannot see at all, and an ambiguity is a verdict the agent could not act on. text and trace are the only free-text fields Reticle ever sends. They are capped and redacted client-side before the wire, and the CLI prints the payload before sending it.

identified

Wire name: identified. Emitted from: packages/server/src/telemetry/identify.ts:77. Session-scoped: no. Payload block: identity. The question it answers: who is this, when they have chosen to say? The only personal data Reticle ever transmits, and it transmits it only because a human ran reticle identify. Reticle never infers an identity from a git remote, a git config email, or anything else. That refusal is deliberate.

mcp_client_connected

Wire name: mcp_client_connected. Emitted from: packages/server/src/telemetry/mcp-connection.ts:29. Session-scoped: yes. Payload block: connection. The question it answers: is somebody actually using this, as opposed to having it installed and running? A daemon can sit up for days with no agent attached. A large daemonAgeMs on the first connect is the interesting case: Reticle was started and then sat unused, which is an onboarding failure nobody would otherwise report. Reconnect churn is visible here too, and a client reattaching every few minutes looks identical to healthy usage in every other metric.

app_instrumented

Wire name: app_instrumented. Emitted from: packages/server/src/telemetry/app-instrumented.ts:55. Session-scoped: yes. Payload block: instrumentation. The question it answers: did the second half of the install ever happen? Reticle’s install has two halves, registering the MCP server and getting the SDK into a running page, done at different times by different commands and often in different directories. Almost everyone completes the first. Fired once per daemon run, on the first connect only, so daemon_started to app_instrumented is a real rate and a reconnecting page cannot inflate it. It deliberately carries no stack and no framework: project_profiled reports both for the same daemon run, and the two join on sessionId.

mcp_connection_lost

Wire name: mcp_connection_lost. Emitted from: packages/server/src/mcp/mcp-outage.ts:53. Session-scoped: no. Payload block: outage. The question it answers: how often does a real user’s MCP server go down, and does it come back? Capped at two per proxy process: once on the first outage of a session, and once if the retry budget is spent. An event per reconnect would bill for the pathology instead of measuring it. pendingLost is the part an agent can feel. Zero means nobody noticed. Non-zero is the number of calls that came back an error, and the count worth driving down. OutageReason.OTHER exists so a new proxy reason string can arrive without raw free text reaching the wire.

init_completed

Wire name: init_completed. Emitted from: packages/server/src/telemetry/init-telemetry.ts:31. Session-scoped: no. Payload block: init. The question it answers: does the onboarding work? Before this event existed, a setup that failed on a missing dependency was indistinguishable from a user who never tried.

bug_found

Wire name: bug_found. Emitted from: packages/server/src/tools/invoke-tool.ts:147 and packages/server/src/telemetry/run-telemetry.ts:58. Session-scoped: yes. Payload block: bug. The question it answers: does Reticle work? Everything else here measures whether Reticle is used. Three properties of this schema exist to stop the number being wrong in a way that only shows up after it has been published:
  • falseGreen is defined by the presentation, not by “an assertion passed”, because the same defect arrives both ways: through reticle_assert there is a passing assertion to contradict, and through a crawl there is no assertion at all.
  • repeat separates distinct defects from instances. Count repeat: false for distinct, count everything for frequency. Scoped to the session, because the payload deliberately carries no selector or URL, so the same defect in two sessions cannot be recognised as one, and must not be.
  • attribution separates a defect in the app under test from the agent’s own bad predicate and from Reticle’s own blind spot. Only attribution: 'app' belongs in a published defect count, and app requires positive evidence: something the app itself did, taken from core’s ABSENCE_DERIVED_CONTRADICTIONS line, never “nothing else explained it”. Everything the evidence cannot settle is unclassified, which is a value rather than a gap.
The payload never carries a selector, a URL, an element, or any description of the user’s app. It reports that a class of defect was found, never what it was in.

tool_refused

Wire name: tool_refused. Emitted from: packages/server/src/tools/invoke-tool.ts, via packages/server/src/telemetry/tool-refused.ts. Session-scoped: yes. Payload block: refusal. The question it answers: why does the largest cohort in the funnel go quiet? The refusal path already computes a precise diagnosis and hands it to the agent as prose; until this event it then threw it away, so a user who hit a wall on their first call emitted nothing at all.
  • The reason is a bucket over the recovery table in error-recovery.ts, not a second list of patterns, so a recovery added without a reason does not compile.
  • retried lands on the retry, not the first refusal. Reporting it the other way round means holding the first event back until a next call reveals whether one came, which loses it entirely for the agent that gives up, the population the event exists to describe.
  • Capped at 50 per daemon run. A stuck agent is the shape that produces hundreds, and consecutiveRepeats on the session summary still reports how long the loop ran.
The message itself never leaves. It interpolates whatever the caller asked for (a baseline name, a selector, a testid), so only the tool name and the bucket are sent.

Adding a new event

Read docs/telemetry-contract.md before you touch anything that emits. The short version:
  1. Add the kind to TelemetryEventKind in packages/core/src/telemetry.ts, with a doc comment saying what question it answers.
  2. Define its payload schema in the same file, or in telemetry-session.ts if it is a rollup.
  3. Add the payload key to TelemetryEventSchema.
  4. Add it to the emitter’s key allowlist, or it will be silently dropped.
  5. Decide whether it is session-scoped and add it to SESSION_SCOPED if so.
  6. If it produces a verdict, add the tool to VERIFICATION_TOOLS. If it introduces a finding kind, add that to core’s enum and never re-list it locally.
telemetry-contract.test.ts enforces the parts that can be enforced, and pnpm test:e2e runs a spec that fires every event kind against a real capture endpoint and asserts each one lands.
Telemetry fails silently. Nothing throws, no test reddens, and the data is simply gone. An event that is never emitted looks exactly like a feature nobody uses, and you will not find out for months. That is the whole reason this contract is written down instead of remembered.

What users see

The plain-language version, and every way to switch it off.
Last modified on August 14, 2026