Files
raycast-api/README.md
T

4.0 KiB

raycast-api

AI Slop Inside

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, write config.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. --verify rechecks hashes against a live Raycast install; --quiet collapses to an exit code (0 current, 1 stale, 2 unverifiable) for scripting.
  • ask - one-shot smoke test against chat.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:

  1. Launcher. Read the Mach-O binary, find the 64-hex signature_secret by anchored byte-pattern.
  2. Bundle. Locate index.mjs inside the app, hash it for the cache key.
  3. AST. Parse the bundle with esprima. Walk for the signing function by structural shape - it takes (method, path, body, ts, key), calls crypto.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.
  4. 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.