raycast-api
Python client for backend.raycast.com. Bring-your-own-credentials: the
signing secret and algorithm are extracted from a local Raycast install at
setup time - nothing sensitive ships with the package.
Not affiliated with Raycast. You need your own account, subscription, and a copy of the desktop app on macOS to run discovery once.
Install
As a CLI tool (pulls in the discovery extra):
uv tool install "raycast-api[discovery] @ git+https://git.kotikot.com/beaver/raycast-api"
As a library inside another project (runtime only, no discovery deps):
uv add "raycast-api @ git+https://git.kotikot.com/beaver/raycast-api"
The runtime needs only aiohttp. The discovery extra adds esprima (a JS
AST parser) and is required only for init / refresh.
Credentials
raycast-api init # extract signing spec + secret from the local app → ./config.json
export RAYCAST_BEARER='rca_...' # your OAuth access token, sniffed from the desktop client
raycast-api ask --stream 'hello, raycast'
init writes config.json (chmod 600) containing the signing spec, the
launcher-derived secret, and bundle/launcher hashes used as a cache key.
Re-run raycast-api refresh after a Raycast update - the cache invalidates
automatically when either hash changes.
Use
import asyncio
from raycast_api import Client, Config, Message
async def main() -> None:
async with Client(
config=Config.load("config.json"),
bearer_token="rca_...",
device_id="<64 hex>", # any stable per-install id
) as client:
result = await client.chat.complete(
model="Claude Sonnet 4.6",
messages=[Message.user("hello, raycast")],
)
print(result.text)
asyncio.run(main())
Streaming:
async for chunk in client.chat.stream(model="...", messages=[...]):
if chunk.text:
print(chunk.text, end="", flush=True)
Endpoints: client.chat, client.models, client.me, client.files.
Interrupted streams resume via client.chat.resume(buffer_id=..., last_event_id=...).
CLI
raycast-api init [--app-path PATH] [--output config.json] [--force] [--no-cache]
raycast-api refresh [--app-path PATH] [--config config.json]
raycast-api inspect [--config config.json] [--verify | --app-path PATH] [--quiet]
raycast-api ask PROMPT [--bearer ...] [--device-id ...] [--model ...] [--stream]
init- run discovery, writeconfig.json. Cached by(bundle_hash, launcher_hash).refresh- bypass the cache and re-derive. Use after a launcher rebuild that didn't touch the JS bundle.inspect- print the saved config with the secret redacted.--verifyrechecks hashes against a live Raycast install;--quietcollapses to an exit code (0current,1stale,2unverifiable) for scripting.ask- one-shot smoke test againstchat.complete.
How the signing scheme is recovered
Raycast signs every request with a rotated-alphabet HMAC over a canonical
string. init recovers this end-to-end from your local install, without
running any of Raycast's code:
- Launcher. Read the Mach-O binary, find the 64-hex
signature_secretby anchored byte-pattern. - Bundle. Locate
index.mjsinside the app, hash it for the cache key. - AST. Parse the bundle with
esprima. Walk for the signing function by structural shape - it takes(method, path, body, ts, key), callscrypto.createHmac, and joins its inputs with a single character. From its caller recover the rot transform: alphabet ranges and shift counts are literals in the source. - Spec. Emit a portable
signing_spec(ranges, join char, HMAC and body-hash algorithms, key/output encodings). The runtime signer reads only that - no version-specific code anywhere in the package.
As long as Raycast keeps a rot-transform over an HMAC, a future build only
needs raycast-api refresh, not a release.