Skip to content

Writing Plugins

Verql’s main process is an orchestrator. The actual database logic, import/export formats, type translation, UI panels, AI tools, and connection middleware all come from plugins. Adding a new database or feature should not require editing the main app.

This document covers:

  1. What a plugin looks like — the manifest + activate function
  2. Plugin types — the contribution surfaces (driver, exporter, importer, formatter, type mapper, theme, panel, command, …) with worked examples
  3. Lifecycle — how plugins are discovered, validated, resolved, activated
  4. Where things live — directory and naming conventions for bundled plugins

For adding a new IPC channel after the contribution surface is built, see IPC channels.

A plugin is a directory with two files at minimum:

my-plugin/
├── manifest.json # what the plugin contributes (declarative)
└── index.ts # activate(ctx) function (runtime wiring)

The manifest is purely declarative — it tells the orchestrator what the plugin will provide. The activate(ctx) function is invoked once at boot and is where the plugin registers those contributions through the SDK.

{
"name": "verql-plugin-cassandra",
"version": "1.0.0",
"displayName": "Cassandra",
"description": "Apache Cassandra driver",
"main": "index.js",
"contributes": {
"drivers": [{ "id": "cassandra", "name": "Cassandra" }],
"exporters": [{ "id": "cql", "name": "CQL", "extension": "cql" }],
"importers": [{ "id": "cql", "name": "CQL", "extensions": ["cql"] }]
}
}

The contributes block tells the orchestrator what to expect. After activation the orchestrator verifies that everything declared here was actually registered through the SDK — if a plugin promises a cassandra driver and activate() doesn’t register one, the plugin ends up in 'degraded' state with a clear error.

Manifest validation rules:

  • name matches ^[a-z0-9-]+$ and is unique
  • version is valid semver
  • main ends in .js
  • displayName and description are non-empty

These are enforced for every plugin, including the bundled ones in this repo (since the audit; see tests/unit/plugin-boot.test.ts).

The idiomatic shape uses definePlugin so the compiler pins every field against the SDK’s PluginModule type. Missing fields, mistyped contributions, or a wrong activate signature fail at compile time instead of waiting for the boot pipeline to report a runtime error.

my-plugin/index.ts
import { definePlugin } from '@verql/plugin-sdk'
import { CassandraAdapter } from './cassandra-adapter'
export default definePlugin({
manifest: {
name: 'verql-plugin-cassandra',
version: '1.0.0',
displayName: 'Cassandra',
description: 'Apache Cassandra driver',
main: 'index.js',
contributes: {
drivers: [{ id: 'cassandra', name: 'Cassandra' }],
},
},
activate(ctx) {
ctx.drivers.register('cassandra', {
createAdapter: (config) => new CassandraAdapter(config),
sqlDialect: 'cassandra', // free-form label, never branched on
quoteChar: '"',
placeholderStyle: 'positional', // 'positional' ⇒ ? | 'numbered' ⇒ $1,$2
editorLanguage: 'cql',
defaultSchemaCandidates: ['system'],
connectionFields: [
{ key: 'contactPoints', label: 'Contact Points', type: 'text', required: true },
{ key: 'keyspace', label: 'Keyspace', type: 'text', required: true },
{ key: 'username', label: 'Username', type: 'text' },
{ key: 'password', label: 'Password', type: 'password' },
],
sampleQuery: async (table) => `SELECT * FROM ${table} LIMIT 100;`,
getTableData: async (adapter, table) => {
const result = await adapter.query(/* safely-escaped CQL */)
return { rows: result.rows, columns: /* … */ [] }
},
})
},
// Optional. The SDK tears down subscriptions tracked through ctx.*
// registries automatically. Use this hook only for things outside the
// SDK (raw timers, sockets, child processes).
deactivate() {},
})

ctx is a sandboxed view of the orchestrator. Everything a plugin can do goes through one of its sub-registries:

ctx.XWhat it lets the plugin do
driversRegister a database driver (see Driver)
drivers.registerConnectionMiddlewarePre-/post-connect hooks (e.g. SSH tunnel)
commandsRegister a command palette entry
panelsRegister a UI panel slot
uiRegister declarative widgets (status-bar items, toolbar selectors, slot resolvers)
completionsContribute autocomplete items to the query editor
exportersRegister an export format (CSV, JSON, SQL DDL, …)
importersRegister an import format
formattersPretty-print the query buffer for a dialect
typeMappersTranslate column types between dialects
aiProvide AI tools, providers, or context
servicesProvide / consume named cross-plugin services
settingsRead & subscribe to plugin-scoped settings
schemaRead introspected schema for a connection
connectionsRead active connection profile / run queries on it
keyringRead/write secrets in the OS keyring
ipcOwn typed IPC channels (only if your plugin needs renderer↔plugin RPC outside the SDK)
broadcastPush events to the renderer

Disposable returns. Every ctx.X.register(...) returns a Disposable and is automatically tracked under ctx.subscriptions. When the plugin is deactivated, those subscriptions are disposed in LIFO order — so the plugin doesn’t have to manage cleanup explicitly.

This section is the menu of contribution surfaces. Each shows the manifest declaration, the activate() registration call, and where in this repo to find a worked example.

A driver makes a new database type available. It must implement the DbAdapter interface (src/main/db/adapter.ts).

Manifest

"contributes": {
"drivers": [{ "id": "cassandra", "name": "Cassandra" }]
}

Activation

ctx.drivers.register('cassandra', {
createAdapter: (config) => new CassandraAdapter(config),
connectionFields: [...],
// Free-form badge — never branched on. Use it for displayNames like
// "SQL (Cassandra)".
sqlDialect: 'cassandra',
// Structural capabilities the generic helpers consume. `quoteChar` is the
// identifier quote; `placeholderStyle` is the prepared-statement placeholder
// style — 'numbered' ⇒ $1,$2 (Postgres); 'positional' ⇒ ? (MySQL/SQLite/
// Snowflake). Both are plain serializable data (no functions) so the driver
// descriptor can be process-isolated. The generic CSV → table importer
// renders placeholders from the style via the SDK's `renderPlaceholder`.
quoteChar: '"',
placeholderStyle: 'positional',
editorLanguage: 'cql', // Monaco language id
defaultSchemaCandidates: ['system'], // renderer picks first match
defaultSchemaUseConnectionDatabase: false,
// Which built-in statement splitter the CodeLens "Run/Explain" gutter uses.
// The renderer owns the (Monaco-coupled) splitters and selects one by this id;
// there is no hardcoded db-type list. Omit to disable the per-statement gutter.
statementSyntax: 'sql', // 'sql' | 'redis' | 'mongodb'
// Driver-owned error classification. Each rule's regex (matched
// case-insensitively against the cleaned message; first capture group → the
// message variable) maps to a DbErrorCode; the renderer owns the friendly
// i18n message. Lets a new driver get column-not-found / syntax / constraint
// messages without any host change. Host classifies connection/auth/app errors.
errorRules: [
{ code: 'COLUMN_NOT_FOUND', pattern: 'Undefined column name ([^;]+)' },
{ code: 'SYNTAX_ERROR', pattern: 'line \\d+:\\d+ (no viable alternative.*)' },
// …
],
// REQUIRED for every driver — the orchestrator refuses to fabricate a
// "SELECT * FROM table LIMIT 100" fallback. Async so an isolated driver can
// answer over the RPC bridge.
sampleQuery: async (table, schema) => `SELECT * FROM ${table} LIMIT 100;`,
// Reads all rows for export. Use `createRelationalGetTableData(quoteChar)`
// from the SDK if your driver speaks plain SELECT.
getTableData: async (adapter, table, schema) => { ... },
// Used by the migration tool when this driver is the *target* of a
// schema migration. Async; compose `generateCreateTable()` from the SDK or
// hand-roll for dialect-specific quirks (e.g. SQLite's INTEGER
// PRIMARY KEY rowid alias).
generateMigrationDdl: async (tableName, columns) => /* DDL */
})

The SDK exposes the helpers you’ll need for the common case:

import {
quoteIdentifier, // safe identifier escaping (takes quote char)
formatSqlValue, // value → SQL literal
generateCreateTable, // basic CREATE TABLE for a column list
generateInsertStatements, // single-row INSERTs
splitSqlStatements, // SQL file → statements
createRelationalGetTableData,
validateTheme, // run-your-own-CI helper for themes
REQUIRED_THEME_TOKENS,
} from '@verql/plugin-sdk'

Look at any of the bundled drivers for a full example: postgresql, mysql, sqlite, snowflake, mongodb, redis.

Your adapter (returned by createAdapter) may also implement parseQueryPlan(result): PlanNode[] — the renderer calls it over db:parse-plan to render the Query Plan tab, so EXPLAIN-format parsing stays in the driver. Return [] (or omit the method) if the rows aren’t a plan.

The orchestrator never branches on driver type. If you need behaviour the existing capability flags don’t cover, add a flag to DriverFactory (in src/main/plugins/sdk/types.ts) rather than adding a special case in the main app or the renderer.

Lets the user save table or query data in a new file format.

Manifest

"contributes": {
"exporters": [{ "id": "parquet", "name": "Parquet", "extension": "parquet" }]
}

Activation

ctx.exporters.register('parquet', {
format: 'parquet',
extension: 'parquet',
displayName: 'Parquet',
appliesToTypes: ['cassandra'], // optional
execute(rows, columns, options) {
return serializeParquet(rows, columns) // string | Buffer
}
})

The appliesToTypes array restricts the exporter to connections of the listed types. Omit it for neutral formats (CSV, JSON, Parquet). It is a declarative string[] (not a predicate function) so the contribution can be marshalled across the process-isolation boundary — see plugin-security.md.

Worked examples: core-formats (CSV/JSON), postgresql/sql-format.ts, mongodb/data-format.ts.

The mirror of an exporter — bring data in from a file.

Manifest

"contributes": {
"importers": [{ "id": "parquet", "name": "Parquet", "extensions": ["parquet"] }]
}

Activation

ctx.importers.register('parquet', {
format: 'parquet',
extensions: ['parquet'],
displayName: 'Parquet',
appliesToTypes: ['cassandra'],
// When the importer drives execution itself (e.g. SQL scripts), set this:
driverExecutes: false,
parse(content, options) {
return { rows: deserializeParquet(content), columns: ['id', 'name'] }
}
})

If driverExecutes is true, the IPC layer calls parse() with options.adapter set and skips the default bulk-insert pass — useful for SQL scripts that need to be executed statement-by-statement.

Pretty-print the query buffer. Formatters are keyed by editor language (your driver’s editorLanguagesql, json, plaintext, …), so any database can format its own query language, not just SQL. The main app only resolves and invokes formatters (db:format-query glue, the editor’s “Format Document”, the format-editor app action); the logic is yours.

Manifest

"contributes": {
"formatters": [{ "id": "sql", "name": "SQL (Cassandra)" }]
}

Activation

import { formatSql } from '../../sdk/sql-format'
ctx.formatters.register('sql', {
language: 'sql', // the editor language this formats
displayName: 'SQL (Cassandra)',
appliesToTypes: ['cassandra'], // omit for a language-wide fallback
format: (sql) => formatSql(sql, 'sql'), // or your own CQL formatter
})

Resolution. For a given (editor language, connection type), a formatter whose appliesToTypes includes the connection type wins; otherwise a language-wide fallback (no appliesToTypes) is used; otherwise nothing (a clean no-op). Resolution never crosses languages, so a SQL fallback can’t touch a JSON or plaintext editor.

Shared SDK helpers (so bundled plugins don’t duplicate logic):

  • formatSql(sql, dialect)sql-formatter-backed; pass your dialect ('postgresql' | 'mysql' | 'sqlite' | 'snowflake' | 'sql'). SQL drivers register a dialect formatter in one line; core-formats registers the language-wide sql fallback.
  • formatJson(source) — pretty-prints JSON (MongoDB uses it for its json editor).

Both return the input unchanged on a parse error, so formatting never destroys the buffer. A plugin with bespoke needs (e.g. Redis tidies its plaintext command buffer) just provides its own format(source).

Translate a column type from one dialect to another during migration. Drivers register what they know about translating into / out of their own dialect — the orchestrator has no hardcoded translation table.

Activation

ctx.typeMappers.register('postgresql', 'cassandra', {
'integer': { target: 'int', lossy: false },
'text': { target: 'text', lossy: false },
'jsonb': { target: 'text', lossy: true, note: 'Stored as text' }
}, /* optional fallback */ (normalizedSource) => {
return { target: 'text', lossy: true, note: 'Unmapped' }
})

from is the source dialect name (e.g. 'postgresql'), to is the target. The table looks up the lowercased source type. The fallback runs when nothing in the table matches.

Worked examples (each ships its own type-maps.ts): postgresql, mysql, sqlite.

Wrap the connect/disconnect lifecycle for a particular configuration — e.g. open an SSH tunnel before connecting and tear it down afterwards.

Manifest

"contributes": {
"connectionMiddleware": [{ "id": "ssh-tunnel" }],
"connectionFields": [
{ "key": "sshHost", "label": "SSH Host", "type": "text", "group": "SSH Tunnel" },
{ "key": "sshUser", "label": "SSH User", "type": "text", "group": "SSH Tunnel" }
]
}

Activation

ctx.drivers.registerConnectionMiddleware('ssh-tunnel', {
shouldApply(profile) { return !!profile.sshHost },
async beforeConnect(profile) {
const localPort = await openTunnel(profile)
return { ...profile, host: '127.0.0.1', port: localPort }
},
async onDisconnect(profileId) { await closeTunnel(profileId) }
})

Worked example: ssh-tunnel.

Contribute autocomplete items (keywords, functions, table/column names) to the Monaco editor for a particular connection.

Activation

ctx.completions.register(async (connectionId, context) => {
const items: CompletionItem[] = []
for (const kw of MY_DIALECT_KEYWORDS) {
items.push({ label: kw, kind: 'keyword', sortText: '3' })
}
return items
})

Worked examples: every relational driver plugin contributes its own completion provider. See postgresql/index.ts.

Themes are raw token overrides, layered on top of the design system’s semantic tokens. The registry validates each theme at registration time against REQUIRED_THEME_TOKENS. A theme that’s missing required tokens is still loaded, but the Appearance settings picker disables its tile so the user can’t accidentally land on a half-painted UI.

Manifest

"contributes": {
"themes": [
{ "id": "solarized-dark", "name": "Solarized Dark", "type": "dark" }
]
}

type must be 'dark' or 'light' — the picker groups by it. The manifest validator rejects any other value.

Activation

import { validateTheme, REQUIRED_THEME_TOKENS } from '@verql/plugin-sdk'
const myTheme = {
id: 'solarized-dark',
name: 'Solarized Dark',
type: 'dark' as const,
css: `
[data-theme="solarized-dark"] {
--color-bg-primary: #002b36;
--color-bg-secondary: #073642;
/* …every token in REQUIRED_THEME_TOKENS… */
}
`,
preview: { bg: '#002b36', sidebar: '#073642', text: '#fdf6e3', accent: '#268bd2' },
}
// Validate in your own CI — fails fast with the exact list of missing
// tokens before the plugin even ships.
const report = validateTheme(myTheme)
if (!report.ok) throw new Error(`Missing: ${report.missingRequired.join(', ')}`)
ctx.themes.register(myTheme)
// Or strict-register so the registry throws if your tokens regress:
ctx.themes.register(myTheme, { strict: true })

Required vs recommended tokens. Missing one of REQUIRED_THEME_TOKENS makes the theme unselectable. Missing one of RECOMMENDED_THEME_TOKENS shows a warning badge but the theme stays selectable. The full lists live in src/main/plugins/sdk/theme-registry.ts.

Add a panel into the primary or secondary sidebar, the bottom dock, or a plugin-defined slot.

Manifest

"contributes": {
"panels": [
{ "id": "snowflake-context", "location": "secondary", "title": "Snowflake" }
]
}

Activation

ctx.panels.register('snowflake-context', {
// a renderer component path (plugin UI is React) or a contribution-id
// that pairs with a renderer-side mounted component
})

Or, for purely declarative UI (no React component bundle), use ctx.ui.registerToolbar / registerStatusBar / registerSlot / registerResolver to compose widgets out of primitives like buttons, selectors, and labels. See snowflake/index.ts for the role/warehouse selectors driven entirely through declarative UI.

Register a command palette entry.

Manifest

"contributes": {
"commands": [{ "id": "format-sql", "title": "Format SQL", "keybinding": "Cmd+Shift+F" }]
}

Activation

ctx.commands.register('format-sql', async () => {
/* implementation */
})

The orchestrator prepends the plugin name to the command id, so the palette ends up running verql-plugin-foo:format-sql.

Plugins can register additional AI providers (alongside OpenAI, Anthropic, Ollama) or new AI tools (alongside the built-in schema_list_tables, query_execute, …).

Activation

ctx.ai.registerProvider({
id: 'mistral',
name: 'Mistral',
supportsToolCalling: true,
async models() { … },
async *chat(request) { … }
})
ctx.ai.registerTool({
id: 'count_rows',
name: 'Count Rows',
description: 'Count rows in a table',
parameters: { type: 'object', properties: { table: { type: 'string' } } },
permission: 'read',
async execute(params, ctx) { … }
})

The MongoDB and Redis bundled plugins both register an AI context provider so Claude knows how to format queries for those connections.

A plugin can declare settings that appear in the app’s Settings panel under any category it likes.

Manifest

"contributes": {
"settings": [
{
"key": "queryTimeoutMs",
"title": "Query timeout (ms)",
"type": "number",
"default": 30000,
"category": "performance"
}
]
}

Settings are scoped per-plugin (plugins.<name>.queryTimeoutMs) and accessed via ctx.settings.get('queryTimeoutMs').

13. Desktop notifications & the attention seam

Section titled “13. Desktop notifications & the attention seam”

Two related mechanisms let a plugin reach the user outside the window — the in-app notification bus (a toast), and native OS notifications.

In-app toastctx.notifications.show({ kind, title, message }) pushes a transient toast (and records an activity-log entry). Use it for soft, in-window feedback.

Native OS notification — owned by the bundled os-notifications plugin, which publishes an os-notifications service. Any plugin can consume it:

import type { OsNotificationService } from '../os-notifications'
const notifier = ctx.services.consume<OsNotificationService>('os-notifications')
notifier?.notify({
title: 'Export finished',
body: 'orders.csv is ready',
category: 'completion', // 'approval' | 'alert' | 'completion' | 'info'
onClick: () => { /* runs in main; defaults to focusing the window */ },
})

The plugin owns policy — a master toggle, an “only when Verql isn’t focused” guard, and a per-category approval toggle (all in its settings) — so consumers just describe what to say, never whether to say it.

The attention seam. Approval prompts (an AI write tool, an MCP query authorization) are surfaced automatically: the host owns a delivery-agnostic attention service (src/main/attention/) that producers publish to — hub.request({ id, kind: 'approval', title, body }) when a prompt is raised and hub.resolve(id) when it’s answered — and os-notifications subscribes and turns those into notifications (dismissing them when resolved). Wire a new approval flow into the seam rather than calling os-notifications directly, so the what-needs-attention signal stays decoupled from how it’s surfaced and a future plugin (window flash, dock badge, phone push) can consume the same events.

For the full picture — system context, sequence flows, the dispatcher decision tree, lifecycle, and the class/data models, all as diagrams — see notifications.md.

The boot coordinator runs every plugin through a five-phase pipeline.

PhaseWhat happensFailure mode
DiscoverScan plugin directories, read manifests, register bundled plugins'error', phase discover
ValidateRun validateManifest() — name, version, main, contributes'error', phase validate
ResolveHold for dependency resolution (currently a passthrough)'error', phase resolve
ActivateCall module.activate(ctx) with a 10s timeout'error', phase activate
VerifyCompare actual registrations against manifest’s contributes'degraded' (kept running with whatever it did register)

After verification each plugin is in one of these states:

  • 'active' — everything declared in the manifest was registered
  • 'degraded' — some contributions are missing; the rest still work
  • 'error' — the phase listed in error.phase failed
  • 'inactive' — user-disabled

Activation errors are caught by safeCall() (10s timeout) so a stuck plugin cannot wedge the boot. An ErrorBudget opens / closes around each plugin and auto-disables one that throws repeatedly at runtime (default: 5 errors in a 60s window).

Bundled plugins live under src/main/plugins/bundled/. The naming convention is:

src/main/plugins/bundled/<plugin-id>/
├── index.ts # manifest export + activate()
├── *-adapter.ts # DbAdapter implementation (for driver plugins)
├── sql-format.ts # exporter/importer (for SQL drivers)
├── data-format.ts # exporter/importer (for non-SQL drivers)
└── type-maps.ts # type translation tables (for SQL drivers)

To add a new bundled plugin:

  1. Create the directory under src/main/plugins/bundled/<your-id>/.
  2. Export manifest and activate from index.ts.
  3. Add it to the bundled-plugin list in src/main/plugins/bundled/index.ts. The orchestrator iterates that list — it doesn’t know individual driver names. Order matters when one plugin consumes another’s service: register producers first (the AI plugin is registered first because mongo/redis plugins consume its ai service at activate time).
  4. Write tests under tests/unit/. The pattern is in tests/unit/bundled-plugins.test.ts (single plugin) and tests/unit/export-import-plugin-driven.test.ts (cross-plugin).

The architecture test in tests/unit/audit/main-orchestrator-purity.test.ts will refuse to merge if any file under src/main/ outside plugins/ references your driver by name. Keep dialect knowledge inside the plugin.

To add an external plugin (third-party, installed by the user at runtime), package the directory as a zip and use the Plugins panel inside Verql → Install from Zip. The same manifest.json + compiled index.js applies.

If you find yourself reaching for process.platform === 'darwin', profile.type === 'postgresql', or similar branching inside the main app or the renderer — stop. That’s a sign you should be adding a new DriverFactory flag (data-driven, like defaultSchemaCandidates) or a new registry on PluginContext. The orchestrator stays generic; the plugin owns the knowledge.

There’s a regression test (tests/unit/export-import-no-hardcoding.test.ts) that fails the build if anyone reintroduces this pattern in the core IPC handlers or renderer hot paths.