---
title: Plugin runtime
description: How a plugin is written, loaded, and run.
---

A plugin is an ES module whose default export is `definePlugin(…)` from
`@vimputer/plugin-sdk`. It runs in Electron main, trusted, with no sandbox. The
host owns persistence, the picker, and how actions are drawn; the plugin supplies
data and code.

```ts plugins/example/src/index.ts
import { z } from 'zod'
import { defineAction, definePlugin } from '@vimputer/plugin-sdk'

const run = defineAction({
  id: 'run',
  label: 'Example command',
  paramsSchema: z.object({ url: z.string() }),
  async run(ctx, { url }) {
    await ctx.openUrl(url)
  },
  // Pure: recognize an imported action as this one, or pass.
  claim: (action) => (action.kind === 'open_url' && action.url.startsWith('example://') ? { url: action.url } : null),
})

export default definePlugin({
  id: 'example',
  label: 'Example',
  actions: [run],
  async discover(ctx) {
    return { groups: [{ id: 'all', label: 'Commands', items: [/* { id, title, actionId: 'run', actionParams } */] }] }
  },
})
```

## What a plugin can export

| Export | Does |
| --- | --- |
| `actions` | Code that runs when a bound key fires. `paramsSchema` validates the binding's params. |
| `discover(ctx)` | The external inventory as groups of items, each naming an action and its params. |
| `claim` on an action | Rewrites a matching imported action into this plugin's action. Must be pure. |
| `views` | Pages and author dialogs, written in React against ten host elements. |
| `activate(ctx)` | A long-lived service, such as a socket. Returns `{ dispose() }`. |

Discovery is stale-while-revalidate: the last snapshot, persisted to disk, is served
at once and refreshed in the background. Saving a plugin instance, or a service
calling `invalidateDiscovery()`, forces a fresh scan.

## The action context

Every `run`, `discover`, and service gets a `ctx`:

| Member | For |
| --- | --- |
| `exec(file, args)` | Run a binary with argv, never through a shell |
| `fetch`, `openUrl`, `notify` | HTTP, URLs, notifications |
| `instances` | Plugin-owned records, such as Soundboard's boards |
| `secrets` | Passwords and keys, encrypted with `safeStorage` |
| `assets` | Icons and imported files, served over `plugin-asset://` |
| `dialog`, `fs`, `logger` | File pickers, scoped filesystem reads, logging |

## Views

A plugin's React tree is reconciled in main and streamed to the renderer as a
snapshot, which draws it with `@vimputer/ui`. The vocabulary is `page`, `section`,
`list`, `list.item`, `text-field`, `button`, `audio`, `drop-zone`, `empty`, and
`status`. Page views appear under Settings → Integrations; author views open inside
Add from plugin.

## Loading

- **First-party:** modules under `apps/desktop/plugins/<id>/`, imported directly in
  `apps/desktop/src/main/index.ts`. No `plugin.json`.
- **Local:** a folder in `~/Library/Application Support/Vimputer/plugins/<id>/` with
  a `plugin.json` giving `id` and `entry`, the path to a built JS module. There is no
  install UI.

Services start when the app starts or the plugin is enabled, restart on reload, and
are disposed when the plugin is disabled or the app quits.

## Layers from plugins

**Add from plugin** writes a group's items into the current layer as ordinary
`plugin_action` bindings and records the source in the layer's `pluginStamps`.
Re-picking a stamped group adds only items whose action and params match no binding
in the layer. Nothing is ever removed or overwritten.

Reference implementations: Shottr is the smallest (URL scheme, no service). OBS and
Paseo are live services. Soundboard builds its inventory from instances and views.
