# 04 — API Contracts Two contract surfaces: 1. **Frontend ⇄ Rust** — Tauri **commands** (request/response) and **events** (Rust→UI push). 2. **Rust internal** — service **traits** that decouple callers from concrete engines (NFR-MNT-1/2). All payloads are `serde`-serializable; timestamps are unix epoch ms unless noted. Errors are typed: each command returns `Result` where `WaError` carries a `kind` (machine-readable) and a `message` (human-readable). ## 1. Tauri commands (frontend → Rust) ```ts // ---- Recording lifecycle ---- // `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is // deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting. // templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below. // language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code // (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted. // Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only // model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs). start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef pause_recording(input: { meetingId: MeetingId }): void resume_recording(input: { meetingId: MeetingId }): void set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1) acknowledge_recording_consent(): void // one-time (FR-REC-2) // ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ---- // Both are live-session only (err "no matching active recording" once finalized — post-finalize, // notes.md is the single editable document and `update_notes` is the command for it). update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording // anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription). // text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph // covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge). set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void // ---- Hardware ---- hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } set_preferred_backend(input: { backend: BackendId | "auto" }): void // ---- Transcription / models ---- // language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it. reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3) // ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only) // ggml variants, true for the multilingual ones; gates the Settings language picker. list_models(): ModelInfo[] list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1) download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs // Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown // (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list. list_whisper_languages(): { code: string; label: string }[] // ---- Speakers ---- rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void merge_speakers(input: { meetingId: MeetingId; from: string[]; into: string }): void map_speaker_to_participant(input: { meetingId: MeetingId; label: string; participantId: string }): void // ---- Meetings / storage ---- // MeetingListItem gained a `tags: string[]` field (Phase 8, FR-SEARCH-2). // list_meetings dropped limit/offset (never implemented — no pagination need // yet at local-desktop meeting counts) and gained from/to date filters, per // FR-SEARCH-2's "filter by date, tag, or participant". list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[] // Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source // of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment // keeps start_ms/end_ms; the UI renders these as per-line timestamps. get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated) delete_meeting(input: { meetingId: MeetingId }): void // format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json // (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine. // format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio): // YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions // + action items + timestamped transcript. For dropping a meeting into an Obsidian vault. export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string // Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary, // reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5): // they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an // edit that didn't alter a file uploads nothing. update_notes(input: { meetingId: MeetingId; markdown: string }): void // SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) set_tags(input: { meetingId: MeetingId; tags: string[] }): void list_tags(): string[] // all known tag names, sorted // NoteTemplate = { id: string, name: string, sections: string[] } (T8.1, FR-NOTE-5). Meeting // gained `template_id: string | null` — the template picked at start_recording. list_note_templates(): NoteTemplate[] // Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle // folder) per meeting under destDir. Returns the count actually exported. bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number // Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent // folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original // title/date/duration/speakers/tags/action items preserved). Returns the count imported. import_meeting_bundle(input: { dir: string }): number // ---- LLM / AI provider (ADR-0007/0011) ---- // provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to // set_llm_provider but stored only in the OS credential store; never returned by llm_status. llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: string[] } set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2 generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4) // ActionItem gained `reminder_set: boolean` (T8.6, FR-CAL-5). confirm_action_items now also // schedules/cancels each item's local reminder (Windows scheduled toast notification — the OS // itself delivers it at due_at, no app-side polling timer; see src-tauri/src/reminders.rs). confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggestedModel: { id: string; label: string; approxSizeGb: number } } // T5.7, FR-LLM-5 pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7) // ---- Calendar / .pst ---- // rangeDays: only import events starting within the last N days; omitted imports the full mailbox // history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring // series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file // ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could // produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and // applies it to pst_auto_sync's startup re-import too. import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1) list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[] get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4) // olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting // more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is // always kept regardless of the choice -- protected reports how many were skipped for that reason. cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number } attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void // Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE // + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never // the event body). Not a SyncTarget — begin_graph_calendar_link stores its token separately from // sync_targets and never appears in list_sync_targets. begin_graph_calendar_link(): { authUrl: string } // opens in browser; emits calendar://linked when done import_graph_calendar(input: { from?: number; to?: number }): number // eventsImported; emits calendar://progress disconnect_graph_calendar(): void // best-effort credential cleanup + settings reset // ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS // credential store; they are NEVER returned by list_sync_targets. list_sync_targets(): SyncTargetInfo[] add_sync_target(input: SyncTargetConfig & { secret: string }): SyncTargetInfo // FR-SYNC-2/9 update_sync_target(input: { id: string } & Partial & { secret?: string }): SyncTargetInfo remove_sync_target(input: { id: string }): void test_sync_target(input: { id: string } | SyncTargetConfig & { secret: string }): { ok: boolean; message: string } // FR-SYNC-4 set_sync_enabled(input: { enabled: boolean }): void // master switch (FR-SYNC-1) sync_meeting(input: { meetingId: MeetingId; targetId?: string }): void // manual "Upload now" (FR-SYNC-3) sync_status(input?: { meetingId?: MeetingId }): SyncJobInfo[] // queue state (FR-SYNC-5) retry_sync_job(input: { jobId: string }): void // OAuth (secondary targets, FR-SYNC-9): begins loopback-redirect PKCE flow, returns when linked. begin_oauth_link(input: { kind: "onedrive" | "dropbox" | "box" }): { ok: boolean; account?: string } // ---- Feature briefs + MCP server (ADR-0011) ---- create_feature_brief(input: { meetingId: MeetingId; targetRepo?: string }): FeatureBrief // FR-MCP-4 (LLM-distilled) list_feature_briefs(input: { meetingId?: MeetingId }): FeatureBriefInfo[] get_feature_brief(input: { id: string }): FeatureBrief set_brief_exposed(input: { id: string; exposed: boolean }): void // scope control (FR-MCP-3) mcp_status(): { enabled: boolean; transport: "http" | "stdio"; endpoint: string; tokenSet: boolean; exposeScope: string } set_mcp_enabled(input: { enabled: boolean; transport?: "http" | "stdio"; port?: number }): { endpoint: string; token: string } // FR-MCP-1/6 set_mcp_scope(input: { expose: "none" | "selected" | "all"; exposeRecordings?: boolean }): void // FR-MCP-3 mcp_access_log(input?: { limit?: number }): McpAccessEntry[] // audit (FR-MCP-5) // (Layer 3, later) push handoff — spawn a local agent CLI / open a tracker issue from a brief. run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "copilot"; repoPath: string }): { ok: boolean } // FR-AGENT-1 create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2 // ---- Settings ---- // Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default // transcription language applied at the next start_recording; null = auto-detect. Mirrors // this doc's settings.json `transcription.language` (03-data-model.md). get_settings(): Settings update_settings(input: Partial): Settings // Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2). privacy_self_check(): { llmEndpoint: string; llmIsLocal: boolean; syncEnabled: boolean; syncTargets: { name: string; host: string; thirdParty: boolean; tls: boolean }[]; allowlistedHosts: string[]; } ``` ### Conventions - Commands return promptly; anything long-running (recording, transcription, summary, model download, PST import) reports progress/results through **events** below. - `MeetingId` is a uuid string. `BackendId` ∈ `"npu" | "nvidia" | "amd" | "intel" | "cpu"`. ## 2. Tauri events (Rust → frontend) ```ts "recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs } "recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5) "recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6) "transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2) "transcript://finalized" { meetingId, segmentCount } "diarization://updated" { meetingId, speakers: SpeakerInfo[] } // after post-pass (FR-SPK) "llm://token" { meetingId, text } // streamed summary (FR-LLM-4) "llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer "model://progress" { id, receivedBytes, totalBytes } "pst://progress" { processed, total } "calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4) "calendar://progress" { processed, total } // MS Graph import (M4.4) "hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4) "recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3) "sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5 "sync://done" { meetingId, targetId, uploaded: number, failed: number } "mcp://access" { at, tool, meetingId?, client? } // agent read something (FR-MCP-5) "agent://progress" { briefId, tool, line } // push run output (FR-AGENT-1) "error" { kind, message, context? } ``` ## 3. Internal Rust service traits These define the seams that let engines be swapped without touching callers. Signatures are indicative (async where I/O-bound). ```rust // audio/mod.rs pub trait AudioCapture: Send + Sync { /// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`. fn start(&self, wav_path: &Path, sink: FrameSink) -> Result; /// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV. fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result; fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>; fn stop(&self, h: CaptureHandle) -> Result; } // When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono // frames into the single transcription stream (`list_input_devices` enumerates // mic devices, mirroring `list_audio_devices` for render devices). // hardware/mod.rs pub trait HardwareDetector: Send + Sync { fn detect(&self) -> Vec; // ranked NPU→NVIDIA→AMD→Intel→CPU fn best(&self, preferred: Option) -> BackendInfo; } // transcription/mod.rs pub trait Transcriber: Send + Sync { fn load(model: &Path, backend: BackendId) -> Result where Self: Sized; /// Stream interim + final segments for an audio window. fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>; /// One-shot batch transcription (higher accuracy). fn transcribe_file(&self, wav: &Path) -> Result, TrxError>; } // diarization/mod.rs pub trait Diarizer: Send + Sync { fn diarize(&self, wav: &Path) -> Result, DiarError>; fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]); } // storage/mod.rs (async, sqlx) #[async_trait] pub trait Store: Send + Sync { async fn create_meeting(&self, m: NewMeeting) -> Result; async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>; async fn list_meetings(&self, f: MeetingFilter) -> Result, StoreError>; async fn get_meeting(&self, id: &MeetingId) -> Result; async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; async fn search(&self, q: &str) -> Result, StoreError>; async fn recover_scan(&self) -> Result, StoreError>; // FR-REL-1 async fn enforce_retention(&self, policy: Retention) -> Result; } // llm/mod.rs #[async_trait] pub trait LlmProvider: Send + Sync { async fn status(&self) -> LlmStatus; // reachable? local? models async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result; fn is_local(&self) -> bool; // FR-LLM-6 guard } // calendar/mod.rs // `attendees()` (a second, separate trait method in the original design) was dropped — every // source (PstSource, GraphSource) lists attendees inline per-appointment, so import() returns // them together (see ADR-0008's update). CalImport gained `from`/`to` (M4.4) for a source that // fetches by date range (Graph's calendarView); PstSource ignores them. pub trait CalendarSource: Send + Sync { fn import(&self, input: CalImport) -> Result, CalError>; // pst|graph|ics } // notes/mod.rs pub trait NotesRenderer: Send + Sync { fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String; fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result; } // MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an // inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls // instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md). // sync/mod.rs // One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology. // Secondary OAuth impls: OneDriveTarget, DropboxTarget, BoxTarget (ADR-0010). #[async_trait] pub trait SyncTarget: Send + Sync { fn kind(&self) -> SyncKind; fn is_third_party(&self) -> bool; // false for self-hosted WebDAV async fn test(&self) -> Result<(), SyncError>; // reachability + auth (FR-SYNC-4) async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError>; async fn exists(&self, remote_path: &str, sha256: &str) -> Result; // skip-if-unchanged /// Upload a file; resumable/chunked for large artifacts. Reports progress via `prog`. async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>; } // Owns the durable queue, retry/backoff, credential resolution, and TLS enforcement. #[async_trait] pub trait SyncManager: Send + Sync { async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>; async fn pump(&self) -> Result<(), SyncError>; // drive pending jobs (called on finalize + startup + timer) async fn status(&self, meeting_id: Option<&MeetingId>) -> Result, SyncError>; async fn retry(&self, job_id: &str) -> Result<(), SyncError>; } // llm/mod.rs — `LlmProvider` (above) gains hosted impls behind the same trait (ADR-0011): // OllamaProvider (local) · OpenAiCompatProvider (/v1/chat/completions) · AnthropicProvider (/v1/messages) // `is_local()` stays the egress guard; hosted impls return false and require an API key from the keychain. // mcp/mod.rs — WA as an MCP server (loopback, off by default). Tools-first (FR-MCP-2). #[async_trait] pub trait McpServer: Send + Sync { async fn start(&self, cfg: McpConfig) -> Result; // returns endpoint + token async fn stop(&self, h: McpHandle) -> Result<(), McpError>; fn tools(&self) -> Vec; // list_recent_meetings, get_transcript, get_action_items, get_feature_brief } // Builds the agent-ready spec from a transcript via the configured LlmProvider. #[async_trait] pub trait FeatureBriefBuilder: Send + Sync { async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result; } // agent/mod.rs — Layer 3 (later). Push handoff; one impl per CLI / tracker. #[async_trait] pub trait AgentRunner: Send + Sync { async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result; // claude -p / codex exec / … } #[async_trait] pub trait IssueTracker: Send + Sync { async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result; } ``` ## Versioning - Command/event names and payload shapes are versioned implicitly by `schema` fields in persisted JSON (`03-data-model.md`) and explicitly in a `CONTRACTS_VERSION` constant. Breaking a command shape requires bumping it and updating the frontend client in the same change (see `CLAUDE.md` "source of truth" rule).