Synapse Framework — Plugin system
Plugins are unsandboxed JavaScript modules that extend the desktop app: UI overlays, routes, editor hooks, bridge providers, filesystem access, and more. They load app-wide on the main webview only — undocked V3 tabs and Script Hub secondary webviews skip plugin bootstrap.
This guide covers the generic plugin API. Vendor-specific bridge plugins (Cosmic API, Velocity API, etc.) are not documented here.
What plugins are
| Property | Detail |
|---|---|
| Runtime | JavaScript executed in the main Tauri webview via pluginCodeRunner.ts |
| Bootstrap | GlobalPluginProvider.tsx calls pluginHost.bootstrap() once on the main window |
| Secondary webviews | isSecondaryBridgeWebview() → no bootstrap; bridge relay only |
| Lifecycle | activate(host) on enable; deactivate(host) on disable or framework restart |
| Storage | On-disk folder under app data; enabled list in localStorage |
flowchart TB
ZIP["Install ZIP"] --> Disk["plugins/{id}/"]
Disk --> Registry["localStorage registry"]
Registry --> Bootstrap["pluginHost.bootstrap()"]
Bootstrap --> Activate["activate(host)"]
Activate --> UI["Overlays / routes / hooks"]
Activate --> Bridge["Optional bridge provider"]
On-disk layout
Plugins live under the Tauri app data directory:
{app_data}/plugins/{plugin_id}/
plugin.synapse-plugin # Required — manifest + JS body
bin/ # Optional — exe, dll, assets
...
- Root path: resolved by
plugins_root()insrc-tauri/src/plugins.rs - Plugin IDs: alphanumeric,
-,_only - Multi-file packs: any relative path under the plugin folder (ZIP install extracts the full tree)
- Path traversal (
..) is rejected byplugins_resolve_path
File format
Each plugin is a single .synapse-plugin file (or plugin.synapse-plugin in a pack):
- Manifest block — JSON inside a comment header
- JavaScript body — plugin code with
activate/deactivateexports
/* @synapse-plugin-manifest
{
"format": "synapse-plugin",
"formatVersion": 1,
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "Optional description",
"permissions": {
"ui": true,
"nav": true
}
}
*/
function activate(host) {
host.log("activated");
}
function deactivate(host) {
host.log("deactivated");
}
exports.activate = activate;
exports.deactivate = deactivate;
Parsing and serialization: src/app/synapse-v3/plugins/pluginFileFormat.ts
Registry
Enabled plugins and load order are stored in localStorage:
| Key | synapse.v3PluginsRegistry.v1 |
|---|---|
enabledIds | Plugins to load on bootstrap |
order | Display / load order |
lastAppliedAt | Timestamp after Save + restart |
Enable / disable in the Plugins UI (setPluginEnabled in v3PluginRegistry.ts). Disabling a plugin writes a tombstone on disk (plugins_tombstone) so it does not reload until re-enabled.
Structural changes (new permissions, new routes, pack file updates) require Save and Restart Framework (host.restartFramework() or the Plugins page button).
Lifecycle
- Install —
plugins_install_zipextracts toplugins/{id}/ - Enable — add to
enabledIdsin registry - Save — persist registry; may prompt restart
- Restart Framework — reload webview;
pluginHost.bootstrap()runsactivate()for each enabled plugin - Disable —
deactivate(), tombstone, remove fromenabledIds - Delete —
plugins_deleteremoves folder and registry entry
Permissions
Declared in the manifest permissions object. Each key gates a group of host APIs. full: true grants everything.
| Permission | Gates |
|---|---|
tauri | Allowlisted invokeTauri commands (see below) |
fs | host.fs.*, readPluginFile, writePluginFile, resolvePluginPath |
http | host.http() — backend fetch, bypasses webview CORS |
process | host.exec, spawnPluginProcess, pluginProcessRequest, killPluginProcess, isPluginProcessAlive, findProcessByName |
bridge | Bridge hooks, providers, executeBridge, getBridgeStatus, emitBridgeLog, disablePlugin |
ai | registerAiTool, registerPluginsAiTool |
theme | patchV3Theme, patchShellTheme, patchOgTheme, patchSynapseXTheme, applyV3Theme |
editor | onEditorTabOpen, onEditorContentChange, registerEditorAction |
nav | navigate, navigateV3Page, registerV3Page, hideNavItem, showNavItem |
routes | registerRoute, registerGlobalRoute |
storage | getPluginStorage, setPluginStorage, dataDir, storageFile |
ui | mount, unmount, registerOverlay, registerShellOverlay, injectGlobalStyle |
settings | getAppSettings, patchAppSettings, setUiMode, restartFramework |
full | All of the above + unrestricted invokeTauri |
Source: src/app/synapse-v3/plugins/v3PluginTypes.ts
Host API reference
The host object passed to activate(host) is built in PluginHost.createHostApi(). Grouped by concern:
| Method | Permission | Description |
|---|---|---|
mount(target, node, options?) | ui / full | Render React/DOM/HTML into any live selector; returns disposer |
unmount(key) | ui / full | Remove a prior mount |
registerOverlay(slot, component) | ui / full | Register a React overlay in a UI slot |
registerShellOverlay(shell, component) | ui / full | Overlay mapped to a shell (blue, synapseV3, etc.) |
injectGlobalStyle(id, css) | ui / full | Inject global CSS; can auto-hide theme nav |
hideNavItem / showNavItem / isNavHidden | ui / full | Hide shell nav entries |
navigate(path, options?) | nav / full | React Router navigation |
setUiMode(mode, options?) | settings / full | Switch shell + optional reload |
registerV3Page({ id, label, render }) | nav / full | Add a V3 top-bar page |
navigateV3Page(page) | nav / full | Switch V3 page |
registerRoute / registerGlobalRoute | routes / full | Add React Router routes |
| Method | Permission | Description |
|---|---|---|
host.fs.read/write/remove/mkdir/exists/list | fs / full | Generic backend filesystem (any path) |
readPluginFile(relativePath?) | — | Read from plugin folder (default: plugin.synapse-plugin) |
writePluginFile(relativePath, contents) | fs / full | Write into plugin folder |
resolvePluginPath(relativePath) | fs / full | Absolute path inside plugin dir |
http(request) | http / full | { url, method?, headers?, body?, timeoutMs? } → { status, ok, headers, body } |
| Method | Permission | Description |
|---|---|---|
exec(request) | process / full | One-shot { program, args?, cwd?, stdin? } |
spawnPluginProcess(relativeExe, args?) | process / full | Long-lived process from plugin folder |
pluginProcessRequest(handle, request, timeoutMs?) | process / full | JSON IPC to spawned process |
killPluginProcess(handle) | process / full | Terminate spawned process |
isPluginProcessAlive(handle) | process / full | Check if handle is running |
findProcessByName(name) | process / full | OS process search (PIDs) |
| Method | Permission | Description |
|---|---|---|
getPluginStorage(key) / setPluginStorage(key, value) | storage / full | localStorage prefixed per plugin |
dataDir() | storage / full | Plugin-specific data directory path |
storageFile(name) | storage / full | { read(), write(contents) } for a file in data dir |
| Method | Permission | Description |
|---|---|---|
getAppSettings() / patchAppSettings(partial) | settings / full | Read/write app settings |
getV3Theme, patchV3Theme, applyV3Theme | theme / full | V3 theme |
getShellTheme, patchShellTheme | theme / full | Default shell theme |
getOgTheme, patchOgTheme | theme / full | Synapse Original theme |
getSynapseXTheme, patchSynapseXTheme | theme / full | Synapse X theme |
| Method | Permission | Description |
|---|---|---|
onEditorTabOpen(fn) | editor / full | Tab opened hook |
onEditorContentChange(fn) | editor / full | Content changed hook |
registerEditorAction({ id, label, run }) | editor / full | Editor toolbar action |
registerAiTool / registerPluginsAiTool | ai / full | Register AI function tools |
Plugins with bridge permission can observe, wrap, or replace legacy Port/Stream/Compat execute.
| Method | Description |
|---|---|
onBridgeExecute(fn) | Called before each execute |
wrapBridgeExecute(fn) | Transform or cancel execute ({ cancel, source }) |
registerBridgeProvider({ id, label, attach?, getStatus?, execute, detach?, native? }) | Register a custom executor backend |
unregisterBridgeProvider(id) | Remove provider; re-enables legacy if this plugin owned it |
setLegacyBridgeDisabled(disabled) | When true, blocks Port/Stream/Compat in Rust |
isLegacyBridgeDisabled() | Read legacy-disabled flag |
executeBridge(source, method?) | Dispatch execute via bridgeDispatch.ts |
getBridgeStatus() | Tauri bridge_status snapshot |
emitBridgeLog({ level, message }) | Push line to F9 console |
requestBridgeStatusRefresh() | Force immediate provider status poll |
disablePlugin() | Tombstone + remove from registry (recovery) |
Ownership model: When a provider calls setLegacyBridgeDisabled(true), only that provider’s execute runs. Undocked V3 tabs relay provider execute to the main window. Unregistering a provider calls bridge_set_legacy_enabled to restore Port/Stream/Compat.
See EXECUTOR_BRIDGE.md for legacy bridge methods.
| Method | Permission | Description |
|---|---|---|
restartFramework() | settings / full | Reload app webview |
restartFrameworkAsAdmin() | settings / full | Elevated restart |
isRunningElevated() | — | Check admin elevation |
reloadApp() | — | Alias for restartFramework() |
invokeTauri(cmd, args?) | tauri / full | Direct Tauri invoke (allowlisted unless full) |
log(...args) | — | Console + plugin loader log |
Tauri commands
Plugin management (permissions.tauri)
| Command | Purpose |
|---|---|
plugins_get_root | App data plugins directory |
plugins_list | List installed plugins + manifests |
plugins_read_file | Read file from plugin folder |
plugins_write_file | Write file to plugin folder |
plugins_delete | Delete plugin folder |
plugins_tombstone | Mark plugin disabled on disk |
plugins_install_zip | Install from ZIP |
plugins_export_zip | Export plugin as ZIP |
plugins_scaffold | Create blank plugin template |
plugins_resolve_path | Resolve relative path in plugin dir |
Process and data
| Command | Purpose |
|---|---|
plugin_process_spawn | Spawn long-lived process |
plugin_process_request | JSON request to spawned process |
plugin_process_kill | Kill spawned process |
plugin_process_alive | Check process alive |
plugin_process_find_by_name | Find PIDs by name |
plugin_data_dir | Per-plugin data directory |
plugin_fs_read/write/remove/mkdir/exists/list/search | Backend filesystem |
plugin_http_fetch | Backend HTTP |
plugin_process_exec | One-shot process exec |
Bridge-related (allowlisted)
| Command | Purpose |
|---|---|
bridge_status | Bridge connection snapshot |
bridge_send_execute | Queue script for legacy bridge |
bridge_set_legacy_enabled | Enable/disable legacy Port/Stream/Compat |
Framework
| Command | Purpose |
|---|---|
restart_framework | Reload webview |
restart_framework_as_admin | Elevated reload |
is_running_elevated | Admin check |
Full-power extras (permissions.full)
| Command | Purpose |
|---|---|
send_ai_chat | AI chat |
send_ai_chat_stream | Streaming AI chat |
Full list: PLUGIN_TAURI_COMMANDS and PLUGIN_TAURI_FULL_EXTRA in src/app/synapse-v3/plugins/pluginHostConstants.ts
UI overlay slots
Register components with host.registerOverlay(slot, Component):
| Slot | Typical use |
|---|---|
app | App-wide overlay |
blue | Default Synapse Blue shell |
synapseOriginal | Synapse Original 2017 |
synapseX | Synapse X shell |
synapseV3 | Synapse V3 shell |
shell | Generic shell wrapper |
editor | Editor chrome |
topBar | Top bar area |
pluginsPage | Plugins settings page |
actionBar | Editor action bar |
mainLayoutSidebar | Main layout sidebar |
initScreen | Loading / init screen |
Source: PLUGIN_UI_SLOTS in pluginHostConstants.ts
Multi-file packs and subprocesses
Packs: Install a ZIP containing plugin.synapse-plugin plus binaries, DLLs, configs, etc. Use host.resolvePluginPath("bin/my-tool.exe") to get absolute paths.
Subprocesses: spawnPluginProcess starts a singleton host process per plugin (src-tauri/src/plugin_process_host.rs). Communicate via pluginProcessRequest with JSON payloads. The Rust side enforces one process per plugin ID and cleans up on kill.
Typical pattern:
const { handle } = await host.spawnPluginProcess("bin/helper.exe", ["--mode", "server"]);
const result = await host.pluginProcessRequest(handle, { action: "ping" }, 5000);
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
permission required in console | Manifest missing permission | Add permission key, Save, Restart Framework |
| Plugin not loading after install | Not enabled or tombstoned | Enable in Plugins UI; check tombstone file |
| Overlay not visible | Wrong slot or selector | Verify PLUGIN_UI_SLOTS; use mount with a valid CSS selector |
mount target not found | DOM not ready | Defer mount until after navigation or use registerOverlay |
| Bridge execute fails | Provider owns bridge | Check legacy_disabled in bridge_status; use provider or disable provider |
| Changes not applied | Structural change without restart | Save + Restart Framework |
Command not allowed on invoke | Not in allowlist | Add tauri or full permission |
| CSS not applying | Specificity | Use injectGlobalStyle with unique id; check shell-specific selectors |
Selector tips: Prefer data-* attributes on stable shell elements. Test in each uiMode you support. Use hideNavItem instead of fragile CSS when hiding nav entries.
Key source files
| Concern | Path |
|---|---|
| Host API | src/app/synapse-v3/plugins/PluginHost.ts |
| Code runner | src/app/synapse-v3/plugins/pluginCodeRunner.ts |
| File format | src/app/synapse-v3/plugins/pluginFileFormat.ts |
| Registry | src/app/synapse-v3/plugins/v3PluginRegistry.ts |
| Tauri allowlist | src/app/synapse-v3/plugins/pluginHostConstants.ts |
| Global bootstrap | src/app/plugins/GlobalPluginProvider.tsx |
| Rust plugin I/O | src-tauri/src/plugins.rs |
| Plugin backend FS/HTTP | src-tauri/src/plugin_backend.rs |
| Subprocess host | src-tauri/src/plugin_process_host.rs |
Related documentation
- EXECUTOR_BRIDGE.md — Port, Stream, Compat legacy bridge
- PROJECT_OVERVIEW.md — App architecture and module map