A command that worked once, then hung on “Starting…” forever. That bug sits in the middle of a 723-line design doc for running Raycast extensions outside Raycast, and it’s the reason I read the other 700 lines this Saturday.

If you’ve ever written a plugin host, you’ve met its cousin. You reuse a runtime to save a few milliseconds, and the cleanup code reaches one object too far. The failure turns up two runs later, in code that isn’t yours.

Cheap to cause, miserable to trace.

What Tinycast is

Tinycast is a macOS launcher built in SwiftUI and AppKit, published from one developer’s personal GitHub account. It ships the usual palette features (app launching, clipboard history, snippets, quicklinks, a calculator, 34 Rectangle-style window actions) plus an opt-in AI chat and, the reason I’m writing this, the ability to run existing Raycast extensions.

The pitch is no Electron, zero third-party dependencies, no telemetry, under 100 MB of RAM, and an AGPL-3.0 licence. At the time of writing it has 883 commits and roughly 8.9k stars.

For the record, I read the README, the extension design doc, the engineering standards and the open issues. I haven’t run it. The 100 MB figure is a budget written into the project’s standards, not something I measured.

Why another launcher

The honest objection is that Raycast’s free tier is polished and backed by a funded company, and a one-person alternative is chasing a target that moves every week. I’d normally stop reading there.

What changed my mind is that Tinycast goes after the switching cost rather than the feature list. Your launcher isn’t sticky because of its fuzzy search. It’s sticky because of extensions you set up two years ago. Make those run and the argument becomes about memory and whether you trust closed-source code on a global hotkey.

The part I actually find interesting

I assumed, from the words “runs Raycast extensions”, that a Node binary was hiding somewhere in the app bundle. I was wrong. Tinycast uses JavaScriptCore, which already ships with macOS, so the engine adds nothing to the binary.

The trick is that a built Raycast command is a single CommonJS file with react, react/jsx-runtime, @raycast/api and the Node built-ins left as external imports. Tinycast supplies exactly those from a roughly 200 KB generated runtime containing React 19, react-reconciler, a shim of the Raycast API, and the web and Node polyfills a bare JSContext lacks. The reconciler doesn’t commit to a DOM. It commits to a JSON tree, which Swift decodes and draws as native rows.

This is the project’s own diagram, trimmed:

<command>.js  (esbuild output, deps inlined)
      │  require("@raycast/api"), require("react"), require("node:fs")
      ▼
RaycastRuntime.generated.js   ← React 19 + react-reconciler + @raycast/api shim
      │  render tree as JSON      ▲  dispatch(handlerId, args)
      ▼                           │
ExtensionRuntime (JavaScriptCore, private serial queue)
      │
      ▼
ExtensionManager → ExtensionScreen → the palette

Two conventions make Raycast’s component model fit a tree. Raycast passes elements as props (actions={<ActionPanel/>}), and React never renders an element sitting in a prop, so each shim component re-emits them as __slot children that the serialiser folds back into the parent. Function props become {"$fn": "<nodeId>:<propName>"} handles, which Swift dispatches back across the bridge when you press a key.

The decision I’d actually steal is smaller. Exactly one command runs at a time, in its own JSContext, and leaving a command throws the whole context away. That’s where the hung “Starting…” came from. JavaScriptCore has no MessageChannel, so React’s scheduler runs on setTimeout. When contexts were reused, cancelling an extension’s leftover timers cancelled React’s too, and every later command stalled. Leaving the timers alone leaked intervals instead.

Discarding the context fixes both. The developer measured the cost at about 7 ms on a warm boot.

Getting it in, and what actually runs

# Third-party taps have to be trusted first
brew trust --tap abue-ammar/tinycast
brew tap abue-ammar/tinycast

# Apple silicon on macOS 26 (Intel Macs on macOS 26 use tinycast-universal)
brew install --cask tinycast

# Took the DMG from Releases instead? It's self-signed, so clear quarantine once
xattr -dr com.apple.quarantine "/Applications/Tinycast.app"

The README lists macOS 26 as the minimum, so anyone still on Sequoia is out.

Importing from Raycast copies the already-built bundles out of ~/.config/raycast (and ~/.config/raycast-x for the v2 beta), with no Node or network involved. The Store registry downloads the bundle Raycast already built. A GitHub registry builds from source and does need Node: it installs with --ignore-scripts, then calls ray build -e dist directly, because the manifest’s own build script defaults to dev mode and silently installs into your local Raycast instead.

On coverage, the README says “real Raycast extensions”. The design doc is more careful. Against the 37 extensions on the developer’s machine, 32 extensions and 114 of 147 view commands rendered, about 78%, and the doc warns to re-measure because OAuth landed afterwards. Menu-bar commands don’t run. Calls into Raycast’s own services, such as AI and WindowManagement, throw, and there’s no WebSocket support yet.

Caveats, honestly

It’s self-signed, and installing it means telling macOS to stand down. brew trust --tap and the quarantine removal both exist because the app isn’t notarised. That’s defensible for a free project and a hard stop on most managed company Macs.

Extensions are third-party code with filesystem and shell access. The runtime shims fs and child_process, and Swift services those calls for real. Extensions are off by default and enabling them asks for confirmation, but after that a bad extension can do what any local script can.

Clipboard history is on by default and can keep everything forever. It’s the only feature that ships enabled. History lives in a SQLite file under ~/Library/Application Support/, retention offers Forever, and the clipboard doc says nothing about encryption at rest. On a shared machine, that file matters. Copies carrying only HTML or RTF are also currently dropped (#1697).

It claims the raycast:// URL scheme. That’s how OAuth redirects written for Raycast land in Tinycast. Keep both installed and macOS picks which app wins those links. Store search also relies on a Raycast endpoint the docs call unofficial.

One open bug can freeze your keyboard. If an update installs and you don’t click Restart, the old process keeps a keyboard event tap and input can lock system-wide (#1656).

It’s young and it lives on one person’s account. Releases go back to July, and contributors sign a licence agreement on top of the AGPL. Skip it if you depend on menu-bar extensions or use a Mac your IT team manages.

Where it sits next to Raycast

TinycastRaycast
Best atNative footprint and source you can actually readThe full extension API, menu-bar and AI included, in signed builds
Falls down onSelf-signed installs, partial extension coverage, one developer’s accountClosed source, so you take its memory use and data handling on trust

What I’d take from it

I haven’t moved my own launcher over, and I wouldn’t on a work Mac. But the one-context-per-command rule has already changed how I’d write any plugin host. When cleaning up shared state is risky, throw the state away.

Seven milliseconds is cheap. Proving something is clean usually isn’t.