Skip to main content
Every webview in Jarvis — including your plugins — gets window.jarvis.ipc injected automatically. This is your communication channel to the Rust backend, enabling clipboard access, file reading, panel management, and more.

Core API

The IPC bridge provides three core methods for communication:
// Send a fire-and-forget message to Rust
window.jarvis.ipc.send(kind, payload);

// Listen for messages from Rust
window.jarvis.ipc.on(kind, callback);

// Send a request and await a response (returns a Promise)
const result = await window.jarvis.ipc.request(kind, payload);

Checking Bridge Availability

The bridge is available as soon as your script runs. There is no DOMContentLoaded race because the initialization script runs before your page’s scripts.
if (window.jarvis && window.jarvis.ipc) {
  // IPC bridge is ready
  window.jarvis.ipc.send('ping', {});
}

Sending Messages to Rust

ipc.send(kind, payload)

Fire-and-forget message sending. Use this for operations that don’t need a response.
// Copy text to the system clipboard
window.jarvis.ipc.send('clipboard_copy', { text: 'Hello from my plugin!' });

// Navigate to a URL in the current pane
window.jarvis.ipc.send('open_url', { url: 'https://example.com' });

// Send a ping (Rust will reply with 'pong')
window.jarvis.ipc.send('ping', {});
Implementation Details: Internally, send serializes { kind, payload } as JSON and posts it via window.ipc.postMessage. The Rust IPC handler validates the message against a strict allowlist before processing.
The payload parameter can be any JSON-serializable object. Use {} for messages that don’t need data.

Receiving Messages from Rust

ipc.on(kind, callback)

Register a handler for messages sent from Rust to your webview.
// Listen for pong responses
window.jarvis.ipc.on('pong', (payload) => {
  console.log('Got pong:', payload);
});

// Listen for theme changes (broadcast to all webviews)
window.jarvis.ipc.on('theme_update', (payload) => {
  applyTheme(payload);
});
Registration Rules:
  • You can register one handler per message kind
  • Calling on again for the same kind replaces the previous handler
  • The callback receives the payload object as its argument

Request-Response Pattern

ipc.request(kind, payload) → Promise

For operations that need a result back from Rust. Uses an internal request ID and has a 10-second timeout.
// Read the clipboard (request-response)
try {
  const result = await window.jarvis.ipc.request('clipboard_paste', {});
  if (result.kind === 'text') {
    console.log('Clipboard text:', result.text);
  } else if (result.kind === 'image') {
    console.log('Clipboard image data URL:', result.data_url);
  }
} catch (err) {
  console.error('Clipboard read failed:', err);
}

How It Works

1

Assign request ID

JavaScript calls request(kind, payload). The bridge assigns payload._reqId = N (incrementing integer).
2

Send message

The message is sent to Rust via send.
3

Rust processes request

Rust processes the request and sends a response IPC message back to the webview, including _reqId: N in the payload.
4

Resolve promise

The bridge’s augmented _dispatch function checks incoming messages for _reqId. If found, it resolves (or rejects, if payload.error is present) the matching pending Promise.
5

Timeout handling

If no response arrives within 10 seconds, the Promise rejects with Error('IPC request timeout').
If no _reqId match is found, the message is passed to the regular on handler.

Available IPC Messages

Messages You Can Send (JS → Rust)

// Copy text to clipboard
window.jarvis.ipc.send('clipboard_copy', { text: 'Hello!' });

// Read from clipboard (request-response)
const result = await window.jarvis.ipc.request('clipboard_paste', {});
// Returns: { kind: 'text', text: '...' } or { kind: 'image', data_url: '...' }

Complete Message Reference

KindPayloadTypeDescription
ping{}sendHealth check. Rust replies with pong.
clipboard_copy{ text: string }sendCopy text to system clipboard.
clipboard_paste{}requestRead clipboard contents. Returns { kind, text?, data_url? }.
open_url{ url: string }sendNavigate current pane to URL. URLs without scheme get https:// prepended.
launch_game{ game: string }sendLaunch built-in game in current pane.
open_settings{}sendOpen Settings panel.
open_panel{ kind: string }sendOpen new panel of given kind (e.g., “terminal”).
panel_close{}sendClose current panel (won’t close last one).
read_file{ path: string }requestRead image file from disk. Returns { data_url } or { error }. Max 5 MB, images only. Supports ~/ expansion.
pty_input{ data: string }sendSend input to terminal PTY (if this pane has one).
pty_resize{ cols: number, rows: number }sendResize terminal PTY.
keybind{ key, ctrl, alt, shift, meta }sendSimulate keybind press.
window_drag{}sendStart dragging the window.
debug_event{ type: string, ...data }sendLog structured data to Rust tracing::info! output.

Messages You Can Receive (Rust → JS)

KindPayloadWhen
pong"pong"After you send ping
palette_show{ items, query, selectedIndex, mode, placeholder }Command palette opens (handled automatically by injected script)
palette_update{ items, query, selectedIndex, mode, placeholder }Command palette state changes
palette_hide{}Command palette closes
The palette messages are handled by the injected command palette overlay system. You don’t need to handle them in your plugin unless you want to customize palette behavior.

Keyboard and Input Handling

How Keyboard Events Work

Keyboard events in plugins follow a layered interception model:
1

Overlay mode

When the command palette or assistant is open, ALL keyboard input is captured by the overlay. Your plugin receives no key events during this time.
2

Command keys

Most Cmd/Ctrl+key combinations are intercepted by the IPC bridge’s capture-phase keydown listener and forwarded to Rust as keybind messages. This is how Cmd+T (new pane), Cmd+W (close pane), etc. work even when a plugin is focused.
3

Escape key

Always forwarded to Rust. If the pane is showing a plugin (tracked in game_active), Escape navigates back to the previous page.
4

Normal mode

All other keyboard input goes to your plugin’s webview normally. Standard HTML elements like <input>, <textarea>, and custom key handlers work as expected.

Keys That Pass Through

These Cmd+key combinations are NOT intercepted and reach your plugin normally:
  • Cmd+R / Ctrl+R — useful for refreshing the plugin during development
  • Cmd+L / Ctrl+L
  • Cmd+Q / Ctrl+Q
  • Cmd+A / Ctrl+A — select all
  • Cmd+X / Ctrl+X — cut
  • Cmd+Z / Ctrl+Z — undo

Handling Escape in Your Plugin

If your plugin has modal dialogs or internal states that should close on Escape, handle it before the IPC bridge does:
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    if (myModalIsOpen) {
      closeMyModal();
      e.stopPropagation();  // Attempt to prevent bridge from forwarding
      return;
    }
    // Otherwise, Escape navigates back to the previous page
  }
});
e.stopPropagation() may not prevent the bridge from seeing Escape in all cases, since the bridge uses a capture-phase listener. Structure your plugin so that Escape-to-exit is acceptable behavior.

Security and Validation

IPC Allowlist

All IPC messages from JavaScript are validated against a strict allowlist before processing. Only these kind values are accepted:
pty_input          pty_resize        pty_restart       terminal_ready
panel_focus        presence_request_users              presence_poke
settings_init      settings_set_theme                  settings_update
settings_reset_section               settings_get_config
assistant_input    assistant_ready
open_panel         panel_close       panel_toggle      open_settings
status_bar_init    launch_game
ping               boot_complete     crypto            window_drag
keybind            read_file
clipboard_copy     clipboard_paste   open_url
palette_click      palette_hover     palette_dismiss
debug_event
Any message with a kind not in this list is silently dropped with a warning log:
IPC message rejected: unknown kind (pane_id: 1, kind: "exec")
The allowlist check is case-sensitive. "PTY_INPUT" is rejected. Injection attempts like "ping; rm -rf /" are also rejected.

Message Validation

The IPC handler validates that:
  1. The raw message body is valid JSON
  2. The message contains a kind field
  3. The kind is in the allowlist
  4. The payload is properly structured

Common Patterns

Persistent Data Storage

// Save data to localStorage
localStorage.setItem('my-plugin:data', JSON.stringify(myData));

// Load data from localStorage
const myData = JSON.parse(localStorage.getItem('my-plugin:data') || '{}');
Always namespace your keys with your plugin ID. All plugins share the same jarvis://localhost localStorage origin.

Clipboard Operations

// Copy text to clipboard
function copyToClipboard(text) {
  window.jarvis.ipc.send('clipboard_copy', { text });
}

// Read from clipboard (async)
async function readClipboard() {
  const result = await window.jarvis.ipc.request('clipboard_paste', {});
  return result?.text || '';
}

Reading Image Files

async function readImageFile(path) {
  try {
    const result = await window.jarvis.ipc.request('read_file', { path });
    if (result.error) {
      console.error('Failed to read file:', result.error);
      return null;
    }
    // result.data_url contains a base64 data URL
    return result.data_url;
  } catch (err) {
    console.error('Read file request failed:', err);
    return null;
  }
}
read_file only supports image files (PNG, JPEG, GIF, WebP, BMP) with a maximum size of 5 MB. Supports ~/ expansion.

Opening Panels

// Open a new terminal pane
window.jarvis.ipc.send('open_panel', { kind: 'terminal' });

// Close the current panel
window.jarvis.ipc.send('panel_close', {});

Inter-Plugin Communication

Plugins share the same localStorage namespace. You can use this as a simple message bus:
// Plugin A: send a message
localStorage.setItem('plugin-bus:message', JSON.stringify({
  from: 'plugin-a',
  type: 'data-updated',
  timestamp: Date.now()
}));

// Plugin B: listen for messages
window.addEventListener('storage', (e) => {
  if (e.key === 'plugin-bus:message') {
    const msg = JSON.parse(e.newValue);
    console.log('Got message from', msg.from, msg.type);
  }
});
storage events only fire in other windows/tabs, not the one that made the change. Since plugins in different panes run in different webviews, this works for cross-pane communication.

Debugging

Ping/Pong Test

Verify the IPC bridge is working:
window.jarvis.ipc.on('pong', () => console.log('IPC bridge works!'));
window.jarvis.ipc.send('ping', {});

Debug Events

Send structured debug events to the Rust log:
window.jarvis.ipc.send('debug_event', {
  type: 'my_plugin_state',
  data: { count: 42, status: 'running' }
});
These appear in the Jarvis log output as tracing::info! entries:
[JS] webview event (pane_id: 1, event: {"type":"my_plugin_state","data":{"count":42,"status":"running"}})

Built-in Diagnostic Logging

The IPC bridge automatically sends diagnostic events for certain DOM events:
  • mousedown events (with coordinates and target element)
  • keydown events (with key, code, and modifier state)
  • focus and blur events
These are sent as debug_event IPC messages by the initialization script and appear in the Rust log during development.

Implementation Details

IPC Initialization Script

The IPC bridge is injected as an initialization script (IPC_INIT_SCRIPT) that runs before any page scripts. This ensures window.jarvis.ipc is available immediately. The initialization script provides:
  • window.jarvis.ipc.send() — fire-and-forget messaging
  • window.jarvis.ipc.on() — message handler registration
  • window.jarvis.ipc.request() — request-response pattern
  • Command palette overlay system
  • Keyboard shortcut forwarder
  • Clipboard API polyfill
  • Diagnostic event logging

Message Format

Messages sent from JavaScript to Rust have this structure:
{
  kind: "clipboard_copy",
  payload: { text: "Hello!" }
}
Messages sent from Rust to JavaScript are dispatched via:
window.jarvis.ipc._dispatch(kind, payload);

Request-Response Internals

The request-response pattern works by:
  1. Assigning a unique _reqId to the payload
  2. Storing a { resolve, reject } Promise in _pendingRequests
  3. Setting a 10-second timeout
  4. Matching incoming messages by _reqId and resolving/rejecting accordingly
This allows multiple concurrent requests without response mixing.