Raycast extension development
A Raycast extension is a manifest (package.json) plus React components, bundled
by esbuild and run inside Raycast's own Node 22 process — which launchd starts,
so it has none of your shell environment. The API surface is well documented at
https://developers.raycast.com. Read that for what List and Form accept.
This skill is the other 20%: what the toolchain does not check, what the
runtime does not provide, and what the store checks that the linter does not.
Every claim here was paid for once in a shipped extension; the ## Gotchas
section is the compressed bill.
macOS is the default, not the premise. Raycast ships on Windows too, and
platforms in the manifest decides who can see your extension. The UI layer is
100% shared and domain logic nearly so; only an OS adapter at the bottom differs.
Declare both platforms and subtract, rather than defaulting to macOS by silence —
references/cross-platform.md. The launchd, Homebrew-prefix, and sips material
below is genuinely macOS-scoped and labelled as such.
When to use
- "Build me a Raycast extension for X" → Workflow A, then B if X is a CLI.
- "Wrap this CLI / API in Raycast" → Workflow B. The seam design is the decision
that matters; everything else is recoverable.
- "It works in
npm run dev but not from Raycast" → the launchd PATH gotcha.
This is the most common report and the dev terminal cannot reproduce it.
- "The menu bar shows the wrong count / nothing / stale data" → Workflow E.
- "My action flashes the new state, reverts, then re-applies" → Workflow C.
- "Get this ready for the Raycast Store" → Workflow F and
scripts/check-store-readiness.sh.
- "Make this work on Windows" / "why isn't my extension on Windows" → the
platforms field, then references/cross-platform.md.
- "Add AI to this" / "the @extension prompt list is empty" → Workflow G.
- Reviewing an existing extension: run the gate (Workflow A) first. It usually
finds something, because
ray build alone never would have.
When NOT to use
- Raycast Script Commands — shell scripts in a different repo, no manifest,
no React, no
@raycast/api. Entirely separate surface.
- Generic React/TSX quality (hook rules, component structure, a11y) → the
react-best-practices skill is the better authority. This skill deliberately
restates none of it.
- The semantics of the CLI being wrapped —
pueue's flags, gh's JSON —
belong to that tool's own skill or its --help.
- Taking the store screenshots. This skill can verify their count and
dimensions. It cannot capture a window, and no CLI can — see Workflow F.
The gate
ray build — the default -e dev build, which is what npm run build,
just build, and ray develop all run — proves only that a bundle exists.
| Command |
Catches |
Misses |
tsc --noEmit |
types |
manifest, formatting, runtime shape |
node dev-check.js |
your own invariants, wire shapes, generated argv |
anything you did not assert |
ray lint |
manifest schema, icons, ESLint, Prettier, reserved-shortcut collisions, and — only under CI=true — package-lock.json registry hosts |
types |
ray build (-e dev) |
syntax, that esbuild can bundle it |
types — esbuild strips them without checking |
ray build -e dist |
the above plus types — it shells out to tsc -p tsconfig.json --noEmit |
manifest, formatting |
Measured on a scaffold with one genuine TS2345: ray build exits 0 and
prints ready - built extension successfully; ray build -e dist exits 1
and reports the error. So the dist build is a stronger pre-submit check than it
looks — but it is not the build you run all day, which is exactly why the type
error survives long enough to ship.
All four, in that order, every time:
typecheck: npx tsc --noEmit -p tsconfig.json
verify: npx tsc --outDir .build/verify --module commonjs --target ES2022 \
--lib ES2023 --esModuleInterop --strict src/lib/dev-check.ts \
&& node .build/verify/dev-check.js
check: typecheck verify lint build
assets/Justfile.template is this, complete, plus a dist recipe. Two real bugs
shipped past ray build in the source extension: a MutatePromise<GroupMap>
passed where MutatePromise<State> was expected (it would have emptied the list
on every action), and a ReactNode/JSX.Element clash. Neither printed a line.
Surface map
The complete list of extension entry points. There is nothing else.
| Entry point |
Manifest |
Platform |
Notes |
| View command |
"mode": "view" |
both |
pushes a view onto the navigation stack |
| No-view command |
"mode": "no-view" |
both |
runs and exits; exports async function Command(props), not a component |
| Menu bar command |
"mode": "menu-bar" |
macOS |
returns a MenuBarExtra. Pins the whole extension to macOS |
| AI tools |
"tools": [...] |
macOS; Windows undocumented but shipped |
invoked by Raycast AI. Workflow G |
| Script commands |
separate repo |
— |
shell scripts, not extensions |
There is no widget API — no Notification Center, Control Center, or
Live-Activity surface. A menu-bar command is the only way to show something
without opening Raycast, and that constrains the whole design.
Repo layout
The extension IS the repo root. Not a subdirectory: ray build, develop,
and lint all resolve ./package.json and write ./raycast-env.d.ts relative
to cwd, and ray publish copies the extension directory verbatim into
extensions/<name>/ of the review PR.
package.json the manifest — commands, preferences, arguments, tools
ai.yaml AI instructions + evals, when the extension has tools
raycast-env.d.ts GENERATED by ray build. Commit it.
assets/ extension-icon.png (512x512) + monochrome menu-bar SVG
metadata/ 3-6 store screenshots at 2000x1250
src/<command>.tsx one file per commands[].name — the names must match
src/tools/<tool>.ts one file per tools[].name — the names must match
src/lib/ shared code
src/lib/<backend>/ the transport seam (Workflow B)
src/lib/platform/ the OS adapter, when platforms lists both
Consequence of the verbatim copy: anything else in the root — notes, task lists,
a docs site, a .venv — rides along into the review PR. Use an allowlist export
to a scratch directory if that bothers you; a .gitignore will not help, because
publish copies the directory, not the git index.
Workflow A — scaffold or retrofit
bash scripts/new-raycast-extension.sh --dir ./my-ext --name my-ext \
--author <your-raycast-username> --platforms macOS,Windows \
--command tasks:view --tool list-tasks
cd my-ext && npm install
npm run build # this is what GENERATES raycast-env.d.ts
just check # the four-stage gate
--platforms defaults to macOS. Pass macOS,Windows unless something in the
design is platform-specific — the script refuses Windows alongside a
menu-bar command, because platforms has no per-command form.
For an existing extension, skip the script and copy assets/Justfile.template,
assets/tsconfig.json.template, and assets/dev-check.ts.template in. The one
non-obvious line is "include": ["src/**/*", "raycast-env.d.ts"] — omit it and
the generated Preferences globals are invisible to tsc.
Then open the command from Raycast root search, not from the ray develop
console. The console inherits your interactive environment; Raycast does not.
Anything environment-dependent that you only tested in the terminal is untested.
Workflow B — wrap a CLI behind a transport seam
Model every mutation as a data union, never as argv strings:
export type Mutation =
| { op: "add"; command: string; group?: string; label?: string }
| { op: "kill"; ids?: number[]; group?: string };
export interface Transport {
read(o?: ReadOptions): Promise<State>;
mutate(m: Mutation, o?: Options): Promise<number | void>;
}
If mutate took a string[] of argv, a future socket or HTTP transport would
have to parse argv back into intent — that is not a seam, it is a shell.
assets/transport.ts.template is the skeleton, with the argv builder as a pure
function so dev-check can assert it without spawning anything.
Rules that are not negotiable:
- Resolve an absolute binary path. Preference first,
existsSync-validated so
a stale value falls through, then probe both /opt/homebrew/bin and
/usr/local/bin, then ~/.cargo/bin, ~/.local/bin, /usr/bin, /bin.
execFile with an argv array. Never shell: true. Nothing re-parses your
quoting, and a user-supplied command stays one argv element.
- Raise
maxBuffer. The 1 MB default turns a large JSON read into a parse
error rather than a clear failure. 64 MB is a reasonable ceiling.
- Set a timeout, and know that
execFile reports its own timeout as a SIGTERM
kill, not a distinct code.
- Classify stderr into a discriminated
kind — binary-not-found,
daemon-not-running, bad-arguments, timeout, command-failed — and keep
the full text on the error for a Detail fence while the message stays one
line for a toast title.
- Strip secret-bearing fields at the parse boundary (Workflow C explains why).
- Global flags precede the subcommand for clap-based CLIs.
Read references/runtime-and-subprocess.md when the extension shells out,
resolves a path, streams output, or reports spawn … ENOENT.
Workflow C — data, cache, and mutations
const state = useCachedPromise(
(group: string, account: string) => read({ group, account }),
[group, account], // <-- arguments, not closure captures
{ keepPreviousData: true, abortable: stateAbort },
);
- Everything the fetcher depends on must be an argument. The argument array
is what keys the cache and triggers refetch. A closed-over variable changes
silently and keeps serving the previous account's data.
- One
abortable ref per independent read, or a superseded read cancels an
unrelated in-flight one.
execute: false for conditional fetches, and keepPreviousData: false
when the value is per-selection.
useCachedPromise writes to a disk-backed Cache. Every field of the
resolved value lands on disk in plaintext. Strip environment snapshots, tokens,
and auth headers in the parse function, not at render time.
For mutations, the default mutate() revalidates on success — which is wrong for
any backend that acknowledges before it applies:
await state.mutate(run(mutation), {
optimisticUpdate: (data) => predict(data, mutation),
rollbackOnError: true,
shouldRevalidateAfter: false, // the load-bearing line
});
setTimeout(() => state.revalidate(), FAST_MS);
setTimeout(() => state.revalidate(), SETTLE_MS);
Measured on one such daemon: ack in ~22 ms, applied ~280 ms later, so the
built-in revalidate returned pre-change state and visibly undid the action.
400 and 1500 worked there. Measure your own backend — those two numbers
are a worked example, not a constant.
And an optimistic updater must return its input unchanged when the outcome is
unknowable. A new server-assigned id, a scheduler decision, a reordering: a
wrong prediction flickers exactly like no prediction, and is also wrong.
Read references/data-and-state.md when wiring useCachedPromise/mutate,
when an action reverts and re-applies, or when deciding what to persist and under
which key.
Workflow D — failure states as data
One descriptor, N renderers. The alternative — an errorMarkdown() helper per
surface — drifts, and the menu bar cannot render a List.EmptyView anyway.
export interface ErrorDescriptor {
icon: Image.ImageLike;
title: string;
shortTitle: string; // a few words — the list column is a third of the window
description: string; // one line, for List.EmptyView
markdown: string; // a full page, for Detail
actions: ErrorAction[];// DATA: { id, title, icon, copy? | url? | run? }
structural: boolean; // is the backend gone, or was this read just flaky?
}
structural is the decision that matters. useCachedPromise keeps serving its
last good result when a fetch fails — correct for a flaky read, wrong when the
backend is gone, because then the cached list renders a dead system as live.
| Situation |
Render |
| first read failed, no data |
<List><ErrorEmptyView/></List> — the whole screen |
| structural failure, cached data present |
a persistent banner row atop the list |
| transient failure |
an ordinary toast |
Actions stay data so every renderer maps them to its own primitive — Action in
a panel, a Clipboard.copy call in the menu bar.
assets/error-descriptor.tsx.template has all four renderers.
Workflow E — the menu bar
<MenuBarExtra icon={icon} title={n > 0 ? String(n) : undefined} isLoading={isLoading}>
- There is no badge API. The count is the
title, and undefined is what
removes it.
isLoading is a contract, not a hint. Never set: Raycast renders then
immediately unloads. Stuck true: the whole tree re-runs every tick.
interval is manifest-only — a preference cannot change it, and Raycast
renders its own control. Use "1m"; the docs contradict themselves on the floor.
- Background refresh is off by default for store installs. Until the user runs
the command once, a fresh install shows nothing. Put this at the top of the README.
- Raycast restores the item from a database on restart, not by re-running the
command, so a stale render survives invisibly. Show an
Updated HH:MM row.
confirmAlert cannot present here — it renders in the Raycast window, which
is closed while the menu is open. Destructive items go behind alternate (⌥),
and the truly dangerous ones are not offered at all.
- Two identical items at the same level cross their
onAction handlers. Prefix
every row with its id.
- Cap the item count. 400 rows must not become 400 menu items every minute.
- Never return
null on error — that removes the item entirely from someone
who deliberately enabled it. Render an error menu instead.
- Feedback is
showHUD, not showToast. There is no window.
Read references/menu-bar.md when the extension has or is gaining a
mode: "menu-bar" command, or the menu bar shows stale data, a wrong count, or
nothing at all.
Workflow F — ship to the store
[ ] author is your REGISTERED Raycast username
[ ] license: "MIT" AND an MIT LICENSE file
[ ] at least one category
[ ] platforms declared EXPLICITLY — ["macOS"] if any command is menu-bar or any
macOS-only API is used, otherwise ["macOS", "Windows"]
[ ] ai.evals present if tools[] is non-empty, or the prompt list ships empty
[ ] package-lock.json committed — npm ci must work from a clean checkout
[ ] assets/extension-icon.png at exactly 512x512, readable light and dark
[ ] a monochrome template SVG for the menu bar, tinted at runtime
[ ] CHANGELOG.md opening with `## [Initial Version] - {PR_MERGE_DATE}`
[ ] README covering setup, and any background-refresh default
[ ] 3-6 PNGs at exactly 2000x1250 in metadata/
[ ] tsc --noEmit, ray lint, and ray build -e dist all clean
Then bash scripts/check-store-readiness.sh . — because ray lint exits 0
with a completely empty metadata/. Screenshot count and dimensions, icon
size, the CHANGELOG placeholder, and a real author are review-time
requirements the linter never runs.
Three steps cannot be automated, and pretending otherwise wastes an afternoon:
- Screenshots. Raycast's Window Capture, triggered by a hotkey with
"Save to Metadata" ticked. That option only appears once a
metadata/ folder
exists, so create it (with a README inside) before you start.
ray login is a browser OAuth flow.
npx @raycast/api@latest publish opens a pull request against
raycast/extensions. CI runs, then a human reviews. Days, not seconds.
Note ray build defaults to -e dev. Run ray build -e dist at least once
before submitting — it is the build the store actually produces.
Read references/store-publishing.md when the user asks to publish or make
something store-ready, or when check-store-readiness.sh fails and you want the
reasoning behind a check.
Workflow G — add an AI tool layer
If Workflow B's transport already returns structured data, a tool is a thin
wrapper and the remaining work is prompt-shaped:
// src/tools/list-tasks.ts <- filename MUST equal tools[].name
type Input = {
/** Only return tasks in this group. Omit for every group. */
group?: string;
/** Only return tasks in this state. */
status?: "queued" | "running" | "success" | "failed";
};
export default async function tool(input: Input) {
const state = await read({ group: input.group }); // the existing transport
return state.tasks.filter((t) => !input.status || t.status === input.status);
}
- JSDoc on the
Input fields IS the parameter schema. An undocumented field
is one the model guesses at. Use string unions, not string, for anything
enumerable. Say how to obtain each value — name the sibling tool that
returns the ids — not just what it means.
- Return expected failures as data. An unhandled throw is caught by Raycast,
which shows an error screen and ends the run;
{ error: "daemon not running" }
lets the model tell the user something useful instead.
- Destructive tools are allowed. Export a
confirmation —
Tool.Confirmation<Input> runs before the tool and can return undefined to
skip itself when the call turns out to be harmless. (This supersedes the older
advice in this skill that a first version had to be read-only.)
ai.yaml is where the extension-wide instructions go, and they are
injected as a system message whenever the extension is mentioned.
ai.evals[].input renders as the Suggested Prompts list under
@your-extension. No evals means a working tool set that nobody knows how to
address. Three prompts is the difference between "what is this" and a demo.
# ai.yaml
instructions: |
Never restart, kill, or clean anything unless the user explicitly asks.
evals:
- input: "@my-ext What's running right now, and how long has each one been going?"
- input: "@my-ext Why did my failed tasks fail? Read the logs — don't fix anything yet."
npx ray evals runs them, but it needs AI access and network, so it stays a
manual step rather than joining just check.
Read references/ai-extensions.md when adding tools[], writing ai.yaml,
or deciding whether the AI layer is worth building at all.
Available scripts
scripts/new-raycast-extension.sh — scaffold a gated extension: manifest,
tsconfig, flat ESLint config, Justfile gate, dev-check harness, one stub per
command, one stub per AI tool, ai.yaml when there are tools, placeholder icon.
- Flags:
--dir DIR --name NAME [--title T] [--author U] [--platforms macOS[,Windows]] [--command NAME:MODE[:TITLE]]… [--tool NAME[:TITLE]]… [--license SPDX] [--no-verify-harness] [--dry-run] [--force] [--json] [--help]
- Exit:
0 written · 1 bad args · 2 target not empty · 3 bundled assets
missing · 4 post-write self-check failed
- Refuses
Windows together with a menu-bar command — platforms is
extension-level, so that combination cannot be expressed.
- Does not run
npm install. It prints the ordered next steps instead.
scripts/check-store-readiness.sh — the store preconditions ray lint
does not check. JSON array of {id, status, detail, fix} on stdout.
- Flags:
[DIR] [--json] [--quiet] [--strict] [--help]
- Exit:
0 all pass · 1 bad args · 2 not an extension directory · 3 a
required tool is missing (check skipped, not passed) · 4 a check failed
Deliberately absent, so nobody re-adds them: a wrapper around
tsc && ray lint && ray build (that is a four-line Justfile recipe, already an
asset); a PATH-probe script (it would run in your shell — the one environment
that hides the bug); a manifest validator (ray lint already validates against
the published schema, and a copy would drift).
Bundled assets
| Asset |
Use |
Justfile.template |
the four-stage gate, plus the ([ -d node_modules ] || npm install) && self-bootstrap every recipe needs, because ray ships inside @raycast/api |
tsconfig.json.template |
working config; note include must list raycast-env.d.ts |
eslint.config.mjs.template |
the flat-config incantation for @raycast/eslint-config v2 |
package.json.template |
manifest skeleton with a known-good dependency set |
dev-check.ts.template |
the no-test-runner harness, with the argv-vs---help cross-check |
transport.ts.template |
the Mutation data-union seam |
tool.ts.template |
an AI tool: JSDoc'd Input plus a ready-to-uncomment confirmation |
ai.yaml.template |
instructions plus three evals — which are also the Suggested Prompts |
error-descriptor.tsx.template |
ErrorDescriptor and its four renderers |
metadata-README.md.template |
drop into metadata/ so "Save to Metadata" appears |
extension-icon.placeholder.png |
a real 512x512 so a fresh scaffold passes ray lint — and its sha256 is what check-store-readiness.sh fails on |
Reference files
Load one only when its condition fires. Do not preload.
| Reference |
Read it when |
references/runtime-and-subprocess.md |
the extension shells out, resolves a binary path, spawns or streams a subprocess, or reports spawn … ENOENT / "works in my terminal but not from Raycast" |
references/manifest-and-commands.md |
adding or renaming a command, adding preferences or arguments, wiring one command to launch another, or considering AI tools |
references/data-and-state.md |
wiring useCachedPromise/usePromise/mutate, an action visibly reverts and re-applies, or deciding what to persist and under which key |
references/ui-patterns.md |
building a List/Detail/Form surface, adding a dropdown or shortcut, rendering program output as markdown, or designing empty and error states |
references/menu-bar.md |
the extension has or is gaining a mode: "menu-bar" command, or the menu bar shows stale data, a wrong count, or nothing |
references/cross-platform.md |
targeting Windows, a shortcut does nothing on one OS, shelling out to a platform-specific script, or an extension is missing from the Windows store |
references/ai-extensions.md |
adding tools[], writing ai.yaml, calling AI.ask/useAI, deciding whether an AI layer is worth it, or the @extension Suggested Prompts list is empty |
references/store-publishing.md |
the user asks to publish, submit, or "make this store-ready", or check-store-readiness.sh reports a failure you want the reasoning behind |
See also
react-best-practices — hooks and component rules. A Raycast extension is
React; this skill covers only what is Raycast-specific.
verifiable-surfaces — --help/--dry-run/exit-code design, if you bundle
helper scripts alongside the extension.
project-knowledge-harness — the pitfalls/ + backlog/ + TODO.md format
the gotchas below were originally recorded in.
pueue-job-queue — the CLI the source extension wraps, as an example of what
"the wrapped tool's own skill" means.
claude-api — if the extension talks to a model directly instead of through
AI.ask. Raycast's AI.Model enum is its own thing and churns fast.
Gotchas
ray build does not typecheck in its default dev environment. esbuild
strips types without checking them, so a genuine TS2345 still prints
ready - built extension successfully and exits 0. ray build -e dist does
typecheck (it runs tsc -p tsconfig.json --noEmit) — but npm run build,
just build, and ray develop are all the dev build, which is why the error
survives long enough to ship. Keep tsc --noEmit in the gate.
ray lint does not typecheck either. It runs ESLint, Prettier, and manifest
and icon validation. It alone catches reserved-shortcut collisions, so you need
both, and neither is sufficient.
- Raycast runs extensions under launchd, which never sources a shell rc.
PATH
is roughly /usr/bin:/bin, so a bare pueue / gh / uv fails with
spawn … ENOENT. Probe both /opt/homebrew/bin (Apple Silicon) and
/usr/local/bin (Intel) — hardcoding either breaks half of all Macs.
- The dev terminal hides the PATH bug completely.
ray develop's console
inherits your interactive PATH, so a resolver that fails in production passes
there. Exercise every environment-dependent change from Raycast root search.
- Starting a daemon from Raycast poisons every job it later runs. The child
inherits Raycast's stripped environment. Only offer a one-click start when a
service manager owns the daemon.
- Never hand-write a
Preferences or Arguments interface. ray build
generates raycast-env.d.ts with global Preferences.<Command> namespaces
whose fields are required; consuming those turns manifest/code drift into a
compile error. Commit it, and regenerate after every manifest edit.
- Raycast has no multi-line preference type. The allowed set is exactly
appPicker | checkbox | dropdown | password | textfield | file | directory;
"type": "textarea" fails ray lint with must be equal to one of the allowed values. Form.TextArea is a form component and unrelated. A "one per line"
setting can only ever hold one entry — pick a single-line separator, and say why
in the docs or a reader will assume it is arbitrary taste.
- Manifest strings have schema minimums, and the error does not name the
field.
description ≥ 16 chars, each commands[].description ≥ 12,
preferences[].description ≥ 8, tools[].description ≥ 12. A one-word command
description fails ray lint with a bare
must NOT have fewer than 12 characters and a line number.
useCachedPromise persists to a disk-backed Cache. Anything in the
resolved value is written to disk in plaintext. One measured payload was 21x
larger with its environment snapshot than without, and that snapshot contained
every secret in the submitting shell.
- Fetcher dependencies must be arguments to the hook, not closure captures.
The argument array is the cache key. A capture changes silently and keeps
serving the previous account's data.
mutate() revalidates immediately by default, and many backends ack before
they apply. The follow-up read overwrites the optimistic update with
truth-as-of-a-moment-ago and the action visibly undoes itself. Use
shouldRevalidateAfter: false plus two delayed revalidate() calls — and
measure your own backend rather than copying anyone's milliseconds.
- An optimistic updater must return its input unchanged when the outcome is
unknowable. A wrong prediction flickers worse than no prediction.
- Serving cached data on failure is right for flaky reads and wrong for
structural ones. A cached list renders a dead backend as live, with a toast
that scrolls away as the only hint. Carry a
structural flag and render a
persistent banner.
- Scope
useCachedState and LocalStorage keys by whatever the value belongs
to — account, host, workspace. One shared key meant switching machines left a
list filtered by a group that only existed elsewhere, showing nothing.
- A
List.Dropdown silently resets when its value is not among its children.
Seed a static fallback so the first paint is never empty, and render a synthetic
(gone) item for an unknown current value.
- Raycast has taken more shortcuts than
ray lint knows about. ⌘K and
⌘P are reserved (Open Action Panel, Open Search Bar Dropdown) and lint fails
on them. But while an extension is in development Raycast also injects a
Debug section into every action panel — ⌘R, ⇧⌘S, ⇧⌘D, ⇧⌘X, ⌘⌥D —
and those pass lint, pass typecheck, and silently win over yours. Your own
action just does nothing, on your machine only. Full table, plus the real key
combinations behind every Keyboard.Shortcut.Common.* (Remove is ⌃X, not
⌘⌫; Duplicate is ⌘D, not ⌘⇧D), in
references/ui-patterns.md → Shortcuts Raycast has already taken.
- A hardcoded
cmd modifier is silently dropped on Windows, and a windows
modifier is silently dropped on macOS. Nothing throws, nothing lints — the
action just renders with no key beside it. Keyboard.Shortcut is a union: use
Keyboard.Shortcut.Common.* (Raycast maps those per platform) or the
two-branch { macOS: {...}, Windows: {...} } form, where the type makes both
branches mandatory. Note the capital W — lowercase windows is deprecated
and still compiles.
platforms is extension-level; there is no per-command form. So a single
mode: "menu-bar" command pins every other command in the extension to macOS.
Splitting the menu bar into its own extension is the only way to have both.
- The two authorities disagree on what
platforms defaults to. The changelog
says ["macOS"]; the live JSON schema says "the extension is assumed to be
available on all platforms". It is not in the schema's required list either,
so nothing forces the question. Write the field explicitly every time — the
alternative is either hiding a portable extension or advertising an untested
one, with no way to tell which from the manifest.
- A tool call CAN ask for confirmation. Earlier guidance in this skill said
otherwise and was wrong:
Tool.Confirmation<Input> is exported, runs before the
tool, and returning undefined skips it so the prompt only appears when the
call is actually destructive. Its style is Action.Style, reused — there is
no Tool-local style enum. So a first version does not have to be
read-only.
- AI access is a runtime capability, not a subscription check. Use
environment.canAccess(AI). A user may be inside the free message allowance, on
Pro, on a Custom Provider via providers.yaml, or running a local Ollama model
— the dev docs' "you need to subscribe to Raycast Pro" has not kept up. A
tools[] entry never needs the check at all, since Raycast invokes it only
after the user has cleared whatever gate applies.
tools[] with no ai.evals ships an empty Suggested Prompts list.
evals[].input is what renders under @your-extension; the schema requires
only input, so there is no excuse for zero. usedAsExample defaults to true —
set it false on regression cases with contrived phrasing.
mocks and expected are not in the published schema at all, and the eval
item does not set additionalProperties: false — so they validate by omission.
A typo in either key passes ray lint and simply does nothing. Same for
tools[] itself, which is additionalProperties: true.
@raycast/api bundles its own copy of @types/react. Type props that hold
JSX as React.JSX.Element, not React.ReactNode — the root ReactNode is a
structurally different type that silently fails to match ActionPanel's
children.
ray lint runs MORE checks when it thinks it is in CI. Locally it prints
five steps; with CI=true it prints seven — validate package-lock.json and
validate other lock files appear only there, and nothing announces the
difference. Your local gate is a subset of the remote one, which is backwards.
Put CI=true in the lint recipe so they cannot diverge. The check that bites:
every resolved URL in the lockfile must be registry.npmjs.org, so a global
npm registry pointing at a mirror (registry.npmmirror.com, an internal
Artifactory) writes a lockfile the store rejects. Fix by host substitution —
the tarballs are identical, so integrity stays valid, and
npm install --package-lock-only --registry=… will not rewrite URLs already
in the file.
@types/node must match Raycast's runtime (Node 22), not your shell's node.
Typing against 24 lets you write APIs that compile and throw at runtime.
- Never
shell: true. Use execFile with an argv array. And raise
maxBuffer — the 1 MB default truncates a large JSON read into a confusing
parse error instead of a clear failure.
- Global flags of clap-based CLIs must precede the subcommand.
foo status --color never exits 2 with a clap error; foo --color never status is
correct. Exit codes are 0/1/2, not 0/1. Assert your generated argv against the
CLI's own --help at verify time so a misplaced flag fails in the gate.
- There is no test runner in a Raycast extension, and adding one is store-review
noise. Keep pure modules free of
@raycast/api imports and assert them from
one dev-check.ts, compiled by the already-installed tsc and run under
node. That import discipline is the entire precondition — a barrel that pulls
in the transport pulls in @raycast/api with it.
- A
mode: "no-view" command exports async function Command(props), not a
React component. There is no window, so feedback is showHUD.
- A tool's entry point is
src/tools/<name>.ts, mapped from tools[].name —
the same filename-equals-manifest-name rule as commands, in a different
directory. tools[].description has a 12-character minimum, like a command's.
- Tool schema extraction is the one thing
ray build DOES enforce. An any,
unknown, numeric union, intersection, or Pick/Partial in a tool's Input
aborts the build with Error: extracting tool schemas failed — in the same dev
build that happily ships a TS2345 everywhere else. Supported: string,
number, boolean, string[], string unions (become enum), optional fields,
nested object literals, type aliases.
interface Input extends Base {} extracts as {} — silently. No error,
build succeeds, and the model is handed a tool that looks like it takes no
arguments. Declare members directly or use a type alias.
- Tool JSDoc only counts on its own line.
/** doc */ field: string on one
line, and // comments, produce a property with no description and no
warning — documented-looking in the editor, invisible to the model.
ray lint passes with a completely empty metadata/. Screenshots, icon
dimensions, the CHANGELOG placeholder, and a registered author are review-time
requirements it never checks.
- The extension IS the repo root, and
ray publish copies the directory
verbatim — not the git index. Ignored-but-present directories ride along into
the review PR. Export an allowlisted subset if that matters.
- Fence untrusted program output in markdown, and neutralise embedded
backticks with a zero-width space. A build log full of
# otherwise renders as
headings, and a fence inside the output closes yours early.
launchCommand + launchContext is the supported cross-command path, but
push a view only once (guard with a ref — the receiving command re-renders on
every poll) and wait for dependent async state before acting on the context.
Acting early opens the previous account's data and looks like it worked.
- A deeplink triggered from outside Raycast prompts for confirmation.
launchCommand from inside does not. So an external nudge is a poor substitute,
and some verification genuinely needs a keypress rather than a URL.
1---2name: raycast-extension-dev3description: Build, verify, and ship Raycast extensions in TypeScript with @raycast/api. Use when the user says "Raycast extension", "ray build", "ray develop", "ray lint", "menu bar command", "MenuBarExtra", "useCachedPromise", "raycast-env.d.ts", "no-view command", "Raycast on Windows", "platforms field", "runPowerShellScript", "AI extension", "Raycast AI tool", "ai.yaml", "ai evals", "suggested prompts", or wants to publish to the Raycast Store. Covers the launchd PATH trap, the typecheck ray build skips, optimistic-update reconcile timing, cross-platform macOS/Windows seams, and the store-readiness checks ray lint never runs.4---56# Raycast extension development78A Raycast extension is a manifest (`package.json`) plus React components, bundled9by esbuild and run inside Raycast's own Node 22 process — which launchd starts,10so it has none of your shell environment. The API surface is well documented at11<https://developers.raycast.com>. Read that for what `List` and `Form` accept.1213This skill is the other 20%: what the toolchain does **not** check, what the14runtime does **not** provide, and what the store checks that the linter does not.15Every claim here was paid for once in a shipped extension; the `## Gotchas`16section is the compressed bill.1718**macOS is the default, not the premise.** Raycast ships on Windows too, and19`platforms` in the manifest decides who can see your extension. The UI layer is20100% shared and domain logic nearly so; only an OS adapter at the bottom differs.21Declare both platforms and subtract, rather than defaulting to macOS by silence —22`references/cross-platform.md`. The launchd, Homebrew-prefix, and `sips` material23below is genuinely macOS-scoped and labelled as such.2425## When to use2627- "Build me a Raycast extension for X" → Workflow A, then B if X is a CLI.28- "Wrap this CLI / API in Raycast" → Workflow B. The seam design is the decision29 that matters; everything else is recoverable.30- "It works in `npm run dev` but not from Raycast" → the launchd PATH gotcha.31 This is the most common report and the dev terminal cannot reproduce it.32- "The menu bar shows the wrong count / nothing / stale data" → Workflow E.33- "My action flashes the new state, reverts, then re-applies" → Workflow C.34- "Get this ready for the Raycast Store" → Workflow F and35 `scripts/check-store-readiness.sh`.36- "Make this work on Windows" / "why isn't my extension on Windows" → the37 `platforms` field, then `references/cross-platform.md`.38- "Add AI to this" / "the @extension prompt list is empty" → Workflow G.39- Reviewing an existing extension: run the gate (Workflow A) first. It usually40 finds something, because `ray build` alone never would have.4142## When NOT to use4344- **Raycast Script Commands** — shell scripts in a different repo, no manifest,45 no React, no `@raycast/api`. Entirely separate surface.46- **Generic React/TSX quality** (hook rules, component structure, a11y) → the47 `react-best-practices` skill is the better authority. This skill deliberately48 restates none of it.49- **The semantics of the CLI being wrapped** — `pueue`'s flags, `gh`'s JSON —50 belong to that tool's own skill or its `--help`.51- **Taking the store screenshots.** This skill can verify their count and52 dimensions. It cannot capture a window, and no CLI can — see Workflow F.5354## The gate5556`ray build` — the default `-e dev` build, which is what `npm run build`,57`just build`, and `ray develop` all run — proves only that a bundle exists.5859| Command | Catches | Misses |60| --- | --- | --- |61| `tsc --noEmit` | types | manifest, formatting, runtime shape |62| `node dev-check.js` | your own invariants, wire shapes, generated argv | anything you did not assert |63| `ray lint` | manifest schema, icons, ESLint, Prettier, **reserved-shortcut collisions**, and — **only under `CI=true`** — `package-lock.json` registry hosts | types |64| `ray build` (`-e dev`) | syntax, that esbuild can bundle it | **types — esbuild strips them without checking** |65| `ray build -e dist` | the above **plus types** — it shells out to `tsc -p tsconfig.json --noEmit` | manifest, formatting |6667Measured on a scaffold with one genuine `TS2345`: `ray build` exits **0** and68prints `ready - built extension successfully`; `ray build -e dist` exits **1**69and reports the error. So the dist build is a stronger pre-submit check than it70looks — but it is not the build you run all day, which is exactly why the type71error survives long enough to ship.7273All four, in that order, every time:7475```make76typecheck: npx tsc --noEmit -p tsconfig.json77verify: npx tsc --outDir .build/verify --module commonjs --target ES2022 \78 --lib ES2023 --esModuleInterop --strict src/lib/dev-check.ts \79 && node .build/verify/dev-check.js80check: typecheck verify lint build81```8283`assets/Justfile.template` is this, complete, plus a `dist` recipe. Two real bugs84shipped past `ray build` in the source extension: a `MutatePromise<GroupMap>`85passed where `MutatePromise<State>` was expected (it would have emptied the list86on every action), and a `ReactNode`/`JSX.Element` clash. Neither printed a line.8788## Surface map8990The complete list of extension entry points. There is nothing else.9192| Entry point | Manifest | Platform | Notes |93| --- | --- | --- | --- |94| View command | `"mode": "view"` | both | pushes a view onto the navigation stack |95| No-view command | `"mode": "no-view"` | both | runs and exits; **exports `async function Command(props)`, not a component** |96| Menu bar command | `"mode": "menu-bar"` | **macOS** | returns a `MenuBarExtra`. Pins the *whole extension* to macOS |97| AI tools | `"tools": [...]` | macOS; Windows **undocumented but shipped** | invoked by Raycast AI. Workflow G |98| Script commands | separate repo | — | shell scripts, not extensions |99100**There is no widget API** — no Notification Center, Control Center, or101Live-Activity surface. A `menu-bar` command is the only way to show something102without opening Raycast, and that constrains the whole design.103104## Repo layout105106**The extension IS the repo root.** Not a subdirectory: `ray build`, `develop`,107and `lint` all resolve `./package.json` and write `./raycast-env.d.ts` relative108to cwd, and `ray publish` copies the extension directory *verbatim* into109`extensions/<name>/` of the review PR.110111```text112package.json the manifest — commands, preferences, arguments, tools113ai.yaml AI instructions + evals, when the extension has tools114raycast-env.d.ts GENERATED by ray build. Commit it.115assets/ extension-icon.png (512x512) + monochrome menu-bar SVG116metadata/ 3-6 store screenshots at 2000x1250117src/<command>.tsx one file per commands[].name — the names must match118src/tools/<tool>.ts one file per tools[].name — the names must match119src/lib/ shared code120src/lib/<backend>/ the transport seam (Workflow B)121src/lib/platform/ the OS adapter, when platforms lists both122```123124Consequence of the verbatim copy: anything else in the root — notes, task lists,125a docs site, a `.venv` — rides along into the review PR. Use an allowlist export126to a scratch directory if that bothers you; a `.gitignore` will not help, because127`publish` copies the directory, not the git index.128129## Workflow A — scaffold or retrofit130131```bash132bash scripts/new-raycast-extension.sh --dir ./my-ext --name my-ext \133 --author <your-raycast-username> --platforms macOS,Windows \134 --command tasks:view --tool list-tasks135cd my-ext && npm install136npm run build # this is what GENERATES raycast-env.d.ts137just check # the four-stage gate138```139140`--platforms` defaults to `macOS`. Pass `macOS,Windows` unless something in the141design is platform-specific — the script refuses `Windows` alongside a142`menu-bar` command, because `platforms` has no per-command form.143144For an existing extension, skip the script and copy `assets/Justfile.template`,145`assets/tsconfig.json.template`, and `assets/dev-check.ts.template` in. The one146non-obvious line is `"include": ["src/**/*", "raycast-env.d.ts"]` — omit it and147the generated `Preferences` globals are invisible to `tsc`.148149Then **open the command from Raycast root search**, not from the `ray develop`150console. The console inherits your interactive environment; Raycast does not.151Anything environment-dependent that you only tested in the terminal is untested.152153## Workflow B — wrap a CLI behind a transport seam154155Model every mutation as a **data union**, never as argv strings:156157```ts158export type Mutation =159 | { op: "add"; command: string; group?: string; label?: string }160 | { op: "kill"; ids?: number[]; group?: string };161162export interface Transport {163 read(o?: ReadOptions): Promise<State>;164 mutate(m: Mutation, o?: Options): Promise<number | void>;165}166```167168If `mutate` took a `string[]` of argv, a future socket or HTTP transport would169have to parse argv back into intent — that is not a seam, it is a shell.170`assets/transport.ts.template` is the skeleton, with the argv builder as a pure171function so `dev-check` can assert it without spawning anything.172173Rules that are not negotiable:174175- **Resolve an absolute binary path.** Preference first, `existsSync`-validated so176 a stale value falls through, then probe **both** `/opt/homebrew/bin` and177 `/usr/local/bin`, then `~/.cargo/bin`, `~/.local/bin`, `/usr/bin`, `/bin`.178- **`execFile` with an argv array. Never `shell: true`.** Nothing re-parses your179 quoting, and a user-supplied command stays one argv element.180- **Raise `maxBuffer`.** The 1 MB default turns a large JSON read into a parse181 error rather than a clear failure. 64 MB is a reasonable ceiling.182- **Set a timeout, and know that `execFile` reports its own timeout as a SIGTERM183 kill**, not a distinct code.184- **Classify stderr into a discriminated `kind`** — `binary-not-found`,185 `daemon-not-running`, `bad-arguments`, `timeout`, `command-failed` — and keep186 the full text on the error for a `Detail` fence while the `message` stays one187 line for a toast title.188- **Strip secret-bearing fields at the parse boundary** (Workflow C explains why).189- **Global flags precede the subcommand** for clap-based CLIs.190191Read `references/runtime-and-subprocess.md` **when** the extension shells out,192resolves a path, streams output, or reports `spawn … ENOENT`.193194## Workflow C — data, cache, and mutations195196```ts197const state = useCachedPromise(198 (group: string, account: string) => read({ group, account }),199 [group, account], // <-- arguments, not closure captures200 { keepPreviousData: true, abortable: stateAbort },201);202```203204- **Everything the fetcher depends on must be an argument.** The argument array205 is what keys the cache and triggers refetch. A closed-over variable changes206 silently and keeps serving the previous account's data.207- **One `abortable` ref per independent read**, or a superseded read cancels an208 unrelated in-flight one.209- **`execute: false` for conditional fetches**, and `keepPreviousData: false`210 when the value is per-selection.211- **`useCachedPromise` writes to a disk-backed `Cache`.** Every field of the212 resolved value lands on disk in plaintext. Strip environment snapshots, tokens,213 and auth headers in the parse function, not at render time.214215For mutations, the default `mutate()` revalidates on success — which is wrong for216any backend that acknowledges before it applies:217218```ts219await state.mutate(run(mutation), {220 optimisticUpdate: (data) => predict(data, mutation),221 rollbackOnError: true,222 shouldRevalidateAfter: false, // the load-bearing line223});224setTimeout(() => state.revalidate(), FAST_MS);225setTimeout(() => state.revalidate(), SETTLE_MS);226```227228Measured on one such daemon: ack in ~22 ms, applied ~280 ms later, so the229built-in revalidate returned pre-change state and visibly undid the action.230`400` and `1500` worked there. **Measure your own backend** — those two numbers231are a worked example, not a constant.232233And an optimistic updater must **return its input unchanged when the outcome is234unknowable**. A new server-assigned id, a scheduler decision, a reordering: a235wrong prediction flickers exactly like no prediction, and is also wrong.236237Read `references/data-and-state.md` **when** wiring `useCachedPromise`/`mutate`,238when an action reverts and re-applies, or when deciding what to persist and under239which key.240241## Workflow D — failure states as data242243One descriptor, N renderers. The alternative — an `errorMarkdown()` helper per244surface — drifts, and the menu bar cannot render a `List.EmptyView` anyway.245246```ts247export interface ErrorDescriptor {248 icon: Image.ImageLike;249 title: string;250 shortTitle: string; // a few words — the list column is a third of the window251 description: string; // one line, for List.EmptyView252 markdown: string; // a full page, for Detail253 actions: ErrorAction[];// DATA: { id, title, icon, copy? | url? | run? }254 structural: boolean; // is the backend gone, or was this read just flaky?255}256```257258`structural` is the decision that matters. `useCachedPromise` keeps serving its259last good result when a fetch fails — correct for a flaky read, wrong when the260backend is gone, because then the cached list renders a dead system as live.261262| Situation | Render |263| --- | --- |264| first read failed, no data | `<List><ErrorEmptyView/></List>` — the whole screen |265| structural failure, cached data present | a persistent banner row atop the list |266| transient failure | an ordinary toast |267268Actions stay data so every renderer maps them to its own primitive — `Action` in269a panel, a `Clipboard.copy` call in the menu bar.270`assets/error-descriptor.tsx.template` has all four renderers.271272## Workflow E — the menu bar273274```tsx275<MenuBarExtra icon={icon} title={n > 0 ? String(n) : undefined} isLoading={isLoading}>276```277278- **There is no badge API.** The count *is* the `title`, and `undefined` is what279 removes it.280- **`isLoading` is a contract, not a hint.** Never set: Raycast renders then281 immediately unloads. Stuck true: the whole tree re-runs every tick.282- **`interval` is manifest-only** — a preference cannot change it, and Raycast283 renders its own control. Use `"1m"`; the docs contradict themselves on the floor.284- **Background refresh is off by default for store installs.** Until the user runs285 the command once, a fresh install shows nothing. Put this at the top of the README.286- **Raycast restores the item from a database on restart**, not by re-running the287 command, so a stale render survives invisibly. Show an `Updated HH:MM` row.288- **`confirmAlert` cannot present here** — it renders in the Raycast window, which289 is closed while the menu is open. Destructive items go behind `alternate` (⌥),290 and the truly dangerous ones are not offered at all.291- **Two identical items at the same level cross their `onAction` handlers.** Prefix292 every row with its id.293- **Cap the item count.** 400 rows must not become 400 menu items every minute.294- **Never return `null` on error** — that removes the item entirely from someone295 who deliberately enabled it. Render an error menu instead.296- Feedback is `showHUD`, not `showToast`. There is no window.297298Read `references/menu-bar.md` **when** the extension has or is gaining a299`mode: "menu-bar"` command, or the menu bar shows stale data, a wrong count, or300nothing at all.301302## Workflow F — ship to the store303304```text305[ ] author is your REGISTERED Raycast username306[ ] license: "MIT" AND an MIT LICENSE file307[ ] at least one category308[ ] platforms declared EXPLICITLY — ["macOS"] if any command is menu-bar or any309 macOS-only API is used, otherwise ["macOS", "Windows"]310[ ] ai.evals present if tools[] is non-empty, or the prompt list ships empty311[ ] package-lock.json committed — npm ci must work from a clean checkout312[ ] assets/extension-icon.png at exactly 512x512, readable light and dark313[ ] a monochrome template SVG for the menu bar, tinted at runtime314[ ] CHANGELOG.md opening with `## [Initial Version] - {PR_MERGE_DATE}`315[ ] README covering setup, and any background-refresh default316[ ] 3-6 PNGs at exactly 2000x1250 in metadata/317[ ] tsc --noEmit, ray lint, and ray build -e dist all clean318```319320Then `bash scripts/check-store-readiness.sh .` — because **`ray lint` exits 0321with a completely empty `metadata/`**. Screenshot count and dimensions, icon322size, the CHANGELOG placeholder, and a real `author` are review-time323requirements the linter never runs.324325Three steps cannot be automated, and pretending otherwise wastes an afternoon:3263271. **Screenshots.** Raycast's Window Capture, triggered by a hotkey with328 "Save to Metadata" ticked. That option only appears once a `metadata/` folder329 exists, so create it (with a README inside) before you start.3302. **`ray login`** is a browser OAuth flow.3313. **`npx @raycast/api@latest publish`** opens a pull request against332 `raycast/extensions`. CI runs, then a human reviews. Days, not seconds.333334Note `ray build` defaults to `-e dev`. Run `ray build -e dist` at least once335before submitting — it is the build the store actually produces.336337Read `references/store-publishing.md` **when** the user asks to publish or make338something store-ready, or when `check-store-readiness.sh` fails and you want the339reasoning behind a check.340341## Workflow G — add an AI tool layer342343If Workflow B's transport already returns structured data, a tool is a thin344wrapper and the remaining work is prompt-shaped:345346```ts347// src/tools/list-tasks.ts <- filename MUST equal tools[].name348type Input = {349 /** Only return tasks in this group. Omit for every group. */350 group?: string;351 /** Only return tasks in this state. */352 status?: "queued" | "running" | "success" | "failed";353};354355export default async function tool(input: Input) {356 const state = await read({ group: input.group }); // the existing transport357 return state.tasks.filter((t) => !input.status || t.status === input.status);358}359```360361- **JSDoc on the `Input` fields IS the parameter schema.** An undocumented field362 is one the model guesses at. Use string unions, not `string`, for anything363 enumerable. Say **how to obtain** each value — name the sibling tool that364 returns the ids — not just what it means.365- **Return expected failures as data.** An unhandled throw is caught by Raycast,366 which shows an error screen and ends the run; `{ error: "daemon not running" }`367 lets the model tell the user something useful instead.368- **Destructive tools are allowed.** Export a `confirmation` —369 `Tool.Confirmation<Input>` runs before the tool and can return `undefined` to370 skip itself when the call turns out to be harmless. (This supersedes the older371 advice in this skill that a first version had to be read-only.)372- **`ai.yaml` is where the extension-wide `instructions` go**, and they are373 injected as a system message whenever the extension is mentioned.374- **`ai.evals[].input` renders as the Suggested Prompts list** under375 `@your-extension`. No evals means a working tool set that nobody knows how to376 address. Three prompts is the difference between "what is this" and a demo.377378```yaml379# ai.yaml380instructions: |381 Never restart, kill, or clean anything unless the user explicitly asks.382evals:383 - input: "@my-ext What's running right now, and how long has each one been going?"384 - input: "@my-ext Why did my failed tasks fail? Read the logs — don't fix anything yet."385```386387`npx ray evals` runs them, but it needs AI access and network, so it stays a388manual step rather than joining `just check`.389390Read `references/ai-extensions.md` **when** adding `tools[]`, writing `ai.yaml`,391or deciding whether the AI layer is worth building at all.392393## Available scripts394395- **`scripts/new-raycast-extension.sh`** — scaffold a gated extension: manifest,396 tsconfig, flat ESLint config, Justfile gate, `dev-check` harness, one stub per397 command, one stub per AI tool, `ai.yaml` when there are tools, placeholder icon.398 - Flags: `--dir DIR --name NAME [--title T] [--author U]399 [--platforms macOS[,Windows]] [--command NAME:MODE[:TITLE]]…400 [--tool NAME[:TITLE]]… [--license SPDX] [--no-verify-harness]401 [--dry-run] [--force] [--json] [--help]`402 - Exit: `0` written · `1` bad args · `2` target not empty · `3` bundled assets403 missing · `4` post-write self-check failed404 - Refuses `Windows` together with a `menu-bar` command — `platforms` is405 extension-level, so that combination cannot be expressed.406 - Does not run `npm install`. It prints the ordered next steps instead.407- **`scripts/check-store-readiness.sh`** — the store preconditions `ray lint`408 does not check. JSON array of `{id, status, detail, fix}` on stdout.409 - Flags: `[DIR] [--json] [--quiet] [--strict] [--help]`410 - Exit: `0` all pass · `1` bad args · `2` not an extension directory · `3` a411 required tool is missing (check skipped, **not** passed) · `4` a check failed412413Deliberately absent, so nobody re-adds them: a wrapper around414`tsc && ray lint && ray build` (that is a four-line Justfile recipe, already an415asset); a PATH-probe script (it would run in *your* shell — the one environment416that hides the bug); a manifest validator (`ray lint` already validates against417the published schema, and a copy would drift).418419## Bundled assets420421| Asset | Use |422| --- | --- |423| `Justfile.template` | the four-stage gate, plus the `([ -d node_modules ] \|\| npm install) &&` self-bootstrap every recipe needs, because `ray` ships inside `@raycast/api` |424| `tsconfig.json.template` | working config; note `include` must list `raycast-env.d.ts` |425| `eslint.config.mjs.template` | the flat-config incantation for `@raycast/eslint-config` v2 |426| `package.json.template` | manifest skeleton with a known-good dependency set |427| `dev-check.ts.template` | the no-test-runner harness, with the argv-vs-`--help` cross-check |428| `transport.ts.template` | the `Mutation` data-union seam |429| `tool.ts.template` | an AI tool: JSDoc'd `Input` plus a ready-to-uncomment `confirmation` |430| `ai.yaml.template` | `instructions` plus three `evals` — which are also the Suggested Prompts |431| `error-descriptor.tsx.template` | `ErrorDescriptor` and its four renderers |432| `metadata-README.md.template` | drop into `metadata/` so "Save to Metadata" appears |433| `extension-icon.placeholder.png` | a real 512x512 so a fresh scaffold passes `ray lint` — and its sha256 is what `check-store-readiness.sh` fails on |434435## Reference files436437Load one only when its condition fires. Do not preload.438439| Reference | Read it **when** |440| --- | --- |441| `references/runtime-and-subprocess.md` | the extension shells out, resolves a binary path, spawns or streams a subprocess, or reports `spawn … ENOENT` / "works in my terminal but not from Raycast" |442| `references/manifest-and-commands.md` | adding or renaming a command, adding preferences or arguments, wiring one command to launch another, or considering AI tools |443| `references/data-and-state.md` | wiring `useCachedPromise`/`usePromise`/`mutate`, an action visibly reverts and re-applies, or deciding what to persist and under which key |444| `references/ui-patterns.md` | building a List/Detail/Form surface, adding a dropdown or shortcut, rendering program output as markdown, or designing empty and error states |445| `references/menu-bar.md` | the extension has or is gaining a `mode: "menu-bar"` command, or the menu bar shows stale data, a wrong count, or nothing |446| `references/cross-platform.md` | targeting Windows, a shortcut does nothing on one OS, shelling out to a platform-specific script, or an extension is missing from the Windows store |447| `references/ai-extensions.md` | adding `tools[]`, writing `ai.yaml`, calling `AI.ask`/`useAI`, deciding whether an AI layer is worth it, or the `@extension` Suggested Prompts list is empty |448| `references/store-publishing.md` | the user asks to publish, submit, or "make this store-ready", or `check-store-readiness.sh` reports a failure you want the reasoning behind |449450## See also451452- `react-best-practices` — hooks and component rules. A Raycast extension is453 React; this skill covers only what is Raycast-specific.454- `verifiable-surfaces` — `--help`/`--dry-run`/exit-code design, if you bundle455 helper scripts alongside the extension.456- `project-knowledge-harness` — the `pitfalls/` + `backlog/` + `TODO.md` format457 the gotchas below were originally recorded in.458- `pueue-job-queue` — the CLI the source extension wraps, as an example of what459 "the wrapped tool's own skill" means.460- `claude-api` — if the extension talks to a model directly instead of through461 `AI.ask`. Raycast's `AI.Model` enum is its own thing and churns fast.462463## Gotchas464465- **`ray build` does not typecheck in its default `dev` environment.** esbuild466 strips types without checking them, so a genuine `TS2345` still prints467 `ready - built extension successfully` and exits 0. `ray build -e dist` *does*468 typecheck (it runs `tsc -p tsconfig.json --noEmit`) — but `npm run build`,469 `just build`, and `ray develop` are all the dev build, which is why the error470 survives long enough to ship. Keep `tsc --noEmit` in the gate.471- **`ray lint` does not typecheck either.** It runs ESLint, Prettier, and manifest472 and icon validation. It alone catches reserved-shortcut collisions, so you need473 both, and neither is sufficient.474- **Raycast runs extensions under launchd, which never sources a shell rc.** `PATH`475 is roughly `/usr/bin:/bin`, so a bare `pueue` / `gh` / `uv` fails with476 `spawn … ENOENT`. Probe **both** `/opt/homebrew/bin` (Apple Silicon) and477 `/usr/local/bin` (Intel) — hardcoding either breaks half of all Macs.478- **The dev terminal hides the PATH bug completely.** `ray develop`'s console479 inherits your interactive `PATH`, so a resolver that fails in production passes480 there. Exercise every environment-dependent change from Raycast root search.481- **Starting a daemon *from* Raycast poisons every job it later runs.** The child482 inherits Raycast's stripped environment. Only offer a one-click start when a483 service manager owns the daemon.484- **Never hand-write a `Preferences` or `Arguments` interface.** `ray build`485 generates `raycast-env.d.ts` with global `Preferences.<Command>` namespaces486 whose fields are *required*; consuming those turns manifest/code drift into a487 compile error. Commit it, and regenerate after every manifest edit.488- **Raycast has no multi-line preference type.** The allowed set is exactly489 `appPicker | checkbox | dropdown | password | textfield | file | directory`;490 `"type": "textarea"` fails `ray lint` with `must be equal to one of the allowed491 values`. `Form.TextArea` is a form component and unrelated. A "one per line"492 setting can only ever hold one entry — pick a single-line separator, and say why493 in the docs or a reader will assume it is arbitrary taste.494- **Manifest strings have schema minimums, and the error does not name the495 field.** `description` ≥ 16 chars, each `commands[].description` ≥ 12,496 `preferences[].description` ≥ 8, `tools[].description` ≥ 12. A one-word command497 description fails `ray lint` with a bare498 `must NOT have fewer than 12 characters` and a line number.499- **`useCachedPromise` persists to a disk-backed `Cache`.** Anything in the500 resolved value is written to disk in plaintext. One measured payload was 21x501 larger with its environment snapshot than without, and that snapshot contained502 every secret in the submitting shell.503- **Fetcher dependencies must be arguments to the hook, not closure captures.**504 The argument array is the cache key. A capture changes silently and keeps505 serving the previous account's data.506- **`mutate()` revalidates immediately by default, and many backends ack before507 they apply.** The follow-up read overwrites the optimistic update with508 truth-as-of-a-moment-ago and the action visibly undoes itself. Use509 `shouldRevalidateAfter: false` plus two delayed `revalidate()` calls — and510 measure your own backend rather than copying anyone's milliseconds.511- **An optimistic updater must return its input unchanged when the outcome is512 unknowable.** A wrong prediction flickers worse than no prediction.513- **Serving cached data on failure is right for flaky reads and wrong for514 structural ones.** A cached list renders a dead backend as live, with a toast515 that scrolls away as the only hint. Carry a `structural` flag and render a516 persistent banner.517- **Scope `useCachedState` and `LocalStorage` keys** by whatever the value belongs518 to — account, host, workspace. One shared key meant switching machines left a519 list filtered by a group that only existed elsewhere, showing nothing.520- **A `List.Dropdown` silently resets when its `value` is not among its children.**521 Seed a static fallback so the first paint is never empty, and render a synthetic522 `(gone)` item for an unknown current value.523- **Raycast has taken more shortcuts than `ray lint` knows about.** `⌘K` and524 `⌘P` are reserved (Open Action Panel, Open Search Bar Dropdown) and lint fails525 on them. But while an extension is *in development* Raycast also injects a526 Debug section into every action panel — `⌘R`, `⇧⌘S`, `⇧⌘D`, `⇧⌘X`, `⌘⌥D` —527 and **those pass lint, pass typecheck, and silently win over yours.** Your own528 action just does nothing, on your machine only. Full table, plus the real key529 combinations behind every `Keyboard.Shortcut.Common.*` (`Remove` is `⌃X`, not530 `⌘⌫`; `Duplicate` is `⌘D`, not `⌘⇧D`), in531 `references/ui-patterns.md` → *Shortcuts Raycast has already taken*.532- **A hardcoded `cmd` modifier is silently dropped on Windows**, and a `windows`533 modifier is silently dropped on macOS. Nothing throws, nothing lints — the534 action just renders with no key beside it. `Keyboard.Shortcut` is a union: use535 `Keyboard.Shortcut.Common.*` (Raycast maps those per platform) or the536 two-branch `{ macOS: {...}, Windows: {...} }` form, where the type makes both537 branches mandatory. Note the capital `W` — lowercase `windows` is deprecated538 and still compiles.539- **`platforms` is extension-level; there is no per-command form.** So a single540 `mode: "menu-bar"` command pins every other command in the extension to macOS.541 Splitting the menu bar into its own extension is the only way to have both.542- **The two authorities disagree on what `platforms` defaults to.** The changelog543 says `["macOS"]`; the live JSON schema says *"the extension is assumed to be544 available on all platforms"*. It is not in the schema's `required` list either,545 so nothing forces the question. Write the field explicitly every time — the546 alternative is either hiding a portable extension or advertising an untested547 one, with no way to tell which from the manifest.548- **A tool call CAN ask for confirmation.** Earlier guidance in this skill said549 otherwise and was wrong: `Tool.Confirmation<Input>` is exported, runs before the550 tool, and returning `undefined` skips it so the prompt only appears when the551 call is actually destructive. Its `style` is `Action.Style`, reused — there is552 no `Tool`-local style enum. So a first version does **not** have to be553 read-only.554- **AI access is a runtime capability, not a subscription check.** Use555 `environment.canAccess(AI)`. A user may be inside the free message allowance, on556 Pro, on a Custom Provider via `providers.yaml`, or running a local Ollama model557 — the dev docs' *"you need to subscribe to Raycast Pro"* has not kept up. A558 `tools[]` entry never needs the check at all, since Raycast invokes it only559 after the user has cleared whatever gate applies.560- **`tools[]` with no `ai.evals` ships an empty Suggested Prompts list.**561 `evals[].input` is what renders under `@your-extension`; the schema requires562 only `input`, so there is no excuse for zero. `usedAsExample` defaults to true —563 set it false on regression cases with contrived phrasing.564- **`mocks` and `expected` are not in the published schema at all**, and the eval565 item does not set `additionalProperties: false` — so they validate by omission.566 A typo in either key passes `ray lint` and simply does nothing. Same for567 `tools[]` itself, which is `additionalProperties: true`.568- **`@raycast/api` bundles its own copy of `@types/react`.** Type props that hold569 JSX as `React.JSX.Element`, not `React.ReactNode` — the root `ReactNode` is a570 structurally different type that silently fails to match `ActionPanel`'s571 children.572- **`ray lint` runs MORE checks when it thinks it is in CI.** Locally it prints573 five steps; with `CI=true` it prints seven — `validate package-lock.json` and574 `validate other lock files` appear only there, and nothing announces the575 difference. Your local gate is a *subset* of the remote one, which is backwards.576 Put `CI=true` in the lint recipe so they cannot diverge. The check that bites:577 every `resolved` URL in the lockfile must be `registry.npmjs.org`, so a global578 npm registry pointing at a mirror (`registry.npmmirror.com`, an internal579 Artifactory) writes a lockfile the store rejects. Fix by host substitution —580 the tarballs are identical, so `integrity` stays valid, and581 `npm install --package-lock-only --registry=…` will *not* rewrite URLs already582 in the file.583- **`@types/node` must match Raycast's runtime (Node 22), not your shell's node.**584 Typing against 24 lets you write APIs that compile and throw at runtime.585- **Never `shell: true`.** Use `execFile` with an argv array. And raise586 `maxBuffer` — the 1 MB default truncates a large JSON read into a confusing587 parse error instead of a clear failure.588- **Global flags of clap-based CLIs must precede the subcommand.** `foo status589 --color never` exits **2** with a clap error; `foo --color never status` is590 correct. Exit codes are 0/1/2, not 0/1. Assert your generated argv against the591 CLI's own `--help` at verify time so a misplaced flag fails in the gate.592- **There is no test runner in a Raycast extension, and adding one is store-review593 noise.** Keep pure modules free of `@raycast/api` imports and assert them from594 one `dev-check.ts`, compiled by the already-installed `tsc` and run under595 `node`. That import discipline is the entire precondition — a barrel that pulls596 in the transport pulls in `@raycast/api` with it.597- **A `mode: "no-view"` command exports `async function Command(props)`, not a598 React component.** There is no window, so feedback is `showHUD`.599- **A tool's entry point is `src/tools/<name>.ts`, mapped from `tools[].name`** —600 the same filename-equals-manifest-name rule as commands, in a different601 directory. `tools[].description` has a 12-character minimum, like a command's.602- **Tool schema extraction is the one thing `ray build` DOES enforce.** An `any`,603 `unknown`, numeric union, intersection, or `Pick`/`Partial` in a tool's `Input`604 aborts the build with `Error: extracting tool schemas failed` — in the same dev605 build that happily ships a `TS2345` everywhere else. Supported: `string`,606 `number`, `boolean`, `string[]`, string unions (become `enum`), optional fields,607 nested object literals, type aliases.608- **`interface Input extends Base {}` extracts as `{}` — silently.** No error,609 build succeeds, and the model is handed a tool that looks like it takes no610 arguments. Declare members directly or use a type alias.611- **Tool JSDoc only counts on its own line.** `/** doc */ field: string` on one612 line, and `//` comments, produce a property with **no description** and no613 warning — documented-looking in the editor, invisible to the model.614- **`ray lint` passes with a completely empty `metadata/`.** Screenshots, icon615 dimensions, the CHANGELOG placeholder, and a registered `author` are review-time616 requirements it never checks.617- **The extension IS the repo root, and `ray publish` copies the directory618 verbatim** — not the git index. Ignored-but-present directories ride along into619 the review PR. Export an allowlisted subset if that matters.620- **Fence untrusted program output in markdown**, and neutralise embedded621 backticks with a zero-width space. A build log full of `#` otherwise renders as622 headings, and a fence inside the output closes yours early.623- **`launchCommand` + `launchContext` is the supported cross-command path**, but624 push a view only once (guard with a ref — the receiving command re-renders on625 every poll) and wait for dependent async state before acting on the context.626 Acting early opens the previous account's data and looks like it worked.627- **A deeplink triggered from outside Raycast prompts for confirmation.**628 `launchCommand` from inside does not. So an external nudge is a poor substitute,629 and some verification genuinely needs a keypress rather than a URL.