The companion server exposes two WebSocket endpoints:
| Endpoint | Port | Protocol | Purpose |
|---|---|---|---|
wss://host:7777 |
7777 | WSS (TLS) | Remote connections (iOS, Obsidian Mobile) |
ws://localhost:7778 |
7778 | WS (plain) | Local connections (same machine) |
Clients authenticate by including the token as a URL query parameter:
wss://your-mac.local:7777?token=<64-char-hex-token>
The server validates the token using timing-safe comparison before accepting the connection. Invalid tokens result in immediate connection close with code 4001.
All messages are JSON strings with a type field:
{ "type": "message-type", ...payload }Begin an audio upload session.
{
"type": "audio_start",
"format": "mp4",
"sampleRate": 16000,
"sessionId": "session-123"
}After this message, send raw audio data as binary WebSocket frames (not JSON). The server accumulates chunks until audio_end.
Signal that audio upload is complete. Triggers the voice pipeline: audio → WAV conversion → whisper-cli transcription → Claude execution → TTS response.
{ "type": "audio_end" }Send a text command directly (bypasses audio transcription).
{
"type": "text_command",
"text": "What files are in this directory?",
"sessionId": "session-123"
}Cancel the active Claude run and clean up resources.
{ "type": "cancel" }Clear the current Claude session and start fresh.
{ "type": "new_session" }Mute or unmute TTS audio.
{
"type": "tts_toggle",
"muted": true
}Respond to a tool permission request from Claude (interactive mode).
{
"type": "permission_response",
"requestId": "req-456",
"behavior": "allow"
}behavior can be "allow" or "deny". Optional updatedPermissions can modify future auto-approvals.
Respond to a question from Claude (interactive mode).
{
"type": "question_response",
"requestId": "req-789",
"answer": "Yes, proceed with the refactor."
}Heartbeat message. Server responds with pong.
{ "type": "ping" }Sent immediately after successful authentication.
{
"type": "connected",
"version": "1.0.0"
}Speech-to-text result from whisper-cpp.
{
"type": "transcription",
"text": "What is the status?",
"detectedLang": "en"
}Streaming text chunk from Claude output.
{
"type": "stream_delta",
"text": "Here are the files in this directory:\n"
}Claude process has completed.
{
"type": "stream_end",
"sessionId": "session-123"
}Claude wants to use a tool that requires approval (interactive mode).
{
"type": "permission_request",
"requestId": "req-456",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"input": { "command": "ls -la" }
}
}Claude is asking the user a question (interactive mode).
{
"type": "question_request",
"requestId": "req-789",
"request": {
"subtype": "elicitation",
"question": "Which database should I connect to?"
}
}PCM audio data for TTS playback.
{
"type": "tts_audio",
"data": "<base64-encoded-pcm>",
"sampleRate": 22050
}Audio is Float32 PCM. Sample rate is 22050 Hz (Piper) or varies by engine.
TTS synthesis complete for the current response.
{ "type": "tts_end" }Heartbeat response.
{ "type": "pong" }Error message with stage indicator.
{
"type": "error",
"stage": "transcription",
"message": "whisper-cli not found"
}The PlatformAdapter is a plain JavaScript object that every platform must implement. The shared loader.js calls only these methods, ensuring identical behavior across platforms.
| Method | Signature | Description |
|---|---|---|
readFile |
(path: string) => string |
Read file contents as UTF-8 string |
writeFile |
(path: string, content: string) => void |
Write string content to file |
stat |
(path: string) => { mtimeMs: number, size: number, isDirectory: boolean } |
Get file metadata |
readdir |
(path: string) => string[] |
List directory entries |
exists |
(path: string) => boolean |
Check if path exists |
mkdir |
(path: string, recursive?: boolean) => void |
Create directory |
| Method | Signature | Description |
|---|---|---|
spawn |
(prog: string, args: string[], opts?: object) => ChildProcessLike |
Spawn a child process |
exec |
(command: string) => string |
Execute shell command synchronously |
kill |
(pid: number) => void |
Kill a process by PID |
| Method | Signature | Description |
|---|---|---|
homedir |
() => string |
User home directory path |
tmpdir |
() => string |
Temporary directory path |
| Method | Signature | Description |
|---|---|---|
vaultBasePath |
() => string |
Root path of the Obsidian vault |
openNote |
(path: string) => void |
Open a note in the editor |
queryRecentFiles |
(folder: string, count: number) => Array |
Get recently modified files |
countFiles |
(folder: string) => number |
Count files in a folder |
parseYamlFrontmatter |
(path: string) => object |
Parse YAML frontmatter from a markdown file |
| Method | Signature | Description |
|---|---|---|
showNotice |
(message: string, duration?: number) => void |
Show a notification/toast |
| Property | Type | Description |
|---|---|---|
platform |
"obsidian" | "tauri" | "ios" |
Current platform identifier |
The macOS Tauri app exposes Rust commands callable from JavaScript via window.__TAURI__.invoke().
| Command | Parameters | Returns | Description |
|---|---|---|---|
read_file |
path: String |
String |
Read file as UTF-8 |
batch_read_files |
paths: Vec<String> |
HashMap<String, String> |
Read multiple files at once |
write_file |
path: String, content: String |
() |
Write content to file |
write_binary_file |
path: String, data: Vec<u8> |
() |
Write binary data to file |
stat_file |
path: String |
FileStat |
Get file metadata (mtime, size, isDir) |
readdir |
path: String |
Vec<String> |
List directory entries |
exists |
path: String |
bool |
Check if path exists |
mkdir |
path: String, recursive: bool |
() |
Create directory |
| Command | Returns | Description |
|---|---|---|
home_dir |
String |
User home directory |
tmp_dir |
String |
System temp directory |
| Command | Parameters | Returns | Description |
|---|---|---|---|
spawn_process |
id, program, args, opts |
u32 (PID) |
Spawn process with stdout/stderr streaming |
kill_process |
id: String |
() |
Kill a spawned process |
stdin_write |
id: String, data: String |
() |
Write to process stdin |
stdin_close |
id: String |
() |
Close process stdin |
transcribe_audio |
audio_data, whisper_path, whisper_model, language |
String (JSON) |
Transcribe audio via ffmpeg + whisper-cli |
exec_sync |
command: String |
String |
Execute shell command synchronously |
open_url |
url: String |
() |
Open URL in default browser |
open_app |
name: String |
() |
Open application by name |
struct SpawnOpts {
cwd: Option<String>, // Working directory
env: Option<HashMap<String, String>>, // Additional env vars
env_remove: Option<Vec<String>>, // Env vars to remove
}Process events are emitted as Tauri events:
process-stdout-{id}— stdout lineprocess-stderr-{id}— stderr lineprocess-close-{id}— process exit (payload: exit code)
| Command | Parameters | Returns | Description |
|---|---|---|---|
parse_yaml_frontmatter |
path: String |
Value (JSON) |
Parse YAML between --- fences |
get_recent_files |
root: String, count: usize |
Vec<RecentFile> |
Get N most recently modified .md files |
count_files |
folder: String |
usize |
Count .md files recursively |
The iOS app communicates between Swift and JavaScript using WKWebView message handlers.
JavaScript calls Swift via window.webkit.messageHandlers:
window.webkit.messageHandlers.audioControl.postMessage({
action: "startRecording" // or "stopRecording"
});Swift calls JavaScript via evaluateJavaScript():
webView.evaluateJavaScript("window.handleAudioData('\(base64AudioData)')")| Handler | Actions | Description |
|---|---|---|
audioControl |
startRecording, stopRecording |
Control AVAudioEngine recording |
keychainStore |
set, get, delete |
Keychain credential management |
// Store a value
window.webkit.messageHandlers.keychainStore.postMessage({
action: "set",
key: "server_host",
value: "my-mac.local"
});
// Retrieve (async via callback)
window.webkit.messageHandlers.keychainStore.postMessage({
action: "get",
key: "server_host",
callbackId: "cb-123"
});
// Swift calls: window.keychainCallback("cb-123", "my-mac.local")