Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
be94fa2
feat(expo): add anonymous usage telemetry and skill feedback
Jun 3, 2026
1a99b31
refactor(expo): auto-detect harness, self-derive plugin root, slim docs
Jun 4, 2026
bfca26b
feat(expo): skip telemetry in CI, add os/arch, first-run notice
Jun 4, 2026
ebc0249
fix(expo): track skill_invoked via the Skill tool (Read hook never fi…
Jun 4, 2026
3395c90
feat(expo): split skill_invoked by initiator (AI vs user)
Jun 5, 2026
1a03c1b
refactor(expo): simplify telemetry to skill_invoked + skill_feedback
Jun 6, 2026
280489e
Merge origin/main into zvadaadam/skills-usage-observability
Jun 6, 2026
8574435
docs(expo): correct Codex hook status + mark the enablement path
Jun 6, 2026
0dd91db
docs(expo): record in-depth Codex finding — skill telemetry not hook-…
Jun 6, 2026
5a9cf78
feat(expo): make skill_invoked resolver payload-tolerant (Codex-ready)
Jun 6, 2026
0949797
chore(expo): apply code-review polish to skill telemetry
Jun 8, 2026
1f9c471
Merge origin/main into zvadaadam/skills-usage-observability
Jun 16, 2026
5c690e3
fix(expo): address skill-telemetry PR review (Codex, latency, consent…
Jun 16, 2026
4143946
docs(expo): broaden skill-feedback triggers + de-dup harness story
Jun 17, 2026
4f95bda
fix(expo): reference feedback script via ${CLAUDE_PLUGIN_ROOT}, not a…
Jun 17, 2026
db9eca4
refactor(expo): focus skill telemetry on Claude Code; drop other-agen…
Jun 18, 2026
5211d90
feat(expo): make the skill footer a proactive feedback nudge + pointer
Jun 18, 2026
287960a
feat(expo): rename to expo-skill-feedback, add Expo-itself feedback, …
Jun 18, 2026
a171cb6
Merge origin/main (expo-examples #84) into the telemetry branch
Jun 18, 2026
08c3ec0
refactor(expo): prune unnecessary telemetry machinery
Jun 18, 2026
d8c6f57
refactor(expo): drop scripts/package.json — use .cjs instead
Jun 18, 2026
422f03e
refactor(expo): prune expo-skill-feedback SKILL.md to its two jobs
Jun 18, 2026
e5d48ab
refactor(expo): drop references/telemetry.md — it duplicated the code
Jun 18, 2026
772d40a
refactor(expo): run skill telemetry via node, drop sh wrappers
Jun 18, 2026
061f807
feat(expo): make telemetry opt-in (off by default), drop skill footers
Jul 2, 2026
9520de0
Merge origin/main into the telemetry branch
Jul 2, 2026
67a9a60
docs(expo): tighten the AGENTS.md telemetry section
Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,9 @@ codex plugin marketplace add expo/skills --ref main
- Include a brief `README.md` for each plugin.
- Keep references close to the skill that uses them.
- Avoid broad rewrites when updating a skill; preserve the skill's existing scope and trigger intent.

## Usage Telemetry & Feedback

Telemetry is anonymous, **opt-in, and off by default** — nothing is sent until the user enables it with `node plugins/expo/skills/expo-skill-feedback/scripts/telemetry.cjs --on` or `EXPO_SKILLS_TELEMETRY=1` (`--off` / `=0` / `DO_NOT_TRACK=1` disable; CI never sends). When enabled, two events fire on **Claude Code only**: automatic `skill_invoked` (plugin-level `hooks/hooks.json`) and explicit `skill_feedback` (the `expo-skill-feedback` skill). The gate is `telemetryActive()` in `telemetry_common.cjs`.

For contributors: new skills need **no telemetry edits and no feedback footer** — the plugin-level hook covers them automatically. Codex and Cursor cannot host plugin hooks (verified against their sources; don't re-investigate), so they ship no hooks and send nothing.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ Use the plugin for Claude Code or Codex; it stays updated through the plugin mar

Expo documentation, Expo CLI, and EAS CLI are the source of truth. These skills teach agents how to apply Expo guidance in real projects.

## Usage telemetry & feedback

**Off by default — these skills send nothing unless you turn telemetry on.** When enabled (Claude Code only), they send anonymous usage events: the skill name, platform, and a hash of a random local install id. Never code, prompts, file paths, or personal data.

- **Turn on:** ask your agent to **"enable Expo skills telemetry"**, or set `EXPO_SKILLS_TELEMETRY=1`.
- **Turn off:** ask it to disable telemetry, or set `EXPO_SKILLS_TELEMETRY=0` / `DO_NOT_TRACK=1`. CI never sends.
- **Feedback:** with telemetry on, the `expo-skill-feedback` skill sends a quick anonymous rating + note about a skill.

Details live in the `expo-skill-feedback` skill.

## License

MIT
2 changes: 1 addition & 1 deletion plugins/expo/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "expo",
"version": "1.5.0",
"version": "1.6.0",
"description": "Official Expo skills for building, deploying, upgrading, and debugging Expo apps",
"author": {
"name": "Expo Team",
Expand Down
2 changes: 1 addition & 1 deletion plugins/expo/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "expo",
"version": "1.5.0",
"version": "1.6.0",
"description": "Official Expo skills for building, deploying, upgrading, and debugging Expo and React Native apps.",
"author": {
"name": "Expo Team",
Expand Down
2 changes: 1 addition & 1 deletion plugins/expo/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "expo",
"displayName": "Expo",
"version": "1.5.0",
"version": "1.6.0",
"description": "Official Expo skills for building, deploying, upgrading, and debugging Expo and React Native apps.",
"author": {
"name": "Expo Team",
Expand Down
4 changes: 4 additions & 0 deletions plugins/expo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ Official AI agent skills from the Expo team for building, deploying, upgrading,

- **upgrading-expo** — Upgrade Expo SDK versions and fix dependency issues

## Usage telemetry & feedback

**Off by default** — nothing is sent unless you turn telemetry on. When enabled (Claude Code only), the plugin sends anonymous usage events — the skill name, platform, and a hash of a random local install id — never code, prompts, file paths, or personal data. Ask your agent to **"enable Expo skills telemetry"** to opt in (or set `EXPO_SKILLS_TELEMETRY=1`); turn off with `EXPO_SKILLS_TELEMETRY=0` / `DO_NOT_TRACK=1`. Feedback on a skill goes through the **expo-skill-feedback** skill.

## License

MIT
27 changes: 27 additions & 0 deletions plugins/expo/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-event.cjs\" --skill auto --initiator ai --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --detach --quiet",
"timeout": 5
}
]
}
],
"UserPromptExpansion": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-event.cjs\" --skill auto --initiator user --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --detach --quiet",
"timeout": 5
}
]
}
]
}
}
54 changes: 54 additions & 0 deletions plugins/expo/skills/expo-skill-feedback/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: expo-skill-feedback
description: Submit feedback on an Expo skill — or on Expo itself — or turn the bundled anonymous usage telemetry on or off (off by default / opt-in; the user saying "enable Expo skills telemetry" in conversation is the switch). Use when an Expo skill was useful, confusing, broken, missing context, or worth improving; when something fell short because of Expo (an SDK bug or confusing framework behavior) rather than the skill; or when the user wants to enable, turn on, opt in to, disable, turn off, opt out of, check the status of, or understand the anonymous usage tracking these skills can send.
---

# Expo Skill Feedback

Send anonymous feedback on an Expo skill — or on Expo itself — and turn the bundled usage
telemetry on or off. **Telemetry is off by default (opt-in)** — nothing is sent until it's
enabled. Feedback goes to the Expo team via PostHog. Only the skill name, your rating + note,
platform, and a hash of a random local install id are sent — never code, prompts, file paths,
secrets, or personal data. (A separate automatic `skill_invoked` event fires when a skill runs
*once enabled*; the switch below controls both.)

## Submitting feedback

Be proactive: when a skill was wrong, confusing, outdated, or missing — or the trouble is
with **Expo itself** — send 1-3 concrete sentences. Pass the skill you're rating to
`--skill` (the skill being rated, not `expo-skill-feedback`):

```bash
node "${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-feedback.cjs" \
--skill expo-deployment --rating useful \
--text "TestFlight steps were clear and worked first try."
```

- `--rating`: `useful` · `confusing` · `bug` · `idea` · `other`
- `--about`: `skill` (default) · `expo` (the issue is Expo itself, not the skill)
- `--dry-run` prints the payload without sending

Never include secrets, source code, long prompts, or stack traces.

If the command refuses because telemetry is off, don't drop the feedback — ask the user
once (see *Turning it on or off*), and resend the same command after they enable.

## Turning it on or off

Telemetry is anonymous and **off by default (opt-in)** — nothing (neither the automatic
`skill_invoked` event nor feedback) is sent until the user enables it. Saying so in
conversation is the switch: when the user says **"enable Expo skills telemetry"** — or
answers yes when you offer — run:

```bash
node "${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/telemetry.cjs" --on
```

`--off` turns it off again. When the user asks whether telemetry is on, run `--status`
and relay its output — don't answer from memory; env vars and CI can override the saved
state. Env equivalents: `EXPO_SKILLS_TELEMETRY=1` to enable, `=0` or `DO_NOT_TRACK=1` to
disable; CI never sends.

**Never enable it on your own.** If a feedback send was refused because telemetry is off,
ask the user once — "Expo skills telemetry is off; enable it (anonymous) and send this?" —
and run `--on` only after an explicit yes. If they decline, drop the feedback silently.
177 changes: 177 additions & 0 deletions plugins/expo/skills/expo-skill-feedback/scripts/skill-event.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// Submit a `skill_invoked` event to PostHog — fire-and-forget.
//
// Invoked two ways, both cross-platform (plain `node`, no shell wrapper, so it runs the
// same on macOS / Linux / Windows):
// 1. From Claude Code hooks (../../../hooks/hooks.json) as
// `node skill-event.cjs --skill auto --initiator <ai|user> --plugin-root <dir> --detach --quiet`.
// The foreground process reads the hook payload from stdin, resolves which skill ran,
// runs the cheap local gates, then — for `--detach` — re-launches a DETACHED copy of
// itself to do the network POST and returns immediately, so the agent turn never blocks.
// 2. Directly (that detached child, or manual / --dry-run testing) with a resolved
// `--skill <name>`, which builds the event and sends it inline.

const fs = require("fs");
const path = require("path");

const {
POSTHOG_PROJECT_API_KEY,
SOURCE,
telemetryActive,
telemetryConfigured,
detectHarness,
platformProps,
telemetryIdentity,
sendToPosthog,
} = require("./telemetry_common.cjs");

const EVENT = "skill_invoked";

function parseArgs(argv) {
const args = { skill: "", agentHarness: "", initiator: "", pluginRoot: "", dryRun: false, quiet: false, detach: false };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const next = () => argv[++i] || "";
switch (flag) {
case "--skill": args.skill = next(); break;
case "--agent-harness": args.agentHarness = next(); break;
case "--initiator": args.initiator = next(); break;
case "--plugin-root": args.pluginRoot = next(); break;
case "--detach": args.detach = true; break;
case "--dry-run": args.dryRun = true; break;
case "--quiet": args.quiet = true; break;
default: break; // ignore unknown flags
}
}
return args;
}

// Read the hook payload from stdin (fd 0) and parse it as JSON. Only used to resolve
// `--skill auto` in the foreground hook process; the detached sender is handed the
// already-resolved name and never touches stdin.
function readHookInput() {
try {
if (process.stdin.isTTY) return {};
const raw = (fs.readFileSync(0, "utf8") || "").trim(); // fd 0 = stdin
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}

// Resolve the invoked skill name from the hook payload. The name arrives in different
// fields across harnesses and payload shapes, so check every plausible location — the
// strict skillBelongsToPlugin() scoping downstream keeps this safe even when permissive
// (anything that isn't really one of our skills is dropped):
// - Claude Code Skill tool: tool_input.skill (e.g. "expo:expo-observe")
// - Claude Code /slash command: command_name (UserPromptExpansion)
// - other payload shapes: tool_input.skill_name, top-level skill / skill_name
// Plugin skills are namespaced (e.g. "expo:expo-observe") — keep the final segment.
function skillFromHook(hookInput) {
const ti = hookInput && typeof hookInput.tool_input === "object" && hookInput.tool_input ? hookInput.tool_input : {};
const raw = String(
ti.skill || ti.skill_name || hookInput.command_name || hookInput.skill || hookInput.skill_name || ""
).trim().replace(/^\//, ""); // tolerate a leading "/" from slash-command payloads
return raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 suggestion] The namespace is stripped before the scoping check, so another plugin's skill with a colliding name is counted as ours: a payload of tool_input.skill = "foo:expo-ui" resolves to expo-ui and passes skillBelongsToPlugin() (verified with a crafted hook payload). When a namespace is present, require it to be expo:

Suggested change
return raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;
if (!raw.includes(":")) return raw;
const sep = raw.lastIndexOf(":");
return raw.slice(0, sep) === "expo" ? raw.slice(sep + 1) : "";

If harnesses ever send a differently-qualified namespace this would drop real events, so feel free to loosen — but the current code over-counts, and the comment above claiming downstream scoping "keeps this safe" doesn't hold for name collisions.

}

function pluginRootFor(args) {
// Self-derive from this script's location: <root>/skills/expo-skill-feedback/scripts.
return args.pluginRoot || path.resolve(__dirname, "..", "..", "..");
}

// Only emit for skills that belong to THIS plugin (so we never track other plugins'
// or the user's own skills). Confirms <pluginRoot>/skills/<skill>/SKILL.md exists.
// The skill name must be a single kebab-case segment — this also blocks path traversal
// (e.g. "../../x") from a malformed payload reaching path.join or the event property.
function skillBelongsToPlugin(skill, pluginRoot) {
if (!skill || !pluginRoot) return false;
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill)) return false;
try { return fs.existsSync(path.join(pluginRoot, "skills", skill, "SKILL.md")); }
catch { return false; }
}

function eventPayload(skill, args) {
const agentHarness = args.agentHarness.trim() || detectHarness();
const initiator = args.initiator.trim();
const timestamp = new Date().toISOString();
const [distinctId, identityProperties] = telemetryIdentity(agentHarness, { createInstallation: !args.dryRun });

const properties = {
$process_person_profile: false,
source: SOURCE,
skill,
agent_harness: agentHarness,
...(initiator ? { initiator } : {}),
...platformProps(),
...identityProperties,
};

return { api_key: POSTHOG_PROJECT_API_KEY, event: EVENT, distinct_id: distinctId, timestamp, properties };
}

// Re-launch this script DETACHED to perform the network POST off the agent's critical
// path — the cross-platform equivalent of `node skill-event.cjs … &`. We pass the already
// resolved `--skill <name>` (not "auto") and drop `--detach`, so the child sends inline and
// never reads stdin or re-detaches. It runs under the same runtime that launched us
// (process.execPath = node or bun) and inherits our env (CLAUDECODE, EXPO_SKILLS_*, …).
// windowsHide avoids a console-window flash on Windows; failures are ignored (best-effort).
function spawnDetachedSend(skill, args) {
try {
const { spawn } = require("child_process");
const childArgs = [__filename, "--skill", skill, "--quiet"];
if (args.initiator.trim()) childArgs.push("--initiator", args.initiator.trim());
if (args.agentHarness.trim()) childArgs.push("--agent-harness", args.agentHarness.trim());
const child = spawn(process.execPath, childArgs, { detached: true, stdio: "ignore", windowsHide: true });
child.unref();
} catch {
// best-effort: if the child can't be spawned, skip the send rather than block
}
}

async function main(argv) {
const args = parseArgs(argv);

// Resolve which skill ran. `--skill auto` means "read it from the hook payload on
// stdin" — which must happen HERE, in the foreground hook process, because a detached
// child's stdin is /dev/null.
let skill = args.skill.trim();
if (skill === "auto") skill = skillFromHook(readHookInput());

// Cheap, local, no-network gates: decide up front whether anything will be sent, so the
// common "not an Expo skill / opted out" cases cost nothing and never spawn a child.
if (!skill) return 0; // not a skill invocation
if (!args.dryRun && !telemetryActive()) return 0; // opt-in: off until enabled (dry-run inspects regardless)
if (!telemetryConfigured() && !args.dryRun) return 0; // no key in this build (e.g. a fork) -> inert
if (!skillBelongsToPlugin(skill, pluginRootFor(args))) return 0; // not one of ours

// Hook path: hand the network POST to a detached copy of ourselves so the turn never
// blocks on it, then return immediately. (--dry-run stays inline so it can be inspected.)
if (args.detach && !args.dryRun) {
spawnDetachedSend(skill, args);
return 0;
}

const payload = eventPayload(skill, args);

if (args.dryRun) {
console.log(JSON.stringify({ ...payload, api_key: "phc_..." }, null, 2));
return 0;
}

try {
await sendToPosthog(payload, { userAgent: "expo-skills/skill-event", timeoutMs: 3000 });
Comment thread
Kudo marked this conversation as resolved.
} catch (err) {
if (!args.quiet) console.error(`skill-event: ${err.message}`);
return args.quiet ? 0 : 1;
}

if (!args.quiet) console.log(`sent ${EVENT}: ${payload.properties.skill} (${payload.properties.initiator || "?"})`);
return 0;
}

main(process.argv.slice(2))
.then((code) => process.exit(code))
.catch(() => process.exit(0));
Loading
Loading