- Status: Stable
- Scope: The boundary between the Tauri v2 Rust backend and the React WebView in the desktop app.
- Related: sse-event-schema.md, ../desktop/tauri-plugins.md, ../desktop/database-schema.md, ../desktop/keychain-and-secrets.md, ../../architecture/desktop-architecture.md, ADR-0018
The desktop app is split across a Tauri boundary: the Rust backend owns the filesystem, the OS keychain, SQLite, child processes, the system tray, and the per-call authenticated LLM HTTP egress; the React WebView owns the ReactFlow canvas, all UI, and the @relavium/core engine plus agent-node orchestration (the engine is pure TypeScript and runs in the WebView's JS runtime, identically to every other surface). They communicate only by message passing — every value crossing the boundary is JSON-serializable. This document is the canonical list of that surface.
flowchart LR
subgraph WebView["React WebView"]
UI[Canvas / panels / stores]
ENG["@relavium/core engine<br/>+ agent-node orchestration"]
end
subgraph Rust["Tauri Rust backend"]
EGRESS["LLM HTTP egress<br/>(reqwest, key from keychain)"]
SQL[SQLite history.db]
KC[OS keychain]
HTTP[axum loopback server]
end
UI -->|invoke command| Rust
ENG -->|invoke llm_stream| EGRESS
EGRESS -->|read key| KC
EGRESS -->|Channel StreamChunk| ENG
Rust -->|window.emit event| UI
HTTP -. SSE mirror .-> VSC[VS Code extension]
Tauri v2 offers three primitives; Relavium uses each for a distinct job.
| Primitive | Direction | Use |
|---|---|---|
Commands (#[tauri::command]) |
WebView → Rust, request/response | Load/save workflows and agents, start/cancel runs, read run history, manage keys. The frontend calls invoke('cmd', {args}) and gets a Promise<Result>. |
Channels (tauri::ipc::Channel) |
Rust → WebView, ordered high-throughput stream | Stream normalized StreamChunk frames from the Rust-delegated llm_stream egress for a single agent call. Typed, backpressure-aware, no per-message string-serialization overhead. (Run events are not streamed from Rust — the engine's RunEventBus is WebView-side; see Run events are WebView-side.) |
Events (window.emit / listen) |
Rust → WebView, broadcast | Loose-coupling system signals: active-run-count change (tray badge), update availability, MCP server health changes. |
Commands and channels are the primary surfaces; events handle UI elements not tied to a specific run.
All commands return a Result; the WebView receives a resolved value or a typed error.
| Command | Args | Returns | Purpose |
|---|---|---|---|
list_workflows |
{ workspaceRoot } |
WorkflowMeta[] |
Enumerate .relavium/ workflow files. |
load_workflow |
{ path } |
WorkflowDefinition |
Parse + validate a workflow file. |
save_workflow |
{ path, definition } |
{ path } |
Serialize a workflow back to YAML. |
list_agents |
{ workspaceRoot } |
AgentConfig[] |
Enumerate .agent.yaml files. |
save_agent |
{ path, agent } |
{ path } |
Persist an agent file. |
start_run |
{ workflowPath, inputs, options? } |
{ runId } |
Begin a WebView-resident engine run (and the Rust-side bookkeeping: active-run count, checkpoint persistence). Run events are consumed from the in-WebView RunEventBus, not pushed from Rust — see Run events are WebView-side. |
cancel_run |
{ runId } |
void |
Cancel an in-flight run (interrupts agent calls). |
resume_run |
{ runId, gateId, decision } |
void |
Resolve a paused human gate (see sse-event-schema.md). |
list_runs |
{ filter? } |
RunSummary[] |
Read run history from SQLite. |
get_run_state |
{ runId } |
RunState |
Durable run snapshot (used for resync). |
pick_file |
{ filters, multiple } |
string[] |
Native file picker (tauri-plugin-dialog). |
set_provider_key |
{ providerId, keyId, secret } |
void |
Store a key in the OS keychain (secret never echoed back). |
get_key_status |
{ providerId } |
'valid' | 'invalid' | 'unchecked' |
Key presence/health — never the key itself. |
list_mcp_servers |
— | McpServerConfig[] |
Read configured MCP servers. |
llm_stream |
{ providerId, keyId, endpoint, headers, body } + a Channel<StreamChunk> |
void (chunks arrive on the channel) |
Perform one authenticated streaming LLM HTTPS request on behalf of the WebView-resident engine. Rust reads the provider key from the OS keychain, sets the Authorization header, issues the request (reqwest), and streams raw provider chunks back. The raw key value never enters the WebView. See Rust-delegated LLM egress. |
start_session |
{ agentRef, context } |
{ sessionId } |
Open a chat agent session (agent-session-spec.md). |
send_message |
{ sessionId, text } |
void (events arrive on the in-WebView bus) |
Run one assistant turn; tokens/tool-calls surface as session:* + reused agent:* events. |
cancel_session |
{ sessionId } |
void |
Abort the in-flight turn; the session stays resumable. |
resume_session |
{ sessionId } |
SessionState |
Reload a persisted session to continue it. |
list_sessions |
{ filter? } |
SessionSummary[] |
Read session history from history.db. |
get_session_state |
{ sessionId } |
SessionState |
Durable session snapshot. |
delete_session |
{ sessionId } |
void |
Soft-delete a session. |
export_session |
{ sessionId } |
{ workflowPath } |
Export the session to a .relavium.yaml scaffold (ADR-0026). |
resume_budget |
{ runId, decision } |
void |
Resolve a budget:paused suspension (ADR-0028). |
Secrets cross the boundary only inbound (
set_provider_key) — and never even inbound forllm_stream, which names the key by{ providerId, keyId }and lets Rust resolve it at call time. No key value is ever returned to the WebView. See ../desktop/keychain-and-secrets.md.
The @relavium/core engine and its agent-node orchestration run in the WebView's JS runtime, identically to the CLI, the VS Code extension host, and the Phase-2 Bun API — Rust does not re-implement workflow execution. The engine and the @relavium/llm adapters are pure TypeScript and depend on an injected HTTP transport. On Node-style surfaces (CLI, extension host, Bun API) that transport is a direct fetch/SDK call inside the one trusted process, with the key resolved at call time and never persisted or logged.
On the desktop, the injected transport is the llm_stream command above: the WebView-resident adapter hands Rust the request (provider id + key id, endpoint, headers, JSON body) and a Channel; Rust reads the key from the OS keychain, sets the Authorization header, performs the streaming HTTPS request with reqwest, and streams the provider's raw chunks back over the channel. The adapter (still in the WebView) folds those chunks into the normalized StreamChunk union. This is the only part of the LLM path that is a Rust command — engine orchestration, normalization, fallback, and cost accounting all stay in the WebView. The benefit is that the raw key value never enters the WebView's JS/renderer; only a non-sensitive key reference does. See ../desktop/keychain-and-secrets.md and ../../architecture/local-first-and-security.md.
Forward note — media-output turns (ADR-0032, not yet implemented). For a turn that returns inline media, this raw-chunk wiring is amended: the Rust
llm_streamcommand de-inlines the inline media bytes to a Rust-side content-addressed store and forwards only a handle on theChannel<StreamChunk>(multi-MB media never crosses IPC), and a session-scopedread_media(ref)command serves display bytes off the hot channel. That media transport-frame +read_mediawill be specified here when ADR-0032 is implemented (roadmap 1.AH / Phase E). Until then, the raw-chunk + WebView-normalization contract above is the live text/tool/reasoning path; ADR-0032 records the decision, this contract is not yet updated to it.
There is exactly one kind of Channel on the LLM hot path that crosses the IPC boundary from Rust: the Channel<StreamChunk> returned by the llm_stream egress (above). Run events (node:started, agent:token, cost:updated, …) do not cross IPC as RunEvents on the desktop. Because the engine runs in the WebView, its RunEventBus and the stores that consume it share one JS runtime — run events are produced and consumed entirely WebView-side and never travel over a Tauri channel. This is the canonical model recorded in ADR-0018.
The start_run command therefore does not push a Channel<RunEvent> from Rust. It kicks off the WebView-resident engine run (and the Rust-side bookkeeping such as the active-run count and checkpoint persistence) and returns a runId; the WebView subscribes to its own in-process RunEventBus:
const { runId } = await invoke('start_run', { workflowPath, inputs });
// The engine's RunEventBus is in the same JS runtime as the stores.
engine.events(runId).subscribe((event) => runStore.handleRunEvent(event));LLM tokens reach the engine over the llm_stream Channel<StreamChunk>; the WebView adapter folds those chunks into the normalized StreamChunk union, and the engine re-emits them on its in-WebView RunEventBus as agent:token RunEvents, which the stores consume directly.
- Event shape: the full
RunEventdiscriminated union — defined once in sse-event-schema.md. On the desktop it is an in-WebView shape, not an IPC payload; it becomes a wire payload only over HTTP SSE in Phase 2 and on the loopback VS Code mirror (below). - Backpressure on the egress channel: if the WebView consumer lags, the
Channel<StreamChunk>'s internal buffer fills and the Rust sender awaits, throttling the egress without dropping chunks. - Routing: the engine dispatches each event into
runStorebynodeId, deliberately kept out of the canvas store so ReactFlow does not re-render per token (see ../shared-core/store-shapes.md).
| Event name | Payload | Consumer |
|---|---|---|
active-runs-changed |
{ count, awaitingGate } |
Tray badge + status indicators. |
mcp-health-changed |
{ serverName, status } |
MCP server status UI. |
update-available |
{ version } |
Update prompt. |
For the VS Code extension's optional desktop-enhancement mode, the Rust backend also runs an axum HTTP server bound to 127.0.0.1:{dynamic_port} inside the same tokio runtime (no extra process). It writes ~/.relavium/ipc.json ({ port, authToken, pid, startedAt }) and mirrors the same RunEvent stream over HTTP SSE. The extension reads that file to discover the port and bearer token.
- Binds loopback only (never
0.0.0.0);ipc.jsonis written with0600permissions;authTokenis a 256-bit hex string valid for the app process lifetime. - The HTTP protocol is framework-decoupled — the extension would work the same against any future backend. Details and the extension side live in ../vscode/extension-api.md; the event shape is the shared sse-event-schema.md.
- Everything crossing the boundary is JSON-serializable; raw streams are carried by channels, not passed directly.
- Commands are request/response; never use a command to push streaming data — use a channel.
- The Rust side enforces the Tauri v2 capability manifest; any plugin API the WebView calls must be declared, or it fails silently at runtime. See ../desktop/tauri-plugins.md.