npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ascenda-one/claude-code-hooks

v0.1.42

Published

Claude Code hooks adapter for Ascenda AI workload telemetry.

Readme

Ascenda Claude Code Hooks

Claude Code hooks adapter for Ascenda AI workload telemetry.

Part of ai-engineer-tools. For what these measurements do and do not establish, see What this measures. Event mapping: docs/CLAUDE_MAPPING.md.

What it sends

Metadata only. It doesn't send prompts, responses, code, file names, repository names, branch names or terminal output. Privacy defaults has the detail, and Build from source shows how to read what runs on your machine.

| Signal | What the hook records | | --- | --- | | Agent interaction | That a prompt, correction loop or compaction happened, and for each tool call the tool's name (Bash, Edit) | | Long agent runs | That a loop ran long (agent_loop_long) | | Friction | Tool failures and context pressure | | Verification | A test, lint or build command ran (editor_verification_activity / compile_error) |

Architecture

Claude Code hook adapter
  -> toolInstallationId + eventWriteToken
  -> Ascenda backend (POST /v1/tool-events)
  -> paired pseudonymous Ascenda user
  -> workload aggregation + baseline comparison
  -> app notification / dashboard

Same loose-coupled pairing model as the VS Code and Cursor extensions. See TOOL_PAIRING_API_REFERENCE.md.

Install

Recommended: the Claude Code plugin

One command installs this adapter, the work-signals skill, and the MCP server together — already wired to every lifecycle event, no settings file to edit:

claude plugin marketplace add ascendaone-com/ai-engineer-tools
claude plugin install ascenda@ascenda-one

From inside a session, use /plugin marketplace add … then /plugin install …. See ascenda-agent-skills for what the bundle holds.

Alternative: this adapter on its own

If you want the deterministic hooks without the skill or MCP server, the published package runs with no clone and no build:

npx @ascenda-one/claude-code-hooks setup

This pairs (printing a 6-digit code to confirm in the Ascenda app), installs a self-contained hook bundle to ~/.ascenda/bin, writes ~/.ascenda/credentials.json, and registers the hooks in ~/.claude/settings.json, so they load in every project. Restart Claude Code and events flow — no shell exports, no settings file to hand-edit.

npx @ascenda-one/claude-code-hooks status      # exits non-zero if anything is unwired
npx @ascenda-one/claude-code-hooks uninstall   # removes hooks and the binary

| Option | | | --- | --- | | --api-base-url <url> | ingest host (default https://api.ascenda.one) | | --local [port] | shorthand for a local dev server (default 4477) | | --tool-installation-id <id> / --token <t> | reuse an existing pairing instead of creating one | | --no-pair | install the local half only: hooks work, nothing is sent | | --no-repo-keys | GitHub repositories get a repoKey and, off the default branch, a branchKey, so a pull request can be matched to a session. They are unsalted digests, so anyone who guesses the names can recompute them. This turns both off, machine-wide. --repo-keys turns them back on | | --event-log [path\|off] | keep every event in a local JSONL file (default ~/.ascenda/events.jsonl). On by default without a pairing; off stops it | | --scope user\|project | register in ~/.claude/settings.json (default, every project) or in this project's .claude/settings.local.json | | --project-dir <path> | project root for --scope project (default cwd) | | --dry-run | print what would change, write nothing |

From a clone, ./scripts/setup-local.sh builds the workspace, starts the dev server detached, and runs the same setup --local — no backend, no phone, no DevAuth. Stop it with ./scripts/setup-local.sh --stop. See TESTING.md.

What setup writes

| Path | | | --- | --- | | ~/.ascenda/bin/ascenda-claude-hook | the launcher every hook command names: a small sh script that finds Node and runs the bundle | | ~/.ascenda/bin/ascenda-claude-hook.mjs | the self-contained bundle — no npm -g, no sudo, no PATH edit | | ~/.ascenda/bin/ascenda-claude-hook.node | the Node the launcher last found, starting with the one that ran setup | | ~/.ascenda/credentials.json | apiBaseUrl + toolInstallationId, 0600 (plus localOnly on an install with no pairing) | | ~/.ascenda/tokens/<id> | the event write token, 0600, rotated in place on renew | | ~/.claude/settings.json | one hook entry per lifecycle event, timeout: 5 (.claude/settings.local.json under --scope project) |

Registration is idempotent and marker-keyed: re-running replaces our entries rather than appending, hooks belonging to anyone else are left alone, and the file is backed up to .ascenda-backup before the first write. Settings that are not valid JSON abort the run rather than being overwritten.

With CLAUDE_CONFIG_DIR set, user-scope setup, status and uninstall use $CLAUDE_CONFIG_DIR/settings.json. If another tool has added its own hook to one of our groups, setup and uninstall take out only ours and leave theirs. A symlinked settings file stays a symlink.

Upgrading

Re-run setup with the scope you installed with. It keeps your pairing, adds the hooks this version registers, and prints what it added:

  hooks        ~/.claude/settings.json (13 events)
               upgraded: added PermissionRequest, PermissionDenied; hook set 3 → 4

status prints the installed hook set and, when it's older, the command to run. The Flow app on your Mac shows one line in Connections → Ingest telemetry when an agent's signals come from an older set. Restart Claude Code afterwards: a session keeps the hooks it started with.

If you use the plugin, run claude plugin update ascenda@ascenda-one instead. Its hooks.json registers the same set. Having both the plugin and a setup install runs every hook twice. setup moves its own entries out of the other scope's file (and out of ~/.claude/settings.local.json, where a project install run from the home folder used to put them, loading only when Claude Code started in ~), but it can't remove the plugin's.

Hooks then need no environment at all. The command names the launcher, and the launcher finds Node: the one that ran setup while it's still there, and otherwise the first Node 20 or newer on PATH, in Homebrew, volta, nvm's default alias, fnm, asdf or mise, which it records for next time. So a hook keeps working when Claude Code is opened from the Dock or an IDE, and after a version manager removes the Node that ran setup. If there is no Node at all, SessionStart says so once and the other hooks stay quiet. Configuration comes from the credentials file; the variables below still override it when set.

status also flags hook entries pointing at a binary that no longer exists — those fail silently on every event otherwise — and names the Node the hooks will run and the last hook Claude Code ran. It checks one scope, defaulting to user like setup; if it finds nothing there it looks in the other one and says where the hooks actually are.

Installing without a pairing

--no-pair installs the hooks and stops before pairing. A setup that tried to pair and couldn't finish — the backend unreachable, nobody at the keyboard to confirm the code, the session expired — lands in the same place instead of failing. The install completes either way, and the summary says which it was. Asked for, it reads as finished; a pairing that didn't finish warns, and offers the retry:

setup --no-pair finishing with "Ready."

Three things work with no pairing at all, which is why this mode exists. The session prompts are composed locally in the hook process. The live signal goes to a socket on your own machine, so a listener there sees an agent working the moment it starts. And every event is written to ~/.ascenda/events.jsonl, the same metadata a paired install would send, for you or any other tool to read. examples/local-event-reader is a small script that does. None of it owes anything to a backend.

What that install holds: the hook binary, the registered events, and a credentials file with an installation id, localOnly: true and installedAt. No pairedAt and no token — nothing here fabricates a credential it doesn't have. The id is the part that matters later. pair claims that same id, so the hooks already registered in your settings file end up paired; the flag is dropped and pairedAt takes its place.

Delivery is a deliberate no-op until then. Nothing is sent, nothing is queued, and the send journal stays empty rather than recording an outage on every hook event. An absent token on an install that was paired is a different matter: that's a revoked or deleted credential, and it goes on being reported. status and doctor both distinguish the two:

pairing        claude_code:… (not paired, installed 2026-09-23T05:13:40.907Z)
token          — none until this install is paired
delivery       inactive — nothing is sent, and nothing is queued for later
local features active — the session prompts and the live socket signal need no pairing
hooks          11/11 registered in ~/.claude/settings.json

uninstall removes an unpaired install the same way, and says there is no token to revoke. It leaves any other agent's pairing in the credentials file where it is.

Pairing by hand (if you skip setup)

setup pairs for you. To pair this machine directly instead:

npx -y @ascenda-one/claude-code-hooks pair

It prints a 6-digit code — confirm it in the Ascenda app under Connections → Ingest telemetry — then saves the write token to ~/.ascenda/tokens/ and the pairing to ~/.ascenda/credentials.json. Restart Claude Code; there is nothing to add to a shell profile. The token itself is never copied around — every CLI tool reads it from the file pair wrote. (The editor extension's pairing cannot be reused here: its token lives in the editor's private secret storage.)

Run on a machine setup has already touched, pair attaches to the installation that file names instead of minting a second one, and pairs against the host recorded there. That's what finishes a --no-pair install.

--tool-type <type> pairs something else CLI-shaped that has no setup of its own. An explicit type always mints its own id: naming a different tool is pairing a second one, not re-pairing this one, and reusing the id there would file the new tool's work under the old tool's identity.

How a hook names itself, in order: ASCENDA_TOOL_INSTALLATION_ID if it is set, then ~/.ascenda/credentials.json, then the token store — where exactly one claude_code token means its filename is the id. With none, or several, every hook invocation records a skipped_no_installation_id attempt in the journal and exits saying so, rather than silently minting a second, unpaired identity that would fragment your telemetry across two installations.

The environment variable is per machine, while the credentials file is per tool — so exporting one id on a machine running two agents sends both agents' work under it. Prefer the file, which both pair and setup write.

On a Dev backend with no phone, pairing-sim stands in for the app:

cd ../ascenda-pairing-sim && npm run build
node dist/cli.js e2e --tool-type claude_code

Optional environment:

| Variable | Purpose | | --- | --- | | ASCENDA_API_BASE_URL | Backend to send to. Defaults to https://api.ascenda.one; use http://localhost:5002 or the Azure Dev host for development | | ASCENDA_EVENT_WRITE_TOKEN | Only if you have no prior pairing to reuse — normally the token file supplies this | | ASCENDA_EVENT_WRITE_TOKEN_FILE | Override token file path (default ~/.ascenda/tokens/<toolInstallationId>) | | ASCENDA_SESSION_ID | Stable session id across hooks | | ASCENDA_REPO_KEYS | off leaves the repository and branch keys off every event. Wins over setup --no-repo-keys | | ASCENDA_EVENT_LOG_FILE | Local JSONL event log path, or off. Wins over setup --event-log. Without either, an unpaired install writes ~/.ascenda/events.jsonl and a paired one writes nothing | | ASCENDA_WORKSPACE_HASH | Override only. By default the hook derives this from the payload's own cwd: a machine-salted hash of the checkout folder's basename (never the path itself) | | ASCENDA_PROJECT_HASH | Override only. Defaults to the salted hash of the canonical repository's basename — a git worktree folds into the repo it was created from | | ASCENDA_STATE_FILE | Override the send journal path (default ~/.ascenda/state/<toolInstallationId>.json) | | ASCENDA_OUTBOX_FILE | Override the outbox path (default ~/.ascenda/state/<toolInstallationId>.outbox.jsonl) | | ASCENDA_HOOK_DELIVERY | inline makes each hook send its own events and wait for the answer. By default a background sender does it; see "How events leave the machine" | | ASCENDA_OUTBOX_DRAIN | 0 holds the outbox without sending it, and hooks send their own events inline. On by default | | ASCENDA_DISABLE_FAILURE_NOTICE | true silences the one-time in-session notice about a collector that has stopped delivering |

On first run the CLI seeds ~/.ascenda/tokens/<toolInstallationId> so tool-scoped renew can persist rotated tokens without you editing anything.

Cloud sessions

A hosted Claude Code session (Claude Code on the web, or one it spawns) runs in a fresh container every time. Its transcript never reaches your machine, so a history import can't see it and these hooks are its only record. Every event carries metadata.runtime: cloud when Claude Code sets CLAUDE_CODE_REMOTE, local otherwise.

Nothing under ~/.ascenda survives a session there, so everything the hooks need comes from the environment's settings:

| Variable | Why | | --- | --- | | ASCENDA_TOOL_INSTALLATION_ID | The installation to send as. Pair one just for cloud sessions (run pair inside one); don't reuse your laptop's pairing, which re-pairing would rotate. | | ASCENDA_EVENT_WRITE_TOKEN | Its write token. | | the salt, ~/.ascenda/salt | Without it every session mints a new one and one repository turns into a new project hash every time. Have whatever launches the hooks write a fixed value there first. Your laptop's value makes cloud and local hashes match. |

The environment's network access must also allow the Ascenda API host.

Tokens never rotate in a cloud session. Renewal revokes the previous token, and the replacement would die with the container, taking the environment's copy down for every later session. So the hooks record a rejected token as a failed send and stop there. Tokens last 30 days. On a laptop the hooks renew theirs well before then, but a cloud session can't, so pair again before the 30 days are up and replace the token in the environment.

Register hooks manually

The plugin and setup both do this for you. Do it by hand only to put the hooks in a file setup doesn't write, such as a project's shared .claude/settings.json.

The commands run the launcher setup installs in ~/.ascenda/bin, so run setup first. Then move its entries into the file you want, using examples/settings.local.json:

{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" SessionStart --hook-set 4", "timeout": 5 }] }],
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" UserPromptSubmit --hook-set 4", "timeout": 5 }] }],
    "PreToolUse": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PreToolUse --hook-set 4", "timeout": 5 }] }],
    "PostToolUse": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PostToolUse --hook-set 4", "timeout": 5 }] }],
    "PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PostToolUseFailure --hook-set 4", "timeout": 5 }] }],
    "PreCompact": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PreCompact --hook-set 4", "timeout": 5 }] }],
    "PostCompact": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PostCompact --hook-set 4", "timeout": 5 }] }],
    "Stop": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" Stop --hook-set 4", "timeout": 5 }] }],
    "StopFailure": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" StopFailure --hook-set 4", "timeout": 5 }] }],
    "Notification": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" Notification --hook-set 4", "timeout": 5 }] }],
    "SessionEnd": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" SessionEnd --hook-set 4", "timeout": 5 }] }],
    "SubagentStart": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" SubagentStart --hook-set 4", "timeout": 5 }] }],
    "SubagentStop": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" SubagentStop --hook-set 4", "timeout": 5 }] }],
    "PermissionRequest": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PermissionRequest --hook-set 4", "timeout": 5 }] }],
    "PermissionDenied": [{ "hooks": [{ "type": "command", "command": "\"$HOME/.ascenda/bin/ascenda-claude-hook\" PermissionDenied --hook-set 4", "timeout": 5 }] }]
  }
}

Keep them in one file: two copies run every event twice. Delete setup's entries from the file it wrote. status and uninstall only read your user settings and a project's settings.local.json, so they won't see a copy in .claude/settings.json. setup --dry-run prints the exact entries for this machine. On Windows there's no launcher, so copy those instead: they name Node and the bundle.

Keep the timeouts. Without one Claude Code gives a hook up to 600s, and all SessionEnd hooks share 1.5s between them.

Verify

npx -y @ascenda-one/claude-code-hooks doctor

doctor starts with the local half, which needs no pairing: the Node the hooks will run (found with a bare PATH, the way a Dock-launched Claude Code starts them), which settings files register them and whether those load in every project, the last hook Claude Code ran, who is listening on the live socket (the desktop app, or the Waterline screen saver and whether it is the one on screen), and a ping that the saver answers in its heartbeat. The System Settings preview never listens, so testing with it shows resting water; doctor says so.

Then it prints the installation id and where it came from (environment, credentials file, or the token store on disk), the token's presence and age, the last recorded send outcome, any sends skipped for want of an installation id, and the result of a live round trip against the real ingest endpoint. It is the first thing to run when the Ascenda app shows a connected tool that is not producing data.

A telemetry failure never blocks your turn: every hook invocation exits 0, including one that failed to send. Do not read the exit code — or stderr, which Claude Code discards for a hook that exits 0 — as evidence of anything. Read the journal instead.

When the collector stops sending

Every send attempt — success included — is recorded to ~/.ascenda/state/<toolInstallationId>.json:

{
  "lastAttemptAt": "2026-08-17T08:05:17.436Z",
  "lastSuccessAt": "2026-08-17T08:05:04.782Z",
  "lastOutcome": "auth_failed",
  "consecutiveFailures": 1,
  "httpStatus": 401,
  "errorCode": "invalid_token",
  "failingSince": "2026-08-17T08:05:17.436Z"
}

Successes are recorded deliberately. If only failures were written, an absent journal would mean both "healthy" and "never ran" — and telling those apart is the entire problem. A lastAttemptAt that is minutes old with "lastOutcome": "accepted" is positive evidence of health; one that is hours stale means the collector is not running at all, which is a different fault with a different fix.

When an installation transitions into a failing state, the next SessionStart or PostToolUse adds one line of context to the session naming the cause and offering doctor and pair. It appears once per outage, not once per tool call, and never repeats until the collector has recovered and failed again.

lastSeenAt in the Ascenda app cannot substitute for any of this: it cannot distinguish a dead token from a night's sleep, because both are "no events". Only the collector knows it tried and was refused.

How events leave the machine

A hook doesn't send anything itself. It appends its events to an outbox next to the journal, starts a background sender, and hands control back to Claude Code in a few milliseconds:

~/.ascenda/state/<toolInstallationId>.outbox.jsonl

Each line is one event, exactly as it goes on the wire, plus the time it was queued. The background sender is the same CLI run with __flush-outbox. It sends the outbox oldest first, up to 100 events per request, until it's empty, and exits. One sender runs at a time: a lock file next to the outbox (.flushing) tells the next hook that one is already working.

So the network never sits between you and your agent. A slow connection, a captive portal or an outage costs delay, not a stalled tool call. Each event's time and idempotencyKey are set when the hook fires, so an event sent a few seconds later keeps its time, and one sent twice comes back duplicate.

An entry is deleted when the server answers accepted or duplicate. If the server can't be reached (a timeout, a 429 or 5xx), the sender stops with everything still on disk and journals the failure, and the next hook starts another. A refusal with a verdict (400, 401, 403) isn't kept: replaying it can't change the answer.

Two settings send from the hook itself, as earlier versions did:

  • ASCENDA_HOOK_DELIVERY=inline. The hook sends each event and waits for the answer. A failed send is retried once after 250 ms and then queued.
  • ASCENDA_OUTBOX_DRAIN=0, which holds the outbox without sending it. The hook still sends its own events inline, so nothing stops arriving.

A hosted session always sends inline. Its container goes when the session does, and an outbox in it would go too.

The outbox is bounded: 10,000 entries and 7 days. When a bound evicts, the discard is recorded in the journal as "lastOutcome": "outbox_discarded" with a cumulative outboxDiscarded block: how many, when, why, and how far back the loss reaches. It survives every later success, because a gap that has since closed is still a gap in the data.

doctor reads all of this:

  Outbox                ~/.ascenda/state/claude_code_….outbox.jsonl
  Outbox depth          37 waiting — oldest queued 2026-09-03T01:12:08.114Z (3h 41m ago)
  Outbox drain          on (queued events are replayed, oldest first) — ASCENDA_OUTBOX_DRAIN
  Delivery              background sender (the hook queues and returns)
  Outbox discarded      12 total; last 12 at 2026-09-02T… (12 age, reaching back to 2026-08-25T…)

A non-empty outbox is the honest health answer. consecutiveFailures resets on the first success after an outage; the outbox says events from that outage are still on this machine.

Hooks overlap, since Claude Code fires PreToolUse and PostToolUse from separate processes. Appends use O_APPEND (whole lines, never fragments), and a sender takes the file by renaming it out from under concurrent appenders. An entry is never deleted before the server has confirmed it. A sender that dies partway leaves a claim file that the next one sweeps back in.

Build from source

Not needed to use this — both routes above ship prebuilt. It is here because "read what runs on your machine" is a fair thing to want from a telemetry tool.

# from the repo root (workspace install + shared packages first)
npm install
npm run build:shared

cd ascenda-claude-code-hooks
npm run build
npm link                 # or ./scripts/install-local.sh

which ascenda-claude-hook
ascenda-claude-hook      # prints usage when passed no hook name

npm run test:sample      # pipes a sample PostToolUse payload through the CLI
npm run test:compact

A local build exposes the binary as ascenda-claude-hook — substitute it for npx -y @ascenda-one/claude-code-hooks in the hook config above. If it is not on Claude Code's PATH, use an absolute path such as /Users/<you>/.nvm/versions/node/<ver>/bin/ascenda-claude-hook.

Supported Claude hook events

SessionStart
UserPromptSubmit
PreToolUse
PostToolUse
PostToolUseFailure
PreCompact
PostCompact
Stop
StopFailure
Notification
SessionEnd
SubagentStart
SubagentStop
PermissionRequest
PermissionDenied

Ascenda event mappings

Full mapping: docs/CLAUDE_MAPPING.md. Catalog-only event types (no aliases).

SessionStart       -> create_focus_session (startup and resume; clear and compact are skipped)
UserPromptSubmit   -> ai_prompt_submitted / ai_correction_prompt
PreToolUse         -> ai_tool_call_started
PostToolUse Edit   -> ai_file_edit
PostToolUse Write  -> ai_file_write
PostToolUse Bash   -> editor_verification_activity / ai_tool_call_completed (success only)
PostToolUseFailure -> compile_error / ai_tool_call_failed
PreCompact         -> context_compression_manual / context_compression_auto
PostCompact        -> context_pressure_high
Stop               -> ai_turn_completed (every turn), agent_loop_long (long only)
StopFailure        -> nothing sent (live signal only)
Notification       -> supervision_interruption (interruptionKind only, never the message)
SessionEnd         -> recovery_offline_period (activity session_ended, sessionEndReason)
SubagentStart      -> subagent_started (subagentId, subagentClass)
SubagentStop       -> subagent_stopped (subagentId, subagentClass)
PermissionRequest  -> permission_requested (toolName, autonomyMode)
PermissionDenied   -> permission_denied (toolName, autonomyMode), auto mode only

Privacy defaults

Metadata-only telemetry. Does not send raw prompts, responses, code, file names, repository names, branch names, or terminal output.

Pairing sends the tool's name (Claude Code) and a random installation id, and nothing about the machine or the person: no hostname, no username, no account name. The installation id is what tells two installs apart.

When the agent stops and waits for you, Notification records only which kind of wait it was — permission_request, input_request, idle_prompt or other. Claude Code's notification_type picks the label, and the notification's own wording does it on a version that doesn't send one. The wording is then discarded; it never leaves the hook process, and neither does anything you answer. tests/interruptionContentGuard.test.mjs fails the build if that changes.

PermissionRequest and PermissionDenied send the tool's name and the permission posture, and nothing about what the tool was asked to do. The request hook fires before you answer and no hook reports the answer, so a request doesn't say whether it was granted. The denied hook only fires in auto mode.

Repository and branch identity travel only as digests, derived in the hook process and never as text. Most are machine-salted: workspaceHash and projectHash from the checkout's and repository's folder names, and metadata.branchHash from the branch the checkout is on. The salt is random per machine, stored at ~/.ascenda/salt, and never transmitted — without it, a digest of a name from a short list is recoverable by guessing. A branch name is low-entropy in exactly that way, so the branch digest is not a security boundary; it is what lets work be grouped by branch on the other side without the name itself leaving the machine. On a detached HEAD, outside a checkout, or with no readable salt the key is omitted entirely rather than sent blank.

Two more digests are not salted, on purpose. metadata.repoKey and metadata.branchKey use the recipe Ascenda's GitHub collector uses, which runs where no salt exists, so a session and a pull request on the same repository and branch carry the same pair. They go on events for github.com remotes only, and branchKey is left off the default branch. Because they're unsalted, anyone who guesses a repository or branch name can confirm it, and for a public repository those names are public. Turn them off with setup --no-repo-keys or ASCENDA_REPO_KEYS=off. metadata.promptId is Claude Code's own id for the prompt a turn answers, and says nothing about its text.

Correction detection uses local pattern matching on prompt text in the hook process only — classified metadata (reason: repeated_reprompting) is sent; raw prompt text is not.

Compliance

consentScope: ide_telemetry and provenance: ai_work_telemetry on every event. Australian-hosted backend. Not a medical device — workload self-awareness only, no diagnosis, no clinical claim. Consent is revocable from the app at any time; after revocation ingest returns 401.