| name | raycast-extension |
| description | Build Raycast extensions with React and TypeScript. Use when the user asks to create a Raycast extension, command, or tool. |
Raycast Extension Development
Quick Start
- Create project structure
- Write package.json with extension config
- Implement command in src/
- Run
npm install && npm run dev
Project Structure
my-extension/
├── package.json # Extension manifest + dependencies
├── tsconfig.json # TypeScript config
├── .eslintrc.json # ESLint config
├── raycast-env.d.ts # Type definitions (auto-generated)
├── assets/
│ └── extension-icon.png # 512x512 PNG icon
└── src/
└── command-name.tsx # Command implementation
package.json Template
{
"name": "extension-name",
"title": "Extension Title",
"description": "What this extension does",
"icon": "extension-icon.png",
"author": "author-name",
"categories": ["Productivity", "Developer Tools"],
"license": "MIT",
"commands": [
{
"name": "command-name",
"title": "Command Title",
"description": "What this command does",
"mode": "view",
"keywords": ["keyword1", "keyword2"]
}
],
"dependencies": {
"@raycast/api": "^1.83.1",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.5.4",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.5.4"
},
"scripts": {
"build": "ray build --skip-types -e dist -o dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint"
}
}
Command Modes
| Mode | Use Case |
|---|
view | Show UI with Detail, List, Form, Grid |
no-view | Background task, clipboard, notifications only |
menu-bar | Menu bar icon with dropdown |
Hotkey Configuration
Add to command in package.json:
"hotkey": {
"modifiers": ["opt"],
"key": "m"
}
Modifiers: cmd, opt, ctrl, shift
Note: Hotkeys in package.json are suggestions. Users set them in Raycast Preferences → Extensions.
tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*", "raycast-env.d.ts"]
}
.eslintrc.json
{
"root": true,
"extends": ["@raycast"]
}
Command Patterns
No-View Command (Background Task)
import { showHUD, Clipboard, showToast, Toast } from "@raycast/api";
export default async function Command() {
const toast = await showToast({
style: Toast.Style.Animated,
title: "Working...",
});
try {
const result = await doSomething();
await Clipboard.copy(result);
await showHUD("✅ Done!");
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed";
toast.message = error instanceof Error ? error.message : "Unknown error";
}
}
View Command (List)
import { List, ActionPanel, Action } from "@raycast/api";
export default function Command() {
return (
<List>
<List.Item
title="Item"
actions={
<ActionPanel>
<Action.CopyToClipboard content="text" />
</ActionPanel>
}
/>
</List>
);
}
View Command (Detail)
import { Detail } from "@raycast/api";
export default function Command() {
const markdown = `# Hello World`;
return <Detail markdown={markdown} />;
}
Performance & Caching
Instant Load Pattern (No Empty Flash)
Use synchronous cache read + async refresh for instant perceived load:
import { List, Cache } from "@raycast/api";
import { useCachedPromise, withCache } from "@raycast/utils";
const cache = new Cache();
const CACHE_KEY = "myData";
function getInitialData(): MyData[] {
const cached = cache.get(CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch {
return [];
}
}
return [];
}
const fetchExpensiveData = withCache(
async () => {
return await someSlowOperation();
},
{ maxAge: 5 * 60 * 1000 }
);
async function fetchAllData(): Promise<MyData[]> {
const data = await fetchExpensiveData();
cache.set(CACHE_KEY, JSON.stringify(data));
return data;
}
export default function Command() {
const { data, isLoading } = useCachedPromise(fetchAllData, [], {
initialData: getInitialData(),
keepPreviousData: true,
});
return (
<List isLoading={isLoading && !data?.length}>
{data?.map(item => <List.Item key={item.id} title={item.name} />)}
</List>
);
}
Key Caching Utilities
| Utility | Purpose |
|---|
Cache | Persistent disk cache, sync read/write |
withCache(fn, {maxAge}) | Wrap async functions with TTL cache |
useCachedPromise | Stale-while-revalidate pattern |
LocalStorage | Async key-value storage |
Avoiding CLS (Content Layout Shift)
Load all data in ONE async function:
const [customData, setCustomData] = useState([]);
useEffect(() => {
loadCustomData().then(setCustomData);
}, []);
async function fetchAllData() {
const [dataA, dataB] = await Promise.all([
fetchDataA(),
fetchDataB(),
]);
return combineData(dataA, dataB);
}
Non-Blocking Operations (Prevent UI Freeze)
Root cause of "tiny delay": Sync operations (execSync, statSync, readdirSync) block the event loop during revalidation, freezing the UI even with cached data displayed.
import { execSync } from "child_process";
import { statSync, readdirSync, copyFileSync } from "fs";
function fetchData() {
copyFileSync(src, dest);
const result = execSync("sqlite3 query");
const entries = readdirSync(dir);
for (const entry of entries) {
statSync(join(dir, entry));
}
}
import { exec } from "child_process";
import { promisify } from "util";
import { stat, readdir, copyFile, access } from "fs/promises";
const execAsync = promisify(exec);
async function fetchData() {
await copyFile(src, dest);
const { stdout } = await execAsync("sqlite3...");
const entries = await readdir(dir, { withFileTypes: true });
const results = entries
.filter(e => e.isDirectory())
.map(e => ({ path: join(dir, e.name), name: e.name }));
}
Key optimizations:
- Replace
execSync with promisify(exec) for shell commands
- Replace
existsSync with access() from fs/promises
- Replace
readdirSync + statSync loop with readdir(dir, { withFileTypes: true })
- Run all path validations in parallel with
Promise.all
- Use SQLite URI mode for direct read-only access (no file copy needed)
SQLite Direct Access (Skip File Copy)
When reading SQLite databases from other apps (like Zed, VS Code, etc.), avoid copying the database file. Use URI mode for direct read-only access:
import { copyFileSync, unlinkSync } from "fs";
const tempDb = `/tmp/copy-${Date.now()}.sqlite`;
copyFileSync(originalDb, tempDb);
execSync(`sqlite3 "${tempDb}" "SELECT..."`);
unlinkSync(tempDb);
const uri = `file:${originalDb}?mode=ro&immutable=1`;
const { stdout } = await execAsync(`sqlite3 "${uri}" "SELECT..."`);
URI parameters:
mode=ro - Read-only mode, no write locks acquired
immutable=1 - Skip WAL/lock checks, treat file as immutable
This eliminates the file copy entirely, saving significant I/O time.
execFile vs exec (Bypass Shell)
exec spawns a shell (~20ms overhead), execFile calls binary directly (~4ms):
import { exec } from "child_process";
const execAsync = promisify(exec);
await execAsync(`sqlite3 -separator '|||' "${db}" "${query}"`);
import { execFile } from "child_process";
const execFileAsync = promisify(execFile);
await execFileAsync("sqlite3", ["-separator", "|||", db, query]);
Sidecar Pattern (True Background Preloading)
For truly instant cold starts, use a background worker to pre-warm the cache before the user opens the extension.
The Problem: view commands cannot use interval (background scheduling). Only no-view and menu-bar modes support it.
The Solution: Create two commands that share the same cache:
{
"commands": [
{
"name": "main",
"title": "My Extension",
"mode": "view"
},
{
"name": "background-sync",
"title": "Background Sync",
"mode": "no-view",
"interval": "15m"
}
]
}
import { Cache } from "@raycast/api";
export const sharedCache = new Cache();
import { sharedCache } from "./shared-cache";
export default async function Command() {
const data = await fetchExpensiveData();
sharedCache.set("projects", JSON.stringify(data));
}
import { sharedCache } from "./shared-cache";
function getInitialData() {
const cached = sharedCache.get("projects");
return cached ? JSON.parse(cached) : [];
}
export default function Command() {
const { data } = useCachedPromise(fetchData, [], {
initialData: getInitialData(),
});
}
Key points:
- Worker runs silently on interval, user never sees it
- Both commands share the same
Cache (scoped to extension, not command)
- View command reads synchronously from pre-warmed cache
- Use
15m to 1h intervals to avoid battery/rate-limit issues
Large Datasets: useSQL over JSON Cache
For >1,000 items, use SQLite instead of JSON cache for instant filtering:
const allProjects = JSON.parse(cache.get("projects"));
const filtered = allProjects.filter(p => p.name.includes(query));
import { useSQL } from "@raycast/utils";
const { data } = useSQL(dbPath, `SELECT * FROM projects WHERE name LIKE ?`, [`%${query}%`]);
Optimistic UI (Instant Actions)
For write operations, update UI immediately before API confirms:
const { mutate } = useCachedPromise(fetchItems);
async function deleteItem(id: string) {
await mutate(deleteItemAPI(id), {
optimisticUpdate: (current) => current.filter(i => i.id !== id),
rollbackOnError: true,
});
}
User sees instant feedback; rollback happens automatically on failure.
const entries = readdirSync(dir);
for (const entry of entries) {
const s = statSync(join(dir, entry));
}
const checkPath = async (p: string) => {
try {
const s = await stat(p);
return s.isDirectory() ? p : null;
} catch { return null; }
};
const results = await Promise.all(paths.map(checkPath));
Common APIs
Clipboard
import { Clipboard } from "@raycast/api";
await Clipboard.copy("text");
await Clipboard.paste("text");
const text = await Clipboard.readText();
Notifications
import { showHUD, showToast, Toast } from "@raycast/api";
await showHUD("Done!");
const toast = await showToast({
style: Toast.Style.Animated,
title: "Loading...",
});
toast.style = Toast.Style.Success;
toast.title = "Complete";
AppleScript (macOS Integration)
import { runAppleScript } from "@raycast/utils";
const url = await runAppleScript(`
tell application "Google Chrome"
return URL of active tab of front window
end tell
`);
const safariUrl = await runAppleScript(`
tell application "Safari"
return URL of current tab of front window
end tell
`);
const app = await runAppleScript(`
tell application "System Events"
return name of first application process whose frontmost is true
end tell
`);
Fetch Data
const response = await fetch("https://api.example.com/data");
const data = await response.json();
Preferences
In package.json:
"preferences": [
{
"name": "apiKey",
"type": "password",
"required": true,
"title": "API Key",
"description": "Your API key"
}
]
In code:
import { getPreferenceValues } from "@raycast/api";
interface Preferences {
apiKey: string;
}
const { apiKey } = getPreferenceValues<Preferences>();
Creating Extension Icon
Use ImageMagick:
convert -size 512x512 xc:'#6366F1' -fill white -gravity center \
-font Helvetica-Bold -pointsize 280 -annotate +0+20 'M' \
assets/extension-icon.png
Development Workflow
npm install
npm run dev
npm run fix-lint
npm run build
Raycast Deeplinks
Trigger Raycast commands programmatically via URL scheme:
open "raycast://extensions/raycast/raycast/reload-extensions"
open "raycast://focus"
open "raycast://extensions/{author}/{extension}/{command}"
Auto-reload after build
Add to package.json scripts:
"build": "ray build --skip-types -e dist -o dist && open raycast://extensions/raycast/raycast/reload-extensions"
Or create a reload script:
#!/bin/bash
npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"
Testing in Raycast
- Run
npm run dev (provides hot reload)
- Open Raycast
- Search for your command name
- Press Enter to run
Without dev server running, use deeplink to reload after changes:
npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"
Publishing
npm run publish
Submits to Raycast Store for review.