Skip to main content
This page is for contributors, not users. It documents the internal event schema so that somebody changing Reticle’s code emits the right shape. It contains no data and no measurements.If you want to know what Reticle collects about you and how to turn it off, read Telemetry instead. That is the user-facing page, and it is the one with the opt-out.
Every event Reticle emits is listed below with the same three things: when it fires, what it carries, and why it exists. The authoritative list is TelemetryEventKind in core/src/telemetry.ts; if this page and that file disagree, the file wins and this page is a bug. Source of truth for everything below:

The shared envelope

TelemetryEventSchema wraps every event. These fields are on every event kind, whatever it is. Then exactly one payload block, keyed by event. The keys are feedback, command plus flags, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, stall. Plus installSource, which is not a block. It is resolved once per process and stamped on every event, so it is the one attribution field you can group any chart by.
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. Twelve 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. mcp_connection_lost is deliberately not on that list even though it feels session-shaped. It fires from the proxy process rather than the daemon, so it carries no sessionId and cannot be joined to a session at all. 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: server/src/telemetry/cli-telemetry.ts:51. 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. installSource is not limited to this event. It is resolved once per process (server/src/telemetry/telemetry.ts:392) and stamped on every event that process sends (:453), so any chart can group by it. It is also sourced config first, then environment: projectInstallSource() prefers the value recorded in .reticle.json, because the environment variable exists for exactly one command and is gone by the next, which is why the field originally reached a handful of events and nothing else. 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: server/src/telemetry/cli-telemetry.ts:58. 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: server/src/telemetry/daemon-telemetry.ts:75. 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: server/src/telemetry/daemon-telemetry.ts:138. 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: server/src/telemetry/daemon-telemetry.ts:116. 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: server/src/tools/invoke-tool.ts:144 and server/src/telemetry/run-telemetry.ts:39. 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: server/src/telemetry/daemon-telemetry.ts:91. 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: 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: 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: 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: 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: 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: 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: 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: server/src/telemetry/init-telemetry.ts:32. 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: server/src/tools/invoke-tool.ts:166 and server/src/telemetry/run-telemetry.ts:59. 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: server/src/telemetry/tool-refused.ts:49, reached from four call sites in server/src/tools/invoke-tool.ts (:285 a handler threw, :325 the wrong-tab guard, :346 a message-shaped refusal, :376 the {error} return convention). 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.

onboarding_step

Wire name: onboarding_step. Emitted from: server/src/telemetry/onboarding-funnel.ts:22, reached from the CLI (command/cli-onboarding.ts), the installer (command/setup/setup-install.ts, command/setup/setup-mcp.ts), init (init/src/host.ts through the injected InitHost) and the tool dispatcher (surface/tools/invoke-tool.ts). Session-scoped: no. Payload block: onboarding. The question it answers: where do people stop, between never having heard of Reticle and having a verdict on their own app. No other event can answer it. init_completed fires when the files are written, and the story continues for minutes after that; app_instrumented sees one moment of it. This is the only event that spans the whole route. The steps are the funnel, in order, and they live in one table in core/src/onboarding.ts: first_run.verdict_produced is the conversion event. Everything before it is setup that proved nothing. Four decisions in this shape are worth knowing before you add to it:
  • One kind for every step, not a kind per step. A phase plus a step keeps a funnel a GROUP BY instead of a union, so inserting or renaming a step does not silently break the queries watching it, and a new step appears in the existing chart rather than beside it.
  • step is a closed z.enum, not a capped string. It was z.string().max(48), with a comment promising it was never user text, and a cap is not a promise. /Users/someone/secret/project is 28 characters and would have validated cleanly, out of the one payload a person can edit: the installer’s breadcrumb file, in their own home directory. The enum is derived from OnboardingSteps, so a step added there is accepted on the same edit and a renamed step cannot arrive under both spellings.
  • abandoned is a status of its own. A step that is started and never resolved is indistinguishable from one that was never reached, and both read as “we never got there”. Somebody walking away from a step we know they began is a different fact from the step failing, and only one of those is our bug. reportStepTimed’s finally is what makes it reliable: an exception escaping without a report turns a failure into silence.
  • skipped is neither a loss nor a win. Already installed, already instrumented. Counting it as either makes a re-run look like a conversion or like a drop.
A malformed step is dropped, not sent and not thrown. Sending it would put unvalidated text on the wire; throwing would let a metric change what a user’s install does.

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 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 VERDICT_TOOLS (server/src/tools/feedback-tools.ts). 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 September 23, 2026