agentleFS
adriangrantdotorgadriangrantdotorg/Raycast-Skill/skills/raycast/SKILL.md

raycast

Skill10 starsChanged 12 days agoApache-2.0View on GitHub

Best practices and workflows for developing and modifying Raycast Extensions (React/Node) AND Raycast Script Commands (shell scripts with @raycast metadata). Use this skill whenever the user wants to create, update, or troubleshoot anything that runs inside Raycast — extensions, script commands, menu-bar commands, AI extension tools, deeplinks, quicklinks. Triggers include: 'make a Raycast command', 'Raycast script command', 'fix my Raycast script', '@raycast.title', registering a Script Directory, useCachedPromise, useFetch, useForm, popToRoot, MenuBarExtra, raycast:// deeplinks, a red warning triangle beside 'Command' in root search, extensions missing after a Raycast 2 upgrade or v1 migration, calling AppleScript/shell/SSH from Raycast, or any task involving shell scripts registered as Raycast Script Commands. Use even when the user asks for a 'quick automation' or 'Raycast hotkey' without explicitly naming the type — Claude should pick script command vs extension based on the task.

  • Deletes or force-pushes
  • Installs packages

2 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions2 projects use a version of this.

    Word for word · 1

    1. adriangrantdotorg/Raycast-AI-Skill★ 10100% same

    Edited · 0

    None found.

    Wrote their own version · 1

    1. lgtm-hq/ai-skills★ 1same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

The file

Show its rules and text

We split this skill into the rules it gives an agent

27 rules. About 0 are common, 10 are distinctive, and 17 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈10

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 17

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    No UI is needed beyond a brief HUD-style success/failure toast, or one line of live text in root search (inline mode + refreshTime).

    line 40
  2. Very specific:

    No persistent state, no dropdowns populated from APIs, no multi-step forms.

    line 41
  3. Distinctive:

    When in doubt, lean Script Command first.

    line 52
  4. Very specific:

    Place the script in a stable directory (e.g.

    line 96
  5. Distinctive:

    If the script ends up needing any of these, migrate to an Extension instead:

    line 159
  6. Very specific:

    Standard Method: If not duplicating, use npx @raycast/api@latest create, or Raycast's Create Extension command, whose templates include …

    line 173
  7. Distinctive:

    When modifying or creating forms and commands, adhere to these standards:

    line 194
  8. Distinctive:

    Auto-Close on Success: After successfully completing an action (like adding a Notion page, running a system script, etc.), the extension …

    line 199
  9. Very specific:

    Validation: use useForm + FormValidation.Required from @raycast/utils (spread itemProps.x) rather than hand-rolled error state.

    line 204
  10. Very specific:

    Mutations: prefer await mutate(apiCall(), { optimisticUpdate: (d) => … }) over calling revalidate() by hand — it updates the UI at once, …

    line 242
  11. Distinctive:

    When an extension requires triggering local MacOS functionality (where external APIs fall short or aren't applicable):

    line 257
  12. Very specific:

    Clipboard Operations: Always use the native Clipboard utilities from @raycast/api (e.g., Clipboard.copy(text)) rather than custom bash …

    line 263
  13. Distinctive:

    Reconsider scope: If the extension's *entire* purpose is to fire a single shell command, a Script Command is simpler.

    line 266
  14. Distinctive:

    When an extension shells out to a compiled binary (Swift, Go, Rust, etc.):

    line 270
  15. Very specific:

    Reference via environment.assetsPath: Use path.join(environment.assetsPath, "binary-name") — never construct paths relative to the source …

    line 273
  16. Very specific:

    Ensure execute permission: After compiling or copying the binary, run chmod +x on the asset.

    line 282
  17. Very specific:

    Surface errors visibly: Don't catch and return empty results — throw or show a showToast(Toast.Style.Failure, ...) (or …

    line 286
  18. Very specific:

    Destructive actions (delete, etc.): Use confirmAlert with Alert.ActionStyle.Destructive for irreversible operations.

    line 295
  19. Very specific:

    Fallback commands: read props.fallbackText to prefill from whatever the user typed in root search.

    line 326
  20. Distinctive:

    Runtime facts: every extension runs in Raycast's own bundled Node (not Homebrew's), with the same stripped PATH as script commands.

    line 344
  21. Distinctive:

    When building an extension that requires an external API (like Notion or Spotify):

    line 348
  22. Very specific:

    Add the required secrets/tokens to the preferences array in package.json ("type": "password", "required": true — Raycast then blocks the …

    line 349
  23. Very specific:

    Per-extension config when duplicating: update name, title, description, author (your Raycast handle), and the commands array.

    line 366
  24. Very specific:

    single select / status: Use Form.Dropdown with a leading <Form.Dropdown.Item value="" title="None" /> so the field defaults to unset.

    line 369
  25. Distinctive:

    Verify a token before the user pastes it into Raycast:

    line 377
  26. Very specific:

    Add a tools entry in package.json (name, title, description) → src/tools/<name>.ts default-exports an async function taking ONE input …

    line 390
  27. Very specific:

    Favorites have no API: Add to Favorites is ⌘⇧F on the command's root-search row (backend add-favorite → favoriteOrder), logged as Action …

    line 452

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Raycast Development

This skill defines the preferred workflow and best practices for building, modifying, and troubleshooting Raycast Extensions (React/Node) and Script Commands (shell scripts with @raycast.* metadata).

0. Reference files — read the one that matches the task

The sections below are the rules. The exact signatures and doc facts live in references/, distilled 09-24-26 from developers.raycast.com and manual.raycast.com (API 2.5.0, @raycast/utils 2.3.0), re-checked 10-02-26 against Raycast 2.6.2 and refreshed 10-19-26 to npm API 2.8.2 (live stamp: references/FRESHNESS.json; npm: utils 2.3.3; the docs changelog has no API change after 2.5.0, so the v2 differences are app-side and live in section 3 Raycast 2 and references/script-commands-and-app.md Raycast 2 app):

📄 FileOpen it when you are…
references/platform.mdediting package.json (commands, modes, interval, arguments, preferences, platforms), using LaunchProps / background refresh / deeplinks, running ray CLI commands, debugging, building AI tools / ai.yaml / evals
references/api-reference.mdwriting UI or calling @raycast/api: List, Grid, Form, Detail, Actions, MenuBarExtra, Toast/HUD/Alert, Clipboard, Cache, LocalStorage, Keyboard shortcuts, launchCommand, OAuth, AI.ask, WindowManagement, BrowserExtension
references/utils-hooks.mdpicking or configuring a @raycast/utils hook (usePromise, useCachedPromise, useFetch, useExec, useSQL, useForm, useFrecencySorting…), pagination, optimistic mutate, runAppleScript, OAuthService, and changelog / migration / ESM questions
references/script-commands-and-app.mdwriting a Script Command (every @raycast.* key, arguments, output per mode, exit codes), or using app features: aliases, hotkeys, Hyper Key, quicklinks, Dynamic Placeholders, raycast:// URLs, MCP, Raycast AI skills, logs

Trigger: you are about to write a Raycast prop, option, manifest key, or metadata key you have not checked in this session. Discrimination: the rules below are enough for workflow decisions; they are not enough for exact names and defaults (filtering, keepPreviousData, refreshTime…), which drift between API versions. Action: grep the matching reference file first. If it is missing or looks stale, ask the live docs: curl -s 'https://developers.raycast.com/readme.md?ask=<question>' (the answer comes back with excerpts), or grep https://developers.raycast.com/llms-full.txt (two parts, the second at /llms-full.txt/1) and https://manual.raycast.com/llms-full.txt. Then add what you learned to the reference file.

Refresh step: run this first, every time the skill loads. Trigger: this skill was just loaded, for any Raycast task. Discrimination: the references are a snapshot; Raycast ships API versions every few days (2.5.0 → 2.7.0 in two weeks, 09-24 → 10-07-26), the docs on developers.raycast.com come from docs/ in github.com/raycast/extensions (store guidelines included), and manual.raycast.com changes pages almost daily. The per-prop live check above only catches what you happen to look up; this catches everything new. Nothing runs between sessions: never turn this into a scheduled job without asking. Action:

  1. python3 ~/.claude/skills/raycast/scripts/refresh-check.py (one network check per day, reused after that; offline prints ⚠️ and exits 0, so carry on). It compares npm @raycast/api / @raycast/utils, new docs/ commits, and every manual page (hashed, from manual.raycast.com/llms-full.txt) with references/FRESHNESS.json.
  2. ✅ = current: say nothing about it, start the task.
  3. 🔄 = something is newer. Before the task, read only what it lists:

    • a version jump: the printed npm diff command, filtered to types/index.d.ts and the dist/commands/ file list (the bundle is minified). A jump also runs 🧪 tsc + ray lint on a throwaway copy of every local extension with the new versions installed, so ray lint is the NEW CLI with its new store and manifest rules (copies in ~/Library/Caches/raycast-skill-refresh/typecheck/, the user's checkouts and node_modules untouched). Lint is compared with a baseline (~/Library/Application Support/raycast-skill-refresh/lint-baseline.json); only NEW findings show, and the "Invalid author" error is dropped for personal extensions that are not published to the Store. A tsc ❌ is a defect in the extension: fix it in that repo the same turn (then reinstall it in Raycast), or say plainly ❌ Not fixed and why. A new lint ⚠️ is a new rule: one line in the reply naming it and the extension, fixed only if it changes how the extension works in Raycast (store-only style rules stay as they are unless the user asks). --stamp then accepts that run's findings into the baseline. --checks runs both on demand; --checks --baseline re-records the baseline after the user fixes or changes something.
    • docs files: gh api repos/raycast/extensions/contents/docs/<file> --jq .content | base64 -d, or the listed commit's diff.
    • manual pages: refresh-check.py --diff-manual <page> diffs a page against the snapshot taken at the last stamp (~/Library/Application Support/raycast-skill-refresh/manual/). Read only pages about things this skill covers (extensions, script commands, aliases, hotkeys, Hyper Key, quicklinks, placeholders, deeplinks, AI commands / tools / MCP / skills, import-export, troubleshooting, settings); billing, enterprise and the like need nothing.

    Add each change that matters to the matching reference file as a tagged line ([API 2.7.0], [utils v2.3.2], [MAN 10-07-26]), update the version note at the top of section 0, then refresh-check.py --stamp (it also saves the new manual snapshot). A change that touches one of the user's extensions gets one line in the reply; everything else stays out of it.

1. Decide: Script Command or Extension?

Pick the simplest tool that fits. Choosing wrong adds complexity (Extensions) or hits walls (Script Commands).

Use a Script Command when:

  • The action is a single shell invocation (run a command, fire a magic packet, restart a service, SSH somewhere and run something).
  • No UI is needed beyond a brief HUD-style success/failure toast, or one line of live text in root search (inline mode + refreshTime).
  • No persistent state, no dropdowns populated from APIs, no multi-step forms. (A fixed dropdown argument is fine: "type": "dropdown" with data.)
  • A single .sh (or .py, .swift, etc.) file is enough — no npm install, no build step.

Use an Extension when:

  • The user needs a form, a list, a dropdown populated from an API, or any other interactive UI.
  • The command has multiple steps that the user navigates between.
  • It integrates with an external API and needs to manage tokens via Raycast preferences.
  • It needs caching, debouncing, background refresh on a schedule, a menu-bar item, or tools Raycast AI can call.

Before either, check the built-ins. A URL with a slot is a Quicklink ({argument name="query"}, up to 3 arguments, any app deeplink works). Fixed text is a Snippet. One prompt run on selected text is an AI Command. None of these needs code.

When in doubt, lean Script Command first. Migrating up to an Extension later is easy; downgrading a half-built Extension to a Script Command feels like wasted work.

2. Script Commands

Anatomy

A Script Command is a single executable file with @raycast.* metadata comments near the top. Minimal template:

#!/usr/bin/env bash

# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title My Command
# @raycast.mode compact

# Optional parameters:
# @raycast.icon 🔧
# @raycast.packageName Personal
# @raycast.description Does the thing.
# @raycast.argument1 { "type": "text", "placeholder": "Optional input", "optional": true }
# @raycast.needsConfirmation false

Modes — what each shows:

ModeShowsUse for
compactLAST line of stdout in a toastBest default for personal automations
silentLAST line (if any) as a HUD after the window closesFire-and-forget
inlineFIRST line inside the root-search row, re-run every refreshTimeLive status (favorite it for a dashboard)
fullOutputAll stdout in a terminal-like viewLong output or long-running jobs

Other languages work too — use #!/usr/bin/env python3, #!/usr/bin/env node, .applescript, etc. The metadata format is the same (// comments work too).

Metadata keys the template leaves out (full list: references/script-commands-and-app.md):

  • @raycast.needsConfirmation true — a confirm alert before running. Use it on anything destructive.
  • @raycast.refreshTime 1m — inline only, minimum 10s. inline WITHOUT it silently behaves like compact. Only the first 10 inline commands refresh on their own.
  • @raycast.currentDirectoryPath ~/somewhere — default is the script's own folder.
  • @raycast.iconDark — dark-theme icon. packageName defaults to the script folder's name.
  • Arguments: max 3; type is text / password / dropdown (dropdown needs data: [{"title","value"}]); percentEncoded: true for URL use; arguments are REQUIRED unless "optional": true; values arrive as $1 $2 $3, an omitted optional one as "".

Registration

  1. Place the script in a stable directory (e.g. ~/raycast-scripts/).
  2. chmod +x the file.
  3. In Raycast 2: Settings → Script Commands → Add Script Directory → point at the directory (v1 had it under Settings → Extensions → +). Or run Create Script Command to scaffold one into a directory already added.
  4. Raycast auto-picks up metadata edits — no restart needed.

A script that never shows up fails one of: all 3 required keys present (schemaVersion, title, mode), not executable, or its filename contains .template. (Raycast ignores those on purpose).

Critical gotchas

These bite every time and aren't obvious from Raycast's documentation:

  • Minimal PATH. Raycast runs script commands in a NON-login shell and only appends /usr/local/bin — /opt/homebrew/bin (Apple Silicon Homebrew) is missing. Tools like wakeonlan, jq, ffmpeg, gh, kubectl will fail with "command not found" even though they work in Terminal. Locate them explicitly (or export PATH="/opt/homebrew/bin:$PATH" at the top, or a #!/bin/bash -l shebang):
    TOOL=""
    if command -v wakeonlan &> /dev/null; then
      TOOL="$(command -v wakeonlan)"
    else
      for p in /opt/homebrew/bin /usr/local/bin; do
        [ -x "$p/wakeonlan" ] && TOOL="$p/wakeonlan" && break
      done
    fi
  • SSH calls need BatchMode and ConnectTimeout. Without -o BatchMode=yes, a failed key auth falls back to a password prompt that hangs Raycast indefinitely. Without -o ConnectTimeout=N, an unreachable host blocks until the system-level timeout (~75 seconds). Pattern:
    ssh -o BatchMode=yes -o ConnectTimeout=5 user@host "command"
  • Errors: print the reason LAST, then exit non-zero. A non-zero exit shows a failure toast, and in compact/inline the last output line becomes the error text: echo "Mac Mini is asleep"; exit 1. Chatty tools (zip without -q, progress bars) break compact/silent/inline — quiet them or use fullOutput.
  • macOS /bin/bash 3.2 misparses a quoted heredoc inside $( ). Trigger: syntax error near unexpected token on a line of Python or another language inside X="$(python3 - <<'PY' … PY)", often after adding a line with ' or (. Discrimination: the heredoc is quoted and correct; bash 3.2 still scans its body for quotes and parentheses while matching the $( ), so an odd number of apostrophes in comments (Raycast 2's) or a r'…' string breaks it, and bash -n fails while bash 5 passes. Action: read the heredoc into a variable outside the substitution, read -r -d '' SRC <<'PY' || true … PY, then X="$(python3 -c "$SRC")"; check with /bin/bash -n.
  • Test from Terminal first. In compact mode Raycast suppresses stderr by default. If a script silently fails when run via Raycast but works in Terminal, suspect a PATH or environment difference and add explicit absolute paths. Reproduce with a stripped env: env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/local/bin ./script.sh.
  • Permissions belong to Raycast. Accessibility, Automation, and Full Disk Access prompts name Raycast, not Terminal. A script that works in Terminal can still need its own grant for Raycast.
  • Arguments are optional UI surface. Hardcoding values in a CONFIG block at the top of the script is often cleaner for personal automations than exposing arguments. Use @raycast.argument1 only when variability actually helps.
  • Hotkey- and refresh-triggered scripts must be idempotent — "ensure X is on" beats a toggle that drifts out of sync.
  • Clone-and-deprecate before editing an existing Script Command. Trigger: the user points at an existing script file and asks to modify, fix, rewrite or "make a new version of" it. Discrimination: a brand-new script needs no clone. Every subfolder of a Script Directory is scanned, so a clone left as .sh shows up in root search as a second command; Raycast only loads known extensions, so the .disabled suffix is what "disabled" means here. Action: (1) BEFORE touching it: mkdir -p "<Script Directory>/Deprecated" and cp -p the file to Deprecated/<Original Name> [Deprecated MM-DD-YY].sh.disabled (date from date +%m-%d-%y; a second clone the same day adds h:MM AM/PM), then cmp the two; (2) edit the ORIGINAL file in place, so the alias and hotkey (stored by path) keep working; (3) a comment in the new script names the clone; (4) rollback = rename the clone back to .sh over the original (one mv). Keep the clone until the user has tried the new version and kept it.
  • "Show X in the Raycast prompt / title" means the ROOT-SEARCH ROW, and a title can never change. Trigger: the user asks for live data (the playing track, a count, a status) "in the Raycast prompt", "in the title", or circles the command's name in a root-search screenshot. Discrimination: a request like "in the Raycast prompt, insert the current track" is easily misread as a search box prefilled with it, when the user means the root-search row. What can change in the row: a title NEVER (@raycast.title and package.json titles are static); an extension command's gray SUBTITLE can (updateCommandMetadata({ subtitle })); a script command only via @raycast.mode inline + refreshTime (min 10 s), which means polling. Keeping a subtitle current needs an event source (e.g. Spotify's distributed notification com.spotify.client.PlaybackStateChanged), i.e. a background helper: ask before building it. Action: when the words could mean the row or a box, ask in the user's own terms ("the row in root search, or a box after it opens?") BEFORE building; state plainly that the title itself cannot change and offer the subtitle.
  • A Script Command shows up only under "Files" in root search. Trigger: the user searches a script's title and gets only .sh rows under Files (Raycast's own file index), no command row. Discrimination: two causes look identical on screen, and the log tells them apart: grep -n -A4 'Script discovery complete' ~/Library/Logs/com.raycast.macos/raycast-x-*.log | tail. (a) count: 0 with the folder in permissionDeniedDirectories (plus Script Command directory is not readable: … EPERM) = macOS denied Raycast the folder (a Google Drive / CloudStorage path, after an update or relaunch): nothing from that folder can show. (b) A healthy count with permissionDeniedDirectories: [] = Raycast HAS the script and it is turned off: a script can be valid and discovered and still be hidden because its checkbox is off. The on/off state is in Raycast's encrypted database (no plist, no log line), so you cannot read it; don't rename the script to force a new identity before asking. Headers are rarely the cause, but check @raycast.schemaVersion / title / mode first. Action: (a) Raycast needs access (System Settings ▸ Privacy & Security ▸ Files & Folders, or the prompt on relaunch), then quit and reopen Raycast and re-read the log line; (b) ask the user to tick it: Raycast Settings ▸ Extensions ▸ Script Commands ▸ the list under the folders. Siblings in the same folder are often off too, so name them.
  • No install step: a Script Command runs its source where it lives. Trigger: a Script Command you are writing would run a helper script or tool, and the plan copies or installs it somewhere (install.sh, cp … ~/.local/bin, its own copy of the logic in the .sh). Discrimination: a command that runs an installed copy (e.g. in ~/.local/bin) keeps running the old code after the source is edited, until someone re-installs. Installing is a last resort; editing the source file must be enough. Compiled code (a Swift helper) may still need a build: say so and why in one line. Action: the Script Command runs the source file by absolute path, interpreter first (/bin/zsh "$HOME/<repo>/tools/<x>/<x>"). No install.sh.
  • Driving an app from a Script Command: its URL scheme. Trigger: the user asks for a Raycast command that makes an app do something ("send the current browser tab to …"). Discrimination: when the app answers a <appname>:// URL scheme, prefer it over UI scripting that clicks menus. Talking to another app (such as the browser) from the script with osascript raises a new Automation prompt for Raycast the first time. Action: a silent script that reads what it needs, encodes each value with V="$x" osascript -l JavaScript -e 'ObjC.import("stdlib"); function run(){return encodeURIComponent($.getenv("V"))}', and ends with /usr/bin/open "<scheme>://…"; print the reason and exit 1 when a value is empty or open fails. Test without opening anything: copy the script with sed turning the open line into echo "$LINK", and run the copy under env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/local/bin.
  • "Open in Editor" on an extension opens VLC. Trigger: that Action Panel item launches a video player. Discrimination: it opens the command's src/<name>.ts, and macOS types .ts as MPEG-2 video (public.mpeg-2-transport-stream), so the .ts default app wins; .tsx is com.microsoft.typescript and is fine. Changing a Launch Services default is a persistent setting the agent's classifier refuses. Action: the user sets it once: Finder ▸ Get Info on any .ts ▸ Open with: Visual Studio Code ▸ Change All.

When NOT to use a Script Command

If the script ends up needing any of these, migrate to an Extension instead:

  • Dropdown populated dynamically from an API.
  • Form with multiple fields the user fills in.
  • Cached/debounced state across runs.
  • Branching UI based on results (a list of items the user picks from).

3. Extension Scaffolding & Setup

  • Preferred Method (Duplication): The fastest way to start a new extension is often duplicating the folder of an existing extension. If you do this, you must carefully update the package.json:
    • "name"
    • "title"
    • "description"
    • "author"
    • "commands" array (update name, title, and description).
  • Standard Method: If not duplicating, use npx @raycast/api@latest create, or Raycast's Create Extension command, whose templates include Show List, Show Grid, Submit Form, Menu Bar Extra, Run Script (no-view + HUD), Show Typeahead Results, and AI.
  • Adding a command to an existing extension: Raycast's Manage Extensions → your extension → Add New Command (or Add New Tool, ⌥⌘T) updates package.json and scaffolds src/<name>.tsx in one step.
  • Local Dev: After creating/duplicating, run npm install then npm run dev (ray develop). This builds the extension, imports it into the user's Raycast app, and starts a watcher. The build is finished once you see ready - built extension successfully. The import persists after you stop the watcher, so for a one-off install you can stop ray develop after the first successful build. npm run build (ray build -e dist) with no -o does NOT compile to dist/: it overwrites the installed copy in ~/.config/raycast/extensions/<name>/ from the checkout it runs in (no dist/ folder appears, and the installed source map points at that checkout). For a compile-and-type check that leaves Raycast alone, use npx ray build -e dist -o <scratch dir> or npx tsc --noEmit -p .; to change what Raycast loads, use ray develop.
  • Raycast coming forward is announced, and says whether the user can close it. Trigger: a step that makes Raycast show its window: a dev import (ray develop opens raycast://cli/develop/start with open -g -b, and Raycast fronts itself anyway), or a raycast:// deeplink without launchType=background. Discrimination: the user can't tell whether to press Esc or leave it for the assistant; -g doesn't stop Raycast showing its window. Action: before the step, tell the user that Raycast will appear and to leave it open while it builds; once the build reports success, tell them it is safe to press Esc.
  • One-shot import from a session (no watcher left running). Trigger: you changed an extension and need Raycast to load it, but nobody will watch a dev terminal. Discrimination: npm run build overwrites the installed copy without a proper import (section 3); a ray develop left running keeps a watcher alive in the background. Action: start npm run dev > <scratch>/dev.log 2>&1 in the background, wait with until grep -qE 'built extension successfully|rror' <log>; do sleep 1; done, then pgrep -lf -- '<ext>/node_modules/.bin/ray develop' and kill <that PID> (never a pattern kill). Confirm the install by reading ~/.config/raycast/extensions/<name>/package.json or cmp-ing an asset.
  • Red ⚠️ triangle beside "Command" on every command of a dev extension = its import folder is gone. Trigger: a screenshot of root search with a red triangle on each command of one of the user's extensions; or an installed ~/.config/raycast/extensions/<name>/*.js.map whose sources point into a .claude/worktrees/<name>/ folder that no longer exists. Discrimination: Raycast keeps loading a dev import from the folder ray develop (or ray build) last ran in; a session in a worktree imports from the worktree, and when the worktree is deleted after merging, every command gets the triangle at once. A single command with a warning after a background run is a runtime error instead (section 9) — check the map paths first. Store installs map to ../src/… inside their own folder and never do this. Action: re-import from the MAIN checkout. Removing a dev extension: delete its folder from the repo, then ~/.config/raycast/extensions/<name>/; if a row still shows, ⌘K → Uninstall in Raycast. Renaming the repo folder: every dev import breaks at once, because Raycast loads each by path. After the mv: git worktree repair <each worktree>, re-import EVERY extension with ray develop from the new path (one may fail once on a transient build; re-run it), fix paths hardcoded in the code, then grep -rl the old folder name (and its %20-encoded form) over config files, scripts and launch agents that point at it. Run the move from a script outside the repo, last: the session's own folder disappears.
  • Dev extensions vanish after the Raycast 2 upgrade or migration. Trigger: after a Raycast update or a Migrate from Raycast v1 run, the user's own commands are missing from root search (no triangle, just gone), or grep -n 'Skipping unavailable local node extension' ~/Library/Logs/com.raycast.macos/raycast-x-*.log names their <author>/<name> ids. Discrimination: Raycast 2.6.2's migration stored each dev import's folder as a file:// URL with spaces left encoded (Raycast%20Extensions), stated that literal path, failed with ENOENT and dropped every dev extension in such a folder; Store extensions came across fine, and the built copies in ~/.config/raycast/extensions/<name>/ were still on disk, so the folder looking intact proves nothing. A .rayconfig export carries only Store extensions, never dev ones. A red triangle instead is the deleted-worktree case (previous bullet). A deeplink to such a command logs Command not found: <cmd> in <ext>. Action: re-import each with ray develop (the manual's own advice for custom extensions that didn't import). Then tell the user to check the aliases and hotkeys on those commands (the migration could not attach them while the extension was missing; v2 sets both from the Action Panel in root search), and that a re-run of Migrate from Raycast v1 is additive and skips duplicates.
  • ray develop reaches Raycast 2 only while it is running. Trigger: an import that "succeeds" but nothing shows in Raycast, or a session importing while Raycast is quit. Discrimination: the manual: npm run dev opens the extension in Raycast v2 if it is running and falls back to v1 otherwise, and v1 is gone after the upgrade. Action: check pgrep -qx Raycast first and open -g -a Raycast if it isn't running. Keep @raycast/api on the current major (^2.x, @raycast/utils ^2.x) so the CLI carries v2's dev-command behavior; 1.x to 2.x had no breaking changes (extensions typically move with zero code edits). After an upgrade merges, re-import each extension from main (npm install, then ray develop): until then Raycast keeps the build made with the old API. The CLI needs Node >= 22.22.2.
  • EACCES … node_modules/esbuild/bin/esbuild on npm install / ray develop. Trigger: that error, or [ -x node_modules/.bin/ray ] false while the file exists. Discrimination: the deps are present but lost their executable bits (a node_modules copied or moved between folders); npm install on top does not repair it. Action: rm -rf node_modules && npm ci in that extension.
  • First view command in a no-view extension fails with TS17004 "Cannot use JSX unless the '--jsx' flag is provided". Trigger: adding a .tsx command to an extension whose commands were all .ts. Discrimination: the tsconfig was written for no-view commands and has no jsx setting; the code is fine. Action: add "jsx": "react-jsx" to compilerOptions.
  • A failed rebuild keeps the OLD code running. Trigger: the user says a change "didn't apply" while ray develop is running. Discrimination: the watcher only swaps in a build that compiled; a TypeScript error leaves the last good build loaded, and the only sign is the command icon (bottom-left) in a failed state. Action: read the ray develop terminal output (or npx ray build -e dist) for the error before touching anything else.
  • A subtitle set with updateCommandMetadata outlives every re-import. Trigger: you change a command's subtitle in package.json, re-import, and root search still shows the old text. Discrimination: the runtime value is stored per command and wins over the manifest until that command calls updateCommandMetadata again; ray develop does not clear it, and renaming the command's name would (along with the user's alias and hotkey, so never do that for this). Action: have the command write the new subtitle itself on its next run, and tell the user the old text stays until they run it once.
  • ray lint errors that ray build ignores. Trigger: npx ray lint fails on a personal extension. Discrimination: lint applies Store rules: Invalid author "<handle>" (a handle that is not a Store user) and must NOT have fewer than 2 characters (a one-emoji subtitle like 🔊) both fail lint while ray build / ray develop build fine. Action: for personal extensions, a clean npx ray build -e dist -o <scratch> is the check; fix lint-only findings only when publishing.
  • Other CLI commands (npx ray …): lint, migrate (codemods to the latest API — review the diff), bundle -o <path> (a .rayext file, a way to archive or share a personal extension without the Store). Details in references/platform.md.
  • package.json hygiene: set "platforms": ["macOS"] whenever the extension uses AppleScript, macOS automation apps, or other macOS-only APIs. Never add a "version" field (Raycast has none). Keep raycast-env.d.ts gitignored and let the build regenerate it.
  • Renaming an existing extension: changing the name field (and/or the source folder) makes Raycast treat it as a new development extension. The old entry lingers under Settings → Extensions pointing at the old path and must be removed manually, and preferences (notionToken, etc.) do not carry over to the new identity — they must be re-entered. Keep the command name in package.json in sync with its entry-point filename (src/<command-name>.tsx); raycast-env.d.ts is auto-generated from package.json, so let the build regenerate it rather than editing it by hand.
  • Cannot find namespace 'Arguments' (or 'Preferences') on build. Trigger: ray build fails with TS2503 on Arguments.X / Preferences.X. Discrimination: the types exist — raycast-env.d.ts is generated at the extension root — but a tsconfig.json copied from an older extension only includes src/**/*. Action: set "include": ["src/**/*", "raycast-env.d.ts"]; check this whenever you duplicate a tsconfig.
  • ESM-only npm packages need the whole extension converted to ESM ("type": "module", node16 module resolution, .js extensions on relative imports) — prefer a CommonJS alternative for a personal extension. Global fetch exists (API 1.94+); don't add node-fetch.

4. Extension UI & UX Requirements

When modifying or creating forms and commands, adhere to these standards:

  • Clean Inputs: Do not set defaultValue with placeholder characters (e.g., - for bullet points) unless explicitly requested. Prefer no placeholder ghost text — when a field needs explaining, use the info prop (a tooltip) rather than placeholder. Raycast's Store guidelines and ESLint rule (@raycast/prefer-placeholders) ask for placeholders; for a personal extension, turn that rule off in eslint.config.js if lint complains. The one exception: command arguments REQUIRE a placeholder in package.json — it is the field's only label, so make it one short noun ("minutes"), never a sentence (the box cuts text off at about 17 characters).
  • Timing and progress feedback for fire-and-forget commands: end with a confirmation that says what will happen and when (⏱️ 5 min Timer Set · Ends at 4:58 PM), not just "Started".
  • Confirmations are macOS notifications with the extension's own icon, never Raycast's HUD. Trigger: a command confirms something the user will wait on (a timer set, a job started) or reports an error after its window closed, and you are about to call showHUD or a toast. Discrimination: the HUD is a small Raycast notice at the bottom of the screen that is easy to miss. osascript display notification is no fix either: macOS labels it Script Editor and uses Script Editor's icon instead of the extension's. Quick in-window feedback while a view stays open (an Animated toast during a save) is not this rule. Action: await closeMainWindow({ clearRootSearch: true }) first, then post the notification from the extension's helper app, built so its icon is assets/extension-icon.png (recipe: section 7, Native notifications with the extension's icon).
  • Auto-Close on Success: After successfully completing an action (like adding a Notion page, running a system script, etc.), the extension should disappear and return the user to the root Raycast search, rather than retaining the form on-screen.
    • Import: import { popToRoot } from "@raycast/api";
    • Execute: await popToRoot({ clearSearchBar: true });
    • In a no-view command, showHUD(...) closes the window itself. Call await closeMainWindow() BEFORE slow work (AppleScript, network) so the command feels instant.
  • Remembered values: storeValue on a Form item restores the last submitted value next time — use it for fields the user repeats (a default database, a project). <Form enableDrafts> keeps an unsent form, but popToRoot() skips draft saving.
  • Validation: use useForm + FormValidation.Required from @raycast/utils (spread itemProps.x) rather than hand-rolled error state. onSubmit never fires while any field shows an error.
  • Titles: Title Case for command and action titles (Copy to Clipboard, Open in Browser); commands are <Verb> <Noun> (Search Contacts, Create Task). Don't set navigationTitle on a root command.
  • A list row can't style part of its title, and nothing goes before it. Trigger: the user asks for part of a row's text in bold, in a color, in another font, or for gray text BEFORE the white title ("path in gray, then the name"). Discrimination: List.Item has title (white, always first) and subtitle (gray, always after), both plain strings or { value, tooltip }; there is no color, weight, font or order option (checked in types/index.d.ts). Unicode "bold letters" (U+1D5D4 sans-serif bold) LOOK like a fix and were tried: macOS draws them from STIX Two Math, whose capitals are about 7% shorter than the system font's (10.5 vs 11.3 px, measured with CTFontCreateForString), so the bold word looks smaller and the / before it crowds. Action: say it can't be done and why, then offer real layouts with a table: plain full path as the title, name as title + gray subtitle, or name + gray accessory at the right edge.
  • Reading another app's state for a list: read its file each time the command opens. Trigger: a list needs live data another app keeps (a note app's open-vault setting, a plugin's recent-files list). Discrimination: no watcher and no cache of your own: the file IS the source, and useCachedPromise already shows the last result at once and re-reads it behind. Action: read the file in the hook's function; skip entries whose folder is gone on disk; keep the reader in src/lib/ with no Raycast imports so node --test --experimental-strip-types tests it on fixtures.
  • No flicker: pass isLoading to the top-level view and don't render an empty list before data arrives; use List.EmptyView only for a real empty state.
  • Toasts for long work: const t = await showToast({ style: Toast.Style.Animated, title: "Saving…" }), then set t.style / t.title to Success or Failure in place. A toast shown while the window is closed turns into a HUD.
  • Icon Changes: If an extension's icon is modified (e.g., extension-icon.png), Raycast must be fully restarted to reflect the change — the watcher rebuild alone is not enough. Tell the user first, quit Raycast gracefully (a normal quit, never a kill), then open -g /Applications/Raycast.app. The icon file must be a real PNG with alpha channel (not a renamed JPEG) — use sips -s format png source.jpg --out assets/extension-icon.png to convert if needed. A dark-mode variant is just icon@dark.png next to icon.png. Changed list/asset icons (not the extension icon) refresh without a restart through the dev action that clears the local assets cache.
  • Icons fill their whole canvas. Trigger: you create, convert or replace an extension or command icon, in any repo. Discrimination: Raycast draws every icon at the same box size, so a PNG with a transparent margin looks smaller than its neighbours (an icon at 87% of its canvas beside icons at 96 to 100%). A non-square logo fills its longest side; that is enough. Action: draw the tile or art edge to edge, with no padding inside the PNG; measure before shipping (opaque width or height at least 95% of the canvas).
  • Icon transparency & the chat-attachment JPEG trap. When the user attaches an image in chat, some AI coding platforms silently convert it to JPEG, which strips the alpha channel — transparent areas become black. sips -s format png re-encodes the pixels but cannot restore lost transparency. To fix icons with baked-in black corners, use a Python flood-fill from the four corners (Pillow: Image.convert("RGBA"), then flood-fill near-black pixels reachable from (0,0), (w-1,0), (0,h-1), (w-1,h-1) with (0,0,0,0)). Better yet: ask the user for a filesystem path to the original PNG instead of a chat attachment — e.g., "drop it in ~/Downloads/ and give me the path."

5. Extension Data Fetching & State

  • Pick the hook by data source (all from @raycast/utils; options in references/utils-hooks.md):

    Data comes fromUse
    An async function (SDK call, Notion, files)useCachedPromise — shows the last result instantly, then refreshes
    An HTTP endpointuseFetch (headers/body in options, mapResult to reshape)
    A CLI / binaryuseExec(file, args, { parseOutput })
    A local SQLite DB (Notes, Messages)useSQL — return its permissionView first
    Result can't be JSON-serialized, or must never be staleusePromise
    A no-view command or AI tool (no hooks allowed)withCache(fn, { maxAge }) / executeSQL / plain await
  • useCachedPromise results must be JSON-serializable. A Date comes back as a string on the next launch. Store ISO strings and convert when rendering.
  • Hooks already show a failure toast. The default onError shows "Failed to fetch latest data" with a Retry action; pass failureToastOptions: { title } to reword it. Passing onError replaces the toast, so show your own inside it.
  • Search-as-you-type: setting onSearchTextChange on a List turns OFF native filtering. Pass searchText into the hook's args, add throttle, and set keepPreviousData: true so the list doesn't flash empty between keystrokes. If you only want to watch the text, pass filtering={true} explicitly.
  • Pagination: make the hook's function return async ({ page, cursor }) => ({ data, hasMore, cursor }) and pass the returned pagination to <List pagination={pagination}>. Only page 1 is cached.
  • Mutations: prefer await mutate(apiCall(), { optimisticUpdate: (d) => … }) over calling revalidate() by hand — it updates the UI at once, rolls back on error, and revalidates after. Wrap it in try/catch: it rethrows.
  • Where to keep state:

    StoreForNotes
    LocalStorage / useLocalStoragesmall durable user dataasync, encrypted, string | number | boolean values
    Cache / useCachedStatedisposable fetched data, instant first paintsync, strings only, 10 MB LRU
    files in environment.supportPathanything largeplain fs

    The dev action "Clear Local Storage & Cache" wipes both of the first two.

  • Recently used first: useFrecencySorting(items, { key }), calling visitItem(item) in the primary action.
  • Dynamic Forms: For fields like Notion's select, multi_select, or integrations with existing playlists, dynamic Form.TagPicker or Form.Dropdown components should be populated via useCachedPromise rather than hardcoding.

6. Extension Automation & Local Execution

When an extension requires triggering local MacOS functionality (where external APIs fall short or aren't applicable):

  • AppleScript: prefer runAppleScript from @raycast/utils, passing values as an args array instead of splicing them into the script text (no quoting bugs). The script reads them with on run argv / item 1 of argv. For structured results use language: "JavaScript" and return JSON; humanReadableOutput defaults to true and flattens lists. Default timeout is 10 s. Raw execFileSync("/usr/bin/osascript", ["-e", script]) is still fine; never execSync("osascript -e '...'").
    • Example: await runAppleScript('on run argv\n display notification (item 1 of argv)\nend run', [message])
  • Handy system calls (@raycast/api): getSelectedText(), getSelectedFinderItems(), getFrontmostApplication(), open(target, app), showInFinder, trash (moves to Trash, never deletes). The first two REJECT when nothing is selected or Finder isn't frontmost — wrap in try/catch and show a failure toast.
  • Opening a URL in ONE exact app: open -b <bundle id>, never open(url, "<bundle id>"). Trigger: a command must open a page in a specific browser (Chrome vs Chrome Beta, Edge vs Edge Beta). Discrimination: Raycast's open(url, "com.google.Chrome") can launch Chrome Beta: it does not match the bundle id exactly. An app PATH (/Applications/Google Chrome.app) is exact too; no app at all means the default browser and is fine. Action: execFile("/usr/bin/open", ["-b", bundleId, url]) through a small openIn(url, bundleId) helper; verify the id with osascript -e 'POSIX path of (path to application id "<id>")'.
  • Clipboard Operations: Always use the native Clipboard utilities from @raycast/api (e.g., Clipboard.copy(text)) rather than custom bash scripts. { concealed: true } keeps secrets out of clipboard history; Clipboard.copy({ file: path }) copies a file; Clipboard.readText({ offset: 1 }) reads back into history.
  • Native Mac sounds (for alarms and alerts; confirm the choice with the user before building): classic short alerts in /System/Library/Sounds/*.aiff (Glass, Hero, Ping, Submarine…), longer modern tones in /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/Modern/*.m4r (Circles, Chord, Pulse…). Preview with afplay <file> or System Settings → Sound → Alert sound. To loop a sound until closed, set NSSound.loops = true in a Swift helper.
  • Commands calling commands: launchCommand({ name, type: LaunchType.Background, context }) — e.g. a form refreshing its menu-bar item after saving. The target reads props.launchContext.
  • Reconsider scope: If the extension's entire purpose is to fire a single shell command, a Script Command is simpler. Extensions earn their weight when there's actual UI or state involved.

7. Native Binaries & Asset Bundling

When an extension shells out to a compiled binary (Swift, Go, Rust, etc.):

  • Put binaries in assets/. Raycast copies assets/ to the installed extension directory (~/.config/raycast/extensions/<name>/assets/). Files outside assets/ (e.g., swift/, bin/) are not copied and will fail with "No such file or directory" at runtime.
  • Reference via environment.assetsPath. Use path.join(environment.assetsPath, "binary-name") — never construct paths relative to the source tree with ...
  • Use execFileSync, not execSync. The user's workspace path often contains spaces (e.g., Raycast Scripts/…, a Google Drive path). execSync(cmd) runs through a shell and breaks on unquoted spaces. execFileSync(path, args) bypasses the shell entirely and handles spaces safely. (In a view command, useExec(path, args) does the same with caching.)
    // ✅ Correct — handles spaces, no shell quoting needed
    const stdout = execFileSync(BRIDGE_PATH, ["--search", query], { encoding: "utf8" });
    
    // ❌ Breaks on paths with spaces
    const stdout = execSync(`${BRIDGE_PATH} --search "${query}"`, { encoding: "utf8" });
  • Ensure execute permission. After compiling or copying the binary, run chmod +x on the asset.
  • Work that must outlive the command (timers, alarms, watchers). Trigger: a command has to act minutes later ("remind me in 5 min", "alert when done"). Discrimination: a no-view command is unloaded as soon as its promise resolves, and background interval is approximate and at least 1m — neither can hold a precise countdown. Action: launch a helper from assets/ that runs on its own: an app bundle via execFile("/usr/bin/open", ["-n", "-g", "-a", app, "--args", ...args]) (runs under launchd; -n = one instance per job, -g = no focus steal), or a bare binary via spawn(bin, args, { detached: true, stdio: "ignore" }).unref() when it posts no notifications. Then confirm with a notification (section 4) and return. The helper owns the wait (use a wall-clock deadline, e.g. DispatchQueue.main.asyncAfter(wallDeadline:), so Mac sleep doesn't delay it) and any UI. Verify it survives: its PPID is 1 after the parent exits.
  • Listing and cancelling what detached helpers are doing. Trigger: the user wants to see or stop jobs a command started ("show my running timers", "cancel that timer"). Discrimination: the extension cannot remember them itself — the command that started the helper is already unloaded, LocalStorage would go stale when a helper ends or crashes, and pattern-killing processes is banned. Action: the HELPER keeps one record per live job, ~/Library/Application Support/com.example.raycast.<name>/running/<pid>.json (label, endsAt epoch ms, flags, a ringing-style state it rewrites when the state changes), written atomically at start and removed on close; it handles SIGTERM with signal(SIGTERM, SIG_IGN) + DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) whose handler runs the normal close path (stop sound, remove record, terminate). A view command lists the records, checks each PID with one ps -p <pids> -o pid=,comm= call (keep only rows whose comm is the helper binary; delete records whose PID is gone), ticks a setInterval every second for a live countdown, and cancels by process.kill(pid, "SIGTERM") ONLY after re-confirming that PID is still the helper. Give it keywords matching the start command so it shows up under the same alias letters. Test headlessly: start a job with open -n -g -a, list, cancel, list again (empty), and pgrep -lf -- "<App>.app/Contents/MacOS/<binary>" shows nothing. Cancel ONLY the PIDs your test started (pgrep -x <binary> before and after launching, act on the difference): the user may have a real job running, and a test that cancels "everything listed" kills it.
  • Native notifications with the extension's icon. Trigger: an extension must post a macOS notification (section 4 says confirmations are notifications). Discrimination: Raycast's API has no native-notification call; a bare binary cannot use UNUserNotificationCenter (it needs a bundle id); osascript display notification works but shows as Script Editor. Action: ship the helper as assets/<Name>.app, built by a script in the repo (swift/build.sh) that (1) compiles into Contents/MacOS/ with -framework AppKit -framework UserNotifications, (2) makes Contents/Resources/AppIcon.icns from assets/extension-icon.png (sips -z into an .iconset at 16–512 plus @2x, then iconutil -c icns), (3) writes an Info.plist with a com.example.raycast.<name> id, CFBundleIconFile = AppIcon, LSUIElement true, and (4) ad-hoc signs it (codesign --force --sign -). Launch it with open -n -g -a (above), never by exec'ing the inner binary. In Swift: set a UNUserNotificationCenterDelegate whose willPresent returns [.banner, .list], requestAuthorization(options: [.alert]), add the request, and only exit from the add completion (a --notify-only flag for error notices with no job). The first post shows macOS's one-time Allow prompt for the app — expected, say so. Verify: codesign --verify --deep on the INSTALLED copy (~/.config/raycast/extensions/<name>/assets/), and the helper exits after posting (pgrep -lx <binary>).
  • Surface errors visibly. Don't catch and return empty results — throw or show a showToast(Toast.Style.Failure, ...) (or showFailureToast(error) from @raycast/utils) so the user sees what went wrong instead of a blank list.

8. List Navigation Patterns

  • Detail panel (isShowingDetail): Shows contact/item metadata alongside the list. Good for "see everything at a glance" without leaving the list. Fields rendered as List.Item.Detail.Metadata.Link are clickable with the mouse but not keyboard-focusable — they're read-only display. Don't combine accessories with isShowingDetail; use Metadata.TagList for colored tags inside the panel.
  • Drill-in navigation: Use Action.Push as the primary action to push a second List where each field is its own List.Item with actions. This gives full keyboard navigation (↑↓ between fields, Enter to act, Esc to go back). Combine both patterns: detail panel for visual scanning + Enter to drill in for keyboard interaction. Use onPop on Action.Push to revalidate() the parent when the child changed something.
  • Filter dropdown: searchBarAccessory={<List.Dropdown tooltip="…" storeValue onChange={…}>} gives a ⌘P filter in the search bar that remembers its choice.
  • Keyboard shortcuts: Add shortcut props to frequently-used actions. Prefer Keyboard.Shortcut.Common.* (Copy, Open, Refresh, Remove, Pin, Edit…) over hand-picked combos — they match the rest of Raycast. Shortcuts show automatically in the action panel (⌘K). Add shortcut hints as Metadata.Label items at the bottom of a detail panel for persistent visibility. Raycast ignores ⌘K, ⌘W, and ⌘Esc. A custom shortcut on the first two actions works but is not displayed (they are ↵ and ⌘↵, or ⌘↵ and ⌘⇧↵ in a Form).
  • Action ordering: The first action in the ActionPanel becomes the primary action shown in the bottom bar and triggered by Enter. Order actions by frequency of use. Group with ActionPanel.Section; a submenu title ends with … (Set Priority…).
  • Destructive actions (delete, etc.): Use confirmAlert with Alert.ActionStyle.Destructive for irreversible operations. Style the action itself with style={Action.Style.Destructive} so it renders in red. After the mutation, call revalidate() (from useCachedPromise) to refresh the list. Pattern:
    <Action
      title="Delete Contact"
      icon={{ source: Icon.Trash, tintColor: Color.Red }}
      style={Action.Style.Destructive}
      shortcut={Keyboard.Shortcut.Common.Remove}
      onAction={async () => {
        const confirmed = await confirmAlert({
          title: "Delete Contact",
          message: `Permanently delete "${name}"?`,
          primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
        });
        if (confirmed) {
          deleteContact(id);
          await showToast(Toast.Style.Success, `Deleted "${name}"`);
          revalidate();
        }
      }}
    />
  • Revalidating after mutations. Destructure revalidate from useCachedPromise and call it after any write operation (delete, update, create) to refresh the list without a full remount — or use mutate (section 5).

9. Background, Menu Bar, and Launch Entry Points

  • Nothing polls — event-driven first, a schedule last. Trigger: you are about to add anything that runs repeatedly or waits: a manifest "interval", a setInterval/setTimeout loop outside an open view, a launchd StartInterval, a watcher, a helper that checks state. Discrimination: "it's only a few milliseconds" is NOT the test: even a 4 ms once-a-minute check is waste when, most of the time, the thing it checks for does not exist. Work that runs only while the user is looking (a 1 s countdown inside an open List) is fine; work that runs while nothing is happening is the defect. Action: before writing it, list the EVENTS that change the state and trigger the update from them: the command that causes the change (launchCommand({ name, type: LaunchType.Background }) after it acts), the helper process at its own milestones (open -g "raycast://extensions/<author>/<ext>/<cmd>?launchType=background"; Raycast prompts once, Always Allow stores alwaysAllowsExternalLaunches per command, not visible in Settings), a launchd WatchPaths on the one folder that changes, never a StartInterval. Use a schedule only when no event exists, say so to the user with the measured cost, and pick the longest interval that works. In the reply, state what runs when idle (ideally: nothing).
  • Live info in root search: updateCommandMetadata({ subtitle: "3 unread" }) inside a no-view command sets the text next to that command (it can only update ITSELF; null clears it). Refresh it on events (rule above): background-launch it from whatever changed the state. Branch on environment.launchType === LaunchType.Background to skip UI work. A manifest "interval" (min 1m, approximate, with a per-command toggle) is the last resort, never the default. Root search cannot list items, so a live list becomes a subtitle plus a view command Enter opens (launchCommand with LaunchType.UserInitiated).
  • Test a background command without waiting: the dev actions Run in Background on the command in root search; the built-in Extension Diagnostics command lists last runs. Errors show as a warning icon on the command (a triangle on EVERY command of a dev extension is a missing import folder instead — section 3).
  • Menu-bar commands ("mode": "menu-bar") — Trigger: writing or fixing a MenuBarExtra. Discrimination: they are not long-lived apps; Raycast loads, renders, and unloads them, and after a Raycast restart it restores the last render WITHOUT running your code. Action: set isLoading true while fetching and ALWAYS flip it to false (in finally), or the command never unloads; read the last value from Cache synchronously for an instant first paint; return null to hide the item; never put two identical MenuBarExtra.Items at one level (their handlers misfire). An item with no onAction renders disabled, so it works as a section label. alternate gives an ⌥-held variant. On macOS Tahoe a missing menu-bar item can be the OS hiding it: System Settings → Menu Bar → Allow in the Menu Bar must include Raycast, even when the command is enabled in Raycast.
  • Arguments (up to 3 per command, text / password / dropdown) show as inline fields in root search; read them from props.arguments, typed as LaunchProps<{ arguments: Arguments.MyCommand }>. All values are strings.
  • Aliases are invisible to extensions, and argument placeholders are static. Trigger: the user wants help text that names an alias, or any text in the argument box that changes at runtime. Discrimination: there is no API that reads aliases (they live in Raycast's encrypted DB), placeholder is fixed in package.json, and the argument box cuts text off at about 17 characters. Only the command subtitle can change at runtime, via updateCommandMetadata, which updates the CALLING command only and takes effect after it runs. Action: keep the placeholder short ("minutes"); put the reminder in the subtitle, fed by a textfield preference that holds the alias.
  • Fallback commands: read props.fallbackText to prefill from whatever the user typed in root search.
  • Deeplinks: raycast://extensions/<author>/<extension-name>/<command-name>?arguments=<url-encoded JSON>&context=<url-encoded JSON>&launchType=background. Get the exact URL from the command's Copy Deeplink action (⇧⌘C) rather than building it by hand; inside code use createDeeplink({ command, arguments }) from @raycast/utils. Any automation script or helper app can open -g this URL.
  • Deeplinks from outside Raycast: one prompt, then silent — and the choice is invisible. Trigger: a helper app or script launches a command via raycast://…?launchType=background and "nothing happens", or the user can't find an "Always Allow External Launches" setting. Discrimination: the first launch shows a Raycast confirmation and the command does NOT run until answered; clicking Always Allow stores alwaysAllowsExternalLaunches for that one command, which Raycast Settings never shows (not in the extension's command row, not in its detail pane). A command that never runs even after allowing is a wrong URL (author = package.json author, then extension name, then command name). Action: tell the user a prompt will appear once and to click Always Allow; verify WITHOUT screenshots through a side effect the command leaves (e.g. for a command that prunes stale records: drop a record with a dead PID into its folder, open -g the link, the record is gone within seconds if it ran).

10. Production Mode & Publishing

  • The "Development" label is inherent to all extensions loaded via ray develop. There is no local production install — the only way to remove it is ray publish, which submits the extension to the Raycast Store (requires ray login, public listing, and review).
  • package.json metadata semantics: The title field in the top-level package.json is what Raycast shows as the extension subtitle next to the command name (e.g., "Search Contacts Search the Contacts app"). The commands[].title is the command name itself. Change title when the user asks to rename what appears next to the command. A command-level subtitle overrides it per command and is also searchable; command-level keywords add root-search synonyms.

11. Debugging

  • console.log output goes to the terminal running npm run dev, not to Raycast. If nothing appears there, turn on the dev option "Use file logging instead of OSLog".
  • Importing without leaving a watcher running. Trigger: you changed an extension and Raycast must load it, but a live npm run dev would block the turn. Discrimination: npm run build overwrites the installed files but is not the import (section 3), and macOS has no timeout command. Action: npx ray develop > "$TMPDIR/raydev.log" 2>&1 & P=$!, poll the log for ready (1 s steps, ~30 tries), then kill $P — that one PID, never a pattern kill. The import persists after the watcher stops. A bundled .app in assets/ arrives intact (check codesign --verify --deep on the installed copy).
  • "Command not found: <name> in <extension>" while the installed manifest HAS the command. Trigger: a newly added command is missing from root search, and its deeplink logs that error, yet ~/.config/raycast/extensions/<ext>/package.json lists it and <name>.js sits beside it. Discrimination: re-importing does not fix it: Raycast keeps a stale registration for that command NAME (a "Disable Command" on it is the likely cause, and Raycast's settings DB is encrypted, so it can't be read). A red ⚠️ triangle is the deleted-worktree case instead. Action: rename the command's name (and its src/<name>.ts and icon file), re-import, then confirm with open -g "raycast://extensions/<author>/<ext>/<new name>" + grep commandExited in the log. The title can stay; only the internal name changes.
  • Dev-only behavior: gate it on environment.isDevelopment, not NODE_ENV.
  • Breakpoints / live props: npm i -D react-devtools@6.1.1, re-run npm run dev, open the command, press ⌘⌥D.
  • "Command Out of Memory": choose "Reload with Memory Reporting"; captureMemorySnapshot(label) marks points to compare. Usually an unpaginated list or a huge cached blob.
  • Raycast's own logs: the "Reveal Raycast Logs" / "Copy Raycast Logs" commands, or ~/Library/Logs/com.raycast.macos/raycast-x-<date> <time>.log (Raycast 2, one file per launch). They are the only readable trail of extension state: Raycast 2's own databases (~/Library/Application Support/com.raycast.macos/*.db, node_extensions.db included) are encrypted, so sqlite3 says "file is not a database". Useful greps: Skipping unavailable local node extension (a dev import Raycast dropped), Command not found (a deeplink to a command that isn't loaded), No installed local extension for node command (an alias or hotkey left without its command), Action performed: (every run, see section 16). Verify a deeplink headlessly: a wrong one logs Command not found: <slug> in <extension> within a second, a right one logs nothing.
  • Runtime facts: every extension runs in Raycast's own bundled Node (not Homebrew's), with the same stripped PATH as script commands. Extensions are not sandboxed for files, network, or child processes, and macOS privacy grants belong to Raycast.

12. Extension Preferences & Authentication

When building an extension that requires an external API (like Notion or Spotify):

  • Add the required secrets/tokens to the preferences array in package.json ("type": "password", "required": true — Raycast then blocks the command with a setup form until it is filled). Read them with getPreferenceValues<Preferences.MyCommand>().
  • Put the setup steps in help.md next to package.json: Raycast renders it beside the required-preferences form, so the instructions are there when the user needs them. On an auth error, offer an Action that calls openExtensionPreferences().
  • User Instructions: You must still proactively remind the user to configure the token when they first load the extension. For example, provide a short snippet:

    ⚠️ Integration Required You need to provide this extension with an API Token.

    1. Go to [Link to API dashboard]
    2. Create a new token.
    3. Open Raycast, run this new command, and paste the Token in the preferences when prompted.
  • Never ask for, store or show a command's alias. Trigger: you are about to add a preference like "Silent Timer Alias", or a subtitle / hint that names an alias ("Use ts for NO sound"). Discrimination: extensions cannot read aliases: there is no API, and Raycast keeps them only in its SQLCipher-encrypted databases (~/Library/Application Support/com.raycast.macos/settings*.db, main.db; sqlite3 says "file is not a database"), not in the plist or the logs. A typed-in copy goes stale the moment the user swaps aliases (moving t to the silent timer makes "Use ts" wrong). Action: name the COMMAND instead (Start Loud Timer with subtitle 🔊, Start Silent Timer with 🔇 No sound), put the difference in the title, and add no alias preference. Never try the keychain key to decrypt the database (a permission prompt, and fragile).
  • OAuth instead of a pasted token: OAuthService.github|linear|slack|asana({ scope }) work out of the box with Raycast's hosted apps; google|jira|zoom need your own client ID. Wrap the command with withAccessToken(service)(Command) and read getAccessToken().token. Pass personalAccessToken: prefs.token to let a pasted token skip OAuth. Don't save source files mid-login while ray develop runs — the hot reload breaks the flow.

13. Notion-backed extensions

A common pattern is a collection of small extensions that each wrap a Notion database behind a Raycast form (one isolated subfolder + package.json per extension). The token lives in a notionToken password preference; the form reads it via getPreferenceValues and builds new Client({ auth }).

  • Per-extension config when duplicating: update name, title, description, author (your Raycast handle), and the commands array. Then set the databaseId const in the .tsx — extract the 32-char hex id from the database URL (notion.so/.../<id>?v=...).
  • Fetch options dynamically, in one call. Pull every dynamic field (tags, status, …) from a single databases.retrieve inside one useCachedPromise — don't issue one retrieve per field. Find properties by type (and name) rather than a hardcoded key, so a renamed column still resolves; locate the title property with Object.values(db.properties).find(p => p.type === "title").
  • multi_select with create-new. Form.TagPicker only selects from items you provide — it has no freeform entry. To let the user create new options, pair the picker (existing options, with value/title set to the option name) with a companion Form.TextField for comma-separated new names. On submit, merge + dedupe both lists and send multi_select: names.map(name => ({ name })). Notion matches existing options by name and auto-creates any that don't exist — sending by name (not id) is what makes create-on-write work.
  • single select / status. Use Form.Dropdown with a leading <Form.Dropdown.Item value="" title="None" /> so the field defaults to unset; skip writing the property when the value is empty. Handle both the select and status property types: { select: { name } } vs { status: { name } }.

Auth & the "Could not find database" error

APIResponseError: Could not find database with ID … make sure the relevant pages and databases are shared with your integration almost always means the integration is not connected to the database, not that the ID is wrong — Notion returns the same object_not_found message for "doesn't exist" and "not shared."

  • Confirm the DB exists and the ID is right by fetching it through the user's own account (Notion MCP notion-fetch, or the Notion app) — that bypasses the integration's permissions.
  • The fix is manual (an integration cannot grant itself access): in Notion open the database → ⋯ → Connections → Add connections → select the integration whose token is in Raycast.
  • Verify a token before the user pastes it into Raycast:
    curl -s -o /dev/null -w "%{http_code}\n" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Notion-Version: 2022-06-28" \
      "https://api.notion.com/v1/databases/<id>"
    200 = valid + shared; 404 = not shared / wrong id; 401 = bad token.
  • The token is stored only in Raycast's encrypted preferences — you can't set it from the shell, and never hardcode it in source. (Note the rename caveat in section 3: a renamed extension needs the token re-entered.)

14. AI Extensions (tools Raycast AI can call)

Trigger: the user wants to ask Raycast AI to do something with one of their extensions ("@contacts find…"), or mentions tools, ai.yaml, or evals. Discrimination: a tool is NOT a command — it never shows in root search; only the AI calls it. A prompt run on selected text is an AI Command (no code). Action:

  • Add a tools entry in package.json (name, title, description) → src/tools/<name>.ts default-exports an async function taking ONE input object. JSDoc on the function and each input field is what the AI reads — say formats (ISO 8601 dates) and where IDs come from.
  • Guard writes: export const confirmation: Tool.Confirmation<Input> = async (input) => ({ message: … }). Return undefined to skip it for safe inputs.
  • Domain rules go in ai.yaml (instructions: |) next to package.json. Don't write "You are an assistant" — several extensions share one chat.
  • Evals: capture a working run with the Copy Eval action in AI Chat, paste into ai.yaml evals:, run npx ray evals.
  • Needs Raycast Pro. Share code between a command and a tool with environment.entryPointType ("command" / "tool").
  • Raycast AI also reads Agent Skills from ~/.claude/skills (top level). It silently skips a skill whose description is over 1024 characters or whose folder name differs from its name.

15. Opening Claude Desktop (Claude Code) in a folder

Trigger: the user wants a Raycast command that opens Claude Code in a folder/repo ("Raycast hotkey to open this project in Claude"). Discrimination: it is a script command, not an extension — one open of the desktop app's deep link claude://code/new?folder=<percent-encoded absolute path> (verified with Claude.app 2.110.1). Don't shell out to the claude CLI (that is the terminal app) and don't open -a Claude <folder> (lands in Cowork). Action:

#!/bin/bash
# @raycast.schemaVersion 1
# @raycast.title Open in Claude Desktop
# @raycast.mode silent
# @raycast.packageName Claude
# @raycast.argument1 { "type": "text", "placeholder": "absolute folder path" }
V="$1"; [ -d "$V" ] || { echo "not a folder: $V"; exit 1; }
open "claude://code/new?folder=$(V="$V" osascript -l JavaScript -e 'ObjC.import("stdlib"); function run(){return encodeURIComponent($.getenv("V"))}')"

Absolute paths only (~ is not expanded). A "current Finder selection" variant needs tell application "Finder" — that triggers a one-time Automation permission prompt, so prefer the argument form or a fixed list of favorites unless the user asks. Prefill the first message with &q=<encoded text>.

16. What Raycast's app exposes, and what it never will

Trigger: a task needs Raycast's own state or UI (the user's Quicklinks, Snippets, aliases, hotkeys, enabled commands, whether the bar is open) or asks for a Raycast feature that may already be built in. Discrimination: there is no API for any of it, and guessing wastes a build; the facts below were read from Raycast 2.6.3's bundle and logs. Action: use the matching row.

  • User data: the databases are encrypted (section 11). Readable exports the user makes: Search Quicklinks / Search Snippets ▸ ⌘K ▸ Export → ~/Downloads/Quicklinks <date>.json / Snippets <date>.json (plain JSON: name, link or text, keyword). The full Settings export .rayconfig starts RAYCFG3\n + a 4-byte length + a gzip JSON header (exportedAt, encryption: {iv, salt}), then a password-encrypted body. Never ask for that password.
  • Built-in action shortcuts can't be rebound. Disable Command is ⌃⇧⌘D, hard-coded in the app's shortcut table (disableCommand:{mac: ctrl.shift.cmd.d}); there is no setting. A remap needs an external key-remapping or automation tool. A command switched off by accident is re-enabled in Settings ▸ Extensions.
  • The search bar is not the active app. Opening it leaves another app frontmost (the log says App Active: false), so an automation tool's "only in Raycast" app-specific trigger never fires — and conversely, any global hotkey from an automation tool gated on the underlying frontmost app (e.g. a ⌘V paste formatter for one editor) WILL STILL FIRE when Raycast's bar is opened over that app. Detect the bar in both cases via an ON-SCREEN window owned by Raycast at window layer 8 (CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID): w.js["kCGWindowOwnerName"] && w.js["kCGWindowOwnerName"].js === "Raycast" && w.js["kCGWindowLayer"].js === 8; main bar 900×570, compact 530×446). Raycast's AI/Notes windows are layer 0.
  • Raycast Focus is built in (Start / Toggle / Pause / Complete Focus Session). Deeplinks: raycast://focus/start?goal=<title>&categories=<a,b>&duration=<SECONDS>&mode=block|allow, raycast://focus/toggle, raycast://focus/complete. Built-in category ids: social (x.com, twitter.com, linkedin.com…, plus the Twitter app), messaging, gaming, shopping, streaming, news, travel; no email category ships, so mail needs one the user creates. A start deeplink is ignored while a session runs.
  • No Large Type. Raycast has nothing that shows text huge over the screen; a small helper app is needed for that.
  • 100 commands per extension (ray lint: "must NOT have more than 100 items"); ray build still passes above it, so count before adding.
  • What the user ran in Raycast (history), from the log (Raycast 2.8). Every run logs [actions] Action performed: <Extension title>: <verb>, which names only the extension; the item comes from the lines right after it: openApplication → \tFrontmost App: <bundle id>; runScript → Running script { + path: "…"; openCommand of a view command → openRaycastCommand took … { + cmd: "<command title>" (absent from older logs, before about October 2026); a no-view command → commandExited { + entryPoint: "[dev_/]<owner>/<name>/<command>". A command can launch a sibling in the background that exits FIRST (e.g. a timer extension's running-timers), with no marker anywhere; within a 2 s burst the LAST exit of that extension is the one the user ran. Raycast writes <redacted_username> for the user name inside paths and bundle ids; put it back. Not recoverable: quicklinks, snippet pastes, File Search and no-view built-ins (System Actions) log no name. The hidden default raycast_EnableRootSearchLogs only adds search terms and timings, never the pick; index/recent-files.json keeps each file's latest use only. The one full record is the encrypted frecency.db (frecency.root_search: item_id, opened_at), key in the login keychain: reading it means a keychain prompt, so it is the user's call.
  • Favorites have no API. Add to Favorites is ⌘⇧F on the command's root-search row (backend add-favorite → favoriteOrder), logged as Action performed: <Extension>: addFavorite, which is the check that it happened. Pressing it by script means typing into the bar: guard EVERY keystroke on the bar really being up (layer-8 window above; System Events' frontmost of process "Raycast" read false while the bar was showing), and skip it while the user is using the Mac.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.