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 devbut 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
platformsfield, thenreferences/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 buildalone 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-practicesskill 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/binand/usr/local/bin, then~/.cargo/bin,~/.local/bin,/usr/bin,/bin. execFilewith an argv array. Nevershell: 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
execFilereports 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 aDetailfence while themessagestays 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
abortableref per independent read, or a superseded read cancels an unrelated in-flight one. execute: falsefor conditional fetches, andkeepPreviousData: falsewhen the value is per-selection.useCachedPromisewrites to a disk-backedCache. 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, andundefinedis what removes it. isLoadingis a contract, not a hint. Never set: Raycast renders then immediately unloads. Stuck true: the whole tree re-runs every tick.intervalis 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:MMrow. confirmAlertcannot present here — it renders in the Raycast window, which is closed while the menu is open. Destructive items go behindalternate(⌥), and the truly dangerous ones are not offered at all.- Two identical items at the same level cross their
onActionhandlers. Prefix every row with its id. - Cap the item count. 400 rows must not become 400 menu items every minute.
- Never return
nullon error — that removes the item entirely from someone who deliberately enabled it. Render an error menu instead. - Feedback is
showHUD, notshowToast. 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 loginis a browser OAuth flow.npx @raycast/api@latest publishopens a pull request againstraycast/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
Inputfields IS the parameter schema. An undocumented field is one the model guesses at. Use string unions, notstring, 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 returnundefinedto 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.yamlis where the extension-wideinstructionsgo, and they are injected as a system message whenever the extension is mentioned.ai.evals[].inputrenders 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-checkharness, one stub per command, one stub per AI tool,ai.yamlwhen 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:
0written ·1bad args ·2target not empty ·3bundled assets missing ·4post-write self-check failed - Refuses
Windowstogether with amenu-barcommand —platformsis extension-level, so that combination cannot be expressed. - Does not run
npm install. It prints the ordered next steps instead.
- Flags:
scripts/check-store-readiness.sh— the store preconditionsray lintdoes not check. JSON array of{id, status, detail, fix}on stdout.- Flags:
[DIR] [--json] [--quiet] [--strict] [--help] - Exit:
0all pass ·1bad args ·2not an extension directory ·3a required tool is missing (check skipped, not passed) ·4a check failed
- Flags:
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— thepitfalls/+backlog/+TODO.mdformat 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 throughAI.ask. Raycast'sAI.Modelenum is its own thing and churns fast.
Gotchas
ray builddoes not typecheck in its defaultdevenvironment. esbuild strips types without checking them, so a genuineTS2345still printsready - built extension successfullyand exits 0.ray build -e distdoes typecheck (it runstsc -p tsconfig.json --noEmit) — butnpm run build,just build, andray developare all the dev build, which is why the error survives long enough to ship. Keeptsc --noEmitin the gate.ray lintdoes 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.
PATHis roughly/usr/bin:/bin, so a barepueue/gh/uvfails withspawn … 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 interactivePATH, 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
PreferencesorArgumentsinterface.ray buildgeneratesraycast-env.d.tswith globalPreferences.<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"failsray lintwithmust be equal to one of the allowed values.Form.TextAreais 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, eachcommands[].description≥ 12,preferences[].description≥ 8,tools[].description≥ 12. A one-word command description failsray lintwith a baremust NOT have fewer than 12 charactersand a line number. useCachedPromisepersists to a disk-backedCache. 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. UseshouldRevalidateAfter: falseplus two delayedrevalidate()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
structuralflag and render a persistent banner. - Scope
useCachedStateandLocalStoragekeys 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.Dropdownsilently resets when itsvalueis 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 lintknows about.⌘Kand⌘Pare 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 everyKeyboard.Shortcut.Common.*(Removeis⌃X, not⌘⌫;Duplicateis⌘D, not⌘⇧D), inreferences/ui-patterns.md→ Shortcuts Raycast has already taken. - A hardcoded
cmdmodifier is silently dropped on Windows, and awindowsmodifier is silently dropped on macOS. Nothing throws, nothing lints — the action just renders with no key beside it.Keyboard.Shortcutis a union: useKeyboard.Shortcut.Common.*(Raycast maps those per platform) or the two-branch{ macOS: {...}, Windows: {...} }form, where the type makes both branches mandatory. Note the capitalW— lowercasewindowsis deprecated and still compiles. platformsis extension-level; there is no per-command form. So a singlemode: "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
platformsdefaults 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'srequiredlist 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 returningundefinedskips it so the prompt only appears when the call is actually destructive. ItsstyleisAction.Style, reused — there is noTool-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 viaproviders.yaml, or running a local Ollama model — the dev docs' "you need to subscribe to Raycast Pro" has not kept up. Atools[]entry never needs the check at all, since Raycast invokes it only after the user has cleared whatever gate applies. tools[]with noai.evalsships an empty Suggested Prompts list.evals[].inputis what renders under@your-extension; the schema requires onlyinput, so there is no excuse for zero.usedAsExampledefaults to true — set it false on regression cases with contrived phrasing.mocksandexpectedare not in the published schema at all, and the eval item does not setadditionalProperties: false— so they validate by omission. A typo in either key passesray lintand simply does nothing. Same fortools[]itself, which isadditionalProperties: true.@raycast/apibundles its own copy of@types/react. Type props that hold JSX asReact.JSX.Element, notReact.ReactNode— the rootReactNodeis a structurally different type that silently fails to matchActionPanel's children.ray lintruns MORE checks when it thinks it is in CI. Locally it prints five steps; withCI=trueit prints seven —validate package-lock.jsonandvalidate other lock filesappear only there, and nothing announces the difference. Your local gate is a subset of the remote one, which is backwards. PutCI=truein the lint recipe so they cannot diverge. The check that bites: everyresolvedURL in the lockfile must beregistry.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, sointegritystays valid, andnpm install --package-lock-only --registry=…will not rewrite URLs already in the file.@types/nodemust 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. UseexecFilewith an argv array. And raisemaxBuffer— 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 neverexits 2 with a clap error;foo --color never statusis correct. Exit codes are 0/1/2, not 0/1. Assert your generated argv against the CLI's own--helpat 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/apiimports and assert them from onedev-check.ts, compiled by the already-installedtscand run undernode. That import discipline is the entire precondition — a barrel that pulls in the transport pulls in@raycast/apiwith it. - A
mode: "no-view"command exportsasync function Command(props), not a React component. There is no window, so feedback isshowHUD. - A tool's entry point is
src/tools/<name>.ts, mapped fromtools[].name— the same filename-equals-manifest-name rule as commands, in a different directory.tools[].descriptionhas a 12-character minimum, like a command's. - Tool schema extraction is the one thing
ray buildDOES enforce. Anany,unknown, numeric union, intersection, orPick/Partialin a tool'sInputaborts the build withError: extracting tool schemas failed— in the same dev build that happily ships aTS2345everywhere else. Supported:string,number,boolean,string[], string unions (becomeenum), 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: stringon one line, and//comments, produce a property with no description and no warning — documented-looking in the editor, invisible to the model. ray lintpasses with a completely emptymetadata/. Screenshots, icon dimensions, the CHANGELOG placeholder, and a registeredauthorare review-time requirements it never checks.- The extension IS the repo root, and
ray publishcopies 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+launchContextis 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.
launchCommandfrom inside does not. So an external nudge is a poor substitute, and some verification genuinely needs a keypress rather than a URL.