Every feature and every setting. The same user reference is built into the app under Settings → Help, so it works offline too.
The floating mark and the ring around it.
The staff floats above every window and every space, including another app's full-screen. Hover it and up to six shortcuts fan out on an arc; click one to run it.
Settings → General controls the docked edge, the delay before the ring opens, how long before it folds back, and whether the staff is shown at all.
One bar that searches, launches, calculates and routes.
Press ControlSpace anywhere. What you type is interpreted in this order:
chrome launches it. Fuzzy, so chrm works.2+2, 18% of 240. Answers inline; Enter copies the result.Drag the bottom-right grip to resize the window; double-click it to hide. Esc closes.
Route a line somewhere specific.
Three ship by default:
Add your own under Settings → Command Center. A prefix can open a URL template ({query} is where your text lands), run a shell command, run AppleScript, or point at any built-in route. The longest matching prefix wins, so /c beats / regardless of list order.
The single primitive behind the ring and the palette.
A shortcut opens a URL, launches an app, runs a command, runs AppleScript, or opens a built-in view. The same list powers the staff's ring and the Command Center's results — which is why everything is editable, including the ones Caduceus ships with.
CPU, memory, disks, network — and quitting things.
On the staff ring, or type system in the Command Center.
Live CPU and memory, per-disk free space, network throughput, uptime and load average, plus the heaviest processes sorted by CPU or memory. Hover a row for Quit (asks politely) and Force (does not).
Processes you do not own show "system" instead of buttons — terminating them needs privileges Caduceus does not have, and a button that always fails is worse than no button.
Hold to talk, or double-tap the staff.
Push-to-talk records while you hold the key; F1 and double-clicking the staff toggle it instead. A red dot appears on the staff while the microphone is live.
What happens to the transcript is decided by keyword groups — say "search" and it goes to the web, "clipboard" and it searches your history. Anything unmatched falls through to your AI model.
macOS asks for Microphone and Speech Recognition the first time. Both are on-device.
Everything you copied, searchable.
Off by default. Once on, /v searches everything you have copied — text, images and file paths, with pinning.
It never leaves your machine and can be encrypted at rest. Add your password manager to the exclusion list: a clipboard history is a log of every password you copy.
Optional. Everything but / and /c works without it.
Settings → AI has a Scan this Mac button that checks the default ports for Ollama, LM Studio, llama.cpp, Jan and vLLM, and asks Hermes Agent whether it is configured. One click connects what it finds.
Otherwise add any OpenAI-compatible endpoint by hand, cloud or local. Keys go to your OS keychain, never to a config file, and there is no command to read one back out.
One backend is primary — that is what / talks to. /c uses the computer-use backend, which should be Hermes if you want it to actually control the screen. Leave Ask before an agent controls this Mac on.
What is bound, and what happens when something else holds it.
Two dedicated accelerators — Command Center and push-to-talk — plus the F1–F20 table, which is the single place function keys are configured. F12 shows and hides the staff out of the box.
If another app already holds a key, Caduceus moves that action to a free alternative at startup and saves the change, rather than leaving a shortcut that silently does nothing. Settings shows a note when that happens, and Settings → Help always lists what is actually bound.
On a Mac, set Keyboard → Keyboard Shortcuts → Function Keys to use F-keys as standard function keys if you want them to reach Caduceus globally.
Theme, accent, and the size of everything.
Light, dark or follow the system, plus the accent colour used across all three windows. The staff's size, ring reach, icon size and idle opacity are all adjustable — turn idle opacity up if you keep losing it on a busy desktop.
Short answer: almost nothing.
The launcher, calculator, clipboard history, dictation and the system monitor are entirely local. Dictation uses macOS on-device speech.
Two things touch the network, both only when you ask: a web search opens your browser, and the / and /c prefixes talk to whatever AI backend you configured — which can be a model on your own machine.
API keys live in the OS keychain. There is no command to read one back out, so a compromised webview cannot exfiltrate them.
One JavaScript file — metadata in a header comment, no build step.
Extensions add commands to the Command Center. Each extension is a single
.js file you drop onto Settings → Extensions (or save into the
extensions folder Caduceus opens from that tab). There is no separate manifest, no
npm install, and no folder layout to get wrong.
The @caduceus block must be the first comment in the file. Caduceus
reads it without running your code, so you see the name and permissions before
anything executes.
/**
* @caduceus 1
* name: Word Count
* description: Count the words on your clipboard
* author: you
* permissions: clipboard
*/
export default async function (input, ctx) {
const text = await ctx.clipboard.read();
return `${text.trim().split(/\s+/).length} words`;
}
input is whatever the user typed after the command name. Return a string for
a toast-style message, or an array of { title, subtitle?, action? } objects to
show a list.
Only these values are allowed — a typo fails at install time instead of widening access silently.
| Permission | What you get |
|---|---|
clipboard | ctx.clipboard.read() and .write(text) |
network | ctx.fetch(url, init) |
selection | ctx.selection() — current Finder selection |
notifications | ctx.notify(text) |
shell | ctx.shell.run(command, input?, timeoutSecs?) — an arbitrary shell command |
automation | ctx.automation.runAppleScript(script), .runShortcut(name, input?) |
files | ctx.files.read(path) and .write(path, content), under ~ or Caduceus app data |
settings | ctx.settings.get() and .set(settings) — all of them |
commands | ctx.commands.dispatch(paletteLine), .runTool(toolId, input) |
ai | ctx.ai.ask(prompt) — your configured backend, and your credits |
shortcuts | ctx.shortcuts.run(shortcutId, query?) |
Read this line before installing something. shell and
automation mean the file can run commands and drive every app on your Mac —
whatever you can do, it can do. Each permission is checked in Rust on every call, against
the header on disk, so an extension cannot reach past what it declared. What it declared
is the thing to look at.
Omit the permissions line entirely if you only need the APIs below that do not require one.
ctx APIThis is the complete surface — no require, no import, no ambient fetch. Anything not listed here has no implementation to reach.
ctx.clipboard.read() → Promise<string> ctx.clipboard.write(text) → Promise<void> ctx.fetch(url, init) → Promise<Response> // needs network ctx.selection() → Promise<string[]> // needs selection ctx.notify(text) → void // needs notifications ctx.storage.get(key) → Promise<any> ctx.storage.set(key, value) → Promise<void> ctx.open(url) → Promise<void> ctx.shell.run(cmd, input?, timeoutSecs?) // needs shell ctx.automation.runAppleScript(script) // needs automation ctx.automation.runShortcut(name, input?) // needs automation ctx.files.read(path) / write(path, content) // needs files ctx.settings.get() / set(settings) // needs settings ctx.commands.dispatch(input) // needs commands ctx.commands.runTool(toolId, input) // needs commands ctx.ai.ask(prompt) // needs ai ctx.shortcuts.run(shortcutId, query?) // needs shortcuts
ctx.storage is scoped per extension — one JSON file each, so two extensions cannot read each other's keys. ctx.fetch resolves to a Response-like object (ok, status, headers.get(), text(), json()) rather than a real Response: the request happened in Rust, so there is no stream left to wrap.
.js file onto Settings → Extensions, or~/Library/Application Support/com.caduceus.desktop/extensions/ (use Open extensions folder in Settings).No store, no signing, no review — but also no escape hatch around the permission list.
On the Extensions tab, describe what you want in one sentence and use Copy prompt.
Paste that into any assistant; the prompt already includes the header format, permission list,
and API rules. Save the reply as something.js and drag it in.
Type the extension's name in the Command Center. Anything after the name is passed as
input, so word count the quick brown fox runs Word Count with
the quick brown fox. Enter opens the extension's page: a string comes back as
text with a Copy button, an array as a clickable list, and errors are shown there rather
than swallowed.
Extensions execute in a Web Worker — no DOM, and no Tauri IPC, so an extension cannot
call a Caduceus command directly. The network globals are removed before your code runs,
and the CSP allows connect-src 'self' and nothing else; ctx.fetch
is a Rust call that re-reads your header and checks the network permission
first, as every other capability does. A call gets 120 seconds before the worker is
terminated. The worker bounds what an extension reaches by accident; the permissions line
is what bounds what it reaches by asking.
Questions, feedback, or something broken?
Email caduceus@vivaanshahani.com, or use GitHub issues for bugs you can reproduce. The same links are in the app under Settings → Help.
Tauri 2 app: three WebView windows, one Rust binary.
Caduceus is not a single-page app. Each surface is its own Vite entry and window label:
staff — floating Caduceus staff + radial shortcuts (src/staff/)command-center — palette, agent panel, clipboard view (src/command-center/)settings — seven settings tabs + Help (src/settings/)Shared React utilities, IPC wrappers, and the design system live in src/shared/. All native work runs in src-tauri/src/ and is exposed only through named #[tauri::command] handlers in commands.rs (registered in lib.rs). The shell, filesystem, and HTTP Tauri plugins are deliberately not enabled — the frontend never executes arbitrary shell strings.
src-tauri/src/ agent/ AgentBackend trait; Hermes + OpenAI-compatible + Null palette.rs prefix parse + dispatch for Command Center submit shortcuts/ Shortcut model, exec (open_url, open_app, run_command, …) voice/ capture, live STT (macOS), keyword routing clipboard/ NSPasteboard watcher → SQLite (+ optional encryption) window/ staff placement, cursor tracker, hover/collapse timing hotkeys.rs global shortcuts + push-to-talk + fn_keys dispatch settings/ JSON schema (tauri-plugin-store) + keychain secrets
Startup order (see lib.rs): load settings → open clipboard DB → register hotkeys/tray → show staff. Clipboard and hotkey registration can fail softly; the app still runs.
The WebView calls Rust by command name; Rust never trusts HTML with secrets.
TypeScript invokes commands through src/shared/api.ts (invoke<T>("command_name", …)). Examples:
// Frontend
await invoke("dispatch_input", { input: "/ explain OAuth" });
// Rust — commands.rs
#[tauri::command]
pub async fn dispatch_input(app: AppHandle, settings: State<SettingsManager>, input: String)
-> Result<DispatchOutcome, String>
{
Ok(palette::dispatch(&app, &settings, &input).await)
}
Shortcuts run by id from saved settings (run_shortcut), not by passing a command string from the UI. API keys are written with set_backend_api_key and stored in the macOS keychain via the keyring crate; there is no command that reads a key back out.
palette.rs — longest-prefix wins, then action enum.
parse_input / dispatch_input implement the Command Center bar. Prefix rules come from CommandCenterSettings::prefixes (user-editable JSON). Alphanumeric prefixes require a word boundary after the token so /c does not match /computer.
// Default routes (PrefixAction)
WebSearch | PrimaryAi | ComputerUse | ClipboardSearch
| OpenUrl | RunCommand | RunAppleScript | InsertOnly | …
// Dispatch flow (simplified)
let parsed = parse(input, &settings.command_center);
match parsed.action() {
PrefixAction::PrimaryAi => agent::chat(primary_backend, remainder).await,
PrefixAction::ComputerUse => agent::start_session(cu_backend, …).await,
PrefixAction::WebSearch => open_url(search_template.replace("{query}", …)),
…
}
While typing, the React side uses parse_input for highlighting and defaultProviders in providers.ts for fuzzy rows (apps, shortcuts, calculator, clipboard). Submitting Enter calls dispatch_input for the authoritative Rust path.
ResultProvider in src/shared/providers.ts.
export interface ResultProvider {
id: string;
title: string;
search(ctx: ProviderContext): ResultItem[] | Promise<ResultItem[]>;
}
export const emojiProvider: ResultProvider = {
id: "emoji",
title: "Emoji",
search({ query }) {
if (!query) return [];
return findEmoji(query).map((e) => ({
id: `emoji:${e.name}`,
title: e.char,
subtitle: e.name,
icon: e.char,
group: "Emoji",
score: 400,
run: () => { void navigator.clipboard.writeText(e.char); },
}));
},
};
// Append to defaultProviders in providers.ts
Providers return scored rows; the palette merges and sorts. A row’s run() may return false to keep the window open (e.g. chat still streaming).
shortcuts/mod.rs + shortcuts/exec.rs.
A Shortcut has a ShortcutKind: OpenUrl, OpenApp, RunCommand, RunAppleScript, built-in views, etc. Staff pop-out and palette search share the same list. Execution goes through execute_shortcut(app, shortcut, …) with browser profile substitution for URLs.
Function keys (fn_keys.rs) map F1–F20 to FunctionKeyAction (open Command Center, toggle dictation, run a shortcut by id, …). hotkeys.rs registers globals via tauri-plugin-global-shortcut and falls back to alternate bindings if macOS already owns a key.
AgentBackend trait — backend_for(BackendKind).
pub enum BackendKind {
Null, // no-op; app usable without AI
OpenAiCompatible, // HTTP chat/completions
Hermes, // spawns Hermes CLI; computer_use via Hermes tools
}
pub fn backend_for(kind: BackendKind) -> Arc<dyn AgentBackend> {
match kind {
BackendKind::Null => Arc::new(null::NullBackend),
BackendKind::OpenAiCompatible => Arc::new(openai::OpenAiCompatibleBackend),
BackendKind::Hermes => Arc::new(hermes::HermesBackend),
}
}
/ calls chat on the primary backend id from settings. /c starts a computer-use session on the configured backend (Hermes is the supported path for screen control — Caduceus does not embed its own mouse/screen driver). Steps stream to the UI on caduceus://agent-step. User approval is gated in settings (confirmBeforeFirstAction).
Local discovery: agent/discover.rs probes common Ollama/LM Studio ports and Hermes install state for the Settings → AI “Scan this Mac” button.
Push-to-talk and toggle dictation share VoiceRuntime.
macos/CaduceusSTTLive.swift — AVAudioEngine + Speech framework; partial lines on stdout; Rust reads them in voice/live_macos.rs.SttBackend (SystemNative or OpenAI-compatible Whisper HTTP).voice/mod.rs matches keyword groups from settings and returns RoutedText (web search, primary AI, computer use, clipboard, insert-only).Events: caduceus://voice-state, voice-partial, voice-result. Toggle dictation: hotkeys::toggle_dictation + Tauri command toggle_dictation; staff double-click calls the same from React.
Watcher thread + SQLite in clipboard/.
The watcher polls the pasteboard on an interval, skips concealed types and excluded apps, writes rows to SQLite (WAL mode). Optional ChaCha20-Poly1305 encryption at rest with a key in the keychain. List/search/prune commands are exposed to the Command Center clipboard provider and /v dispatch.
window/mod.rs + macOS-specific level/workspaces in window/macos.rs.
The staff window is a large transparent square; hover/collapse is driven from Rust because the WebView stops receiving pointer events when the cursor leaves the window — Rust tracks cursor position (CursorTracker) and emits caduceus://staff-hover. Staff uses a high window level and FullScreenAuxiliary so it remains visible over full-screen apps. Custom marks: user PNG → staff_mark.rs + StaffMark.tsx.
SettingsManager + migrations in settings/mod.rs.
JSON persisted via tauri-plugin-store (shortcuts, prefixes, backends metadata, appearance, function keys, …). Secrets never go in JSON. migrate_settings runs on load to upgrade older configs (hidden dictation shortcut, glyph icons, default F1 dictation, etc.).
// Rust — hermes_template / openai_compatible_template in settings/model.rs
BackendConfig { id, display_name, kind: BackendKind::Hermes, … }
// Frontend draft — settings/useDraft.ts debounced save → update_settings
src-tauri/src/extensions.rs — header parse, install, list, remove.
Extensions are single .js files. The first block comment must contain
@caduceus 1 plus optional name, description,
author, and permissions (comma-separated, closed set). Only the
first comment is parsed so metadata cannot be redefined later in the file.
// Tauri commands (commands.rs)
inspect_extension(path) → Extension metadata or error
install_extension(path) → InstallReport { ok, message, extension? }
list_extensions() → Vec<Extension>
remove_extension(id) → ()
open_extensions_folder() → reveals ~/Library/Application Support/…/extensions/
extension_source(id) → String // for the sandbox to run
extension_clipboard_read/write, extension_fetch, extension_selection,
extension_notify, extension_open, extension_storage_get/set // the ctx API
Install copies a validated file into extensions_dir(app_data) under a
sanitised basename. Remove refuses paths outside that directory, and takes the extension's
storage with it. Every command in the second group is one ctx call: each takes
the extension id and goes through extensions::require, which re-reads the
header from disk and refuses a permission the file does not claim. That check is in Rust
rather than the webview on purpose — the sandbox's own refusal is JavaScript sitting beside
the extension's JavaScript. See
EXTENSIONS.md.
Cross-window pub/sub (string channel names).
caduceus://settings-changed — reload settings in all webviewscaduceus://clipboard-changed — palette clipboard provider refreshcaduceus://agent-step — agent UI progresscaduceus://staff-hover, command-center-open, settings-tabcaduceus://voice-*, hotkey-problemsConstants mirrored in src/shared/types.ts as EVENTS; hooks in useTauriEvent.
From repo root.
npm install npm start # Vite dev + Tauri dev npm run bundle # production .dmg npm run typecheck # tsc npm run test:rust # cargo test in src-tauri python3 scripts/make-icons.py # regenerate icons from pixel grid
macOS-only today: live speech helper is compiled in build.rs on Apple targets. Website static assets live in website/; deploy with Cloudflare Workers (worker/index.js routes /caduceus/*).