# Develop and Submit DBX Plugins

> Build DBX plugins with the CLI, Manifest, Workbench and Sidecar APIs, then debug, package, automatically submit, and publish them through the official Marketplace.

Source: https://dbxio.com/en/docs/plugin-development

Language: en

Relative links resolve against https://dbxio.com/en/docs/plugin-development.



DBX plugins are independently installed `.dbxp` packages that can contribute connection types, workbenches, filesystems, and native sidecars. Plugin source, the DBX plugin platform, and the official Marketplace catalog are owned by different repositories.

**Recommended path:** create a project with `dbx-plugin create`, develop and publish unsigned candidates from the plugin's own repository, then let `dbx-store` create or update the candidate PR. A maintainer reviews and signs the candidates before they enter the official catalog.

**Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), not `t8y2/dbx`.** A plugin's feature code normally remains in its author's source repository. Open a PR against [`t8y2/dbx`](https://github.com/t8y2/dbx) only when changing the DBX host, SDK, CLI, protocol, schemas, documentation, or official examples.

## Plugin Shortcuts

Manage shortcuts in **Plugin Center → Settings → Global settings**. They are enabled by default at the top right. Choose the top or bottom of either side, below the connection tree, or a floating strip before Update. The floating strip shows 3 icons by default, configurable from 0 to 10; remaining entries appear in a dropdown, one icon and name per row. Limited space moves additional entries into the dropdown.

Below the connection tree, **Plugin settings** sits at the top right of the **Plugins** header. Other positions include it as a fixed entry at the end by default. It opens the Plugin Center settings page directly and cannot be reordered. When the floating toolbar overflows, this entry is last in its dropdown. Disable **Show plugin settings shortcut** in global settings to hide it; you can enable it again from Plugin Center → Settings. When no plugin shortcuts are visible, the entire shortcut area, including the settings entry, is hidden and takes up no layout space.

Each function has its own draggable icon. Visibility switches apply to a whole plugin. All positions share the saved order. Hiding or uninstalling a plugin retains its recorded positions, so the same entries return to those positions; unrecorded entries follow saved entries. Entry identity consists of the plugin ID, entry kind, and function ID. Changing these IDs creates a new entry.

Drag previews follow the cursor and adjust their direction and position near window edges. Right-docked shortcuts prefer the left side of the cursor; long names are truncated to the available width.

Choose &#x2A;*Beside Plugin Center (dropdown)** to show all shortcuts as icon-and-name rows in the menu beside the Plugin Center button. Rows support drag sorting; the floating toolbar icon count does not apply here.

Below the connection tree, shortcuts form a single-column list with an icon on the left and a name on the right, using the tree’s row style and font size. Automatic height fits one entry per row up to five rows, with vertical scrolling for remaining entries. Drag the divider to save a custom height; double-click it to restore automatic height. Escape, window blur, or releasing outside the sorting area cancels sorting. Failed saves show an error and roll back.

Shortcuts honor declared `appToolbar` visibility and command enablement. Standalone plugins also expose existing workbenches, preferring a matching command when available. Legacy plugins without commands can expose filesystem entries. Connection plugins only expose explicitly declared global commands; the host does not invent workbench entries without connection context. Incompatible plugins are excluded.

Position affects icons only: content retains its existing `panel/tab` presentation. Hiding shortcuts, changing position, or hiding a plugin does not close running sessions. A panel shortcut toggles only the matching selected command panel and restores that same instance. No Manifest or backend protocol changes are required. Legacy `left/right` preferences migrate to the top of the corresponding side.

## Which Repository Receives the Change

| Change                                                               | Destination                                           | Official DBX PR?                                                        |
| -------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Your plugin frontend, Rust/Go backend, tests, and release scripts    | The plugin's source repository                        | Do not submit it to `dbx`; develop and release it in its own repository |
| A new Marketplace listing, version update, icon, or description      | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | Yes, target `main`                                                      |
| DBX plugin host, Manifest/Marketplace schemas, SDK, CLI, or packager | [`t8y2/dbx`](https://github.com/t8y2/dbx)             | Yes, target `main`                                                      |
| Official examples or plugin-development documentation                | [`t8y2/dbx`](https://github.com/t8y2/dbx)             | Yes                                                                     |
| A bug in a specific third-party plugin                               | That plugin's source repository                       | Do not submit it to `dbx-store`                                         |
| A custom or enterprise private repository                            | Your private repository                               | No official Marketplace submission is required                          |
| A vendor JDBC driver JAR                                             | Do not commit it to either repository                 | Users import it locally or on the server                                |

DBX-maintained plugins that need an independent release cadence should normally use separate source repositories as well. Only tightly coupled host examples, SDK verification projects, or components explicitly accepted by maintainers belong in `t8y2/dbx`.

## Plugin Project Layout

```text
my-plugin/
├── manifest.json
├── dbx-plugin.toml
├── assets/
├── ui/
├── backend/                       # absent from the frontend template
├── .github/workflows/
│   └── plugin-release.yml
└── dist/                          # generated; do not commit
```

* `manifest.json` declares identity, version, permissions, entrypoints, and contributions.
* `ui/` runs in a sandboxed workbench and talks to DBX through Host APIs.
* `backend/` is an optional native sidecar. Rust and Go SDKs are provided, while other languages may implement the DBX JSON-RPC protocol directly.
* `.dbxp` is an installable artifact, not the source repository.
* Frontend-only plugins can publish one `universal` package. Native sidecars require packages for each operating-system and CPU target.

## Complete Manifest Reference

`manifest.json` is the runtime contract and must be at the root of the plugin package. Manifest v1 rejects undeclared fields. Reference the repository's [`plugins/manifest.schema.json`](https://github.com/t8y2/dbx/blob/main/plugins/manifest.schema.json) from your editor. A minimal frontend-only Manifest looks like this:

```json
{
  "$schema": "https://raw.githubusercontent.com/t8y2/dbx/main/plugins/manifest.schema.json",
  "manifest_version": 1,
  "id": "com.example.files",
  "name": "Example Files",
  "version": "0.1.0",
  "publisher": "example",
  "description": "Browse files from an example service.",
  "icon": "assets/plugin.svg",
  "source": "https://github.com/example/dbx-plugin-files",
  "homepage": "https://github.com/example/dbx-plugin-files",
  "engines": { "host_api": "1" },
  "permissions": [],
  "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
  "contributions": [
    { "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }
  ]
}
```

Manifest paths are relative to the package root. They cannot start with `/`, contain `..`, backslashes, or duplicate slashes. `ui.entry` must be inside `ui.root`: with root `ui`, use `ui/index.html`, not `index.html`. `signingKeyId` belongs to Marketplace artifact metadata, not the Manifest.

| Field              | Required | Meaning                                                                                                                                      |
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `$schema`          | No       | JSON Schema URL for editor support; it does not establish identity.                                                                          |
| `manifest_version` | Yes      | Must currently be the number `1`.                                                                                                            |
| `id`               | Yes      | Stable global ID using lowercase letters, digits, `.`, `_`, and `-`, such as `io.github.example.files`; never change it after publishing.    |
| `name`             | Yes      | Default display name.                                                                                                                        |
| `icon`             | No       | Relative path to the default package icon; SVG or PNG is recommended.                                                                        |
| `version`          | Yes      | Semantic version `major.minor.patch`, optionally with prerelease/build suffixes. Official packages cannot overwrite the same ID and version. |
| `publisher`        | Yes      | Publisher identifier; keep it stable after the first listing.                                                                                |
| `description`      | No       | Plugin description.                                                                                                                          |
| `source`           | No       | HTTPS source repository URL, also shown from installed-plugin details.                                                                       |
| `homepage`         | No       | Project, documentation, or support URL.                                                                                                      |
| `engines`          | Yes      | Compatibility declaration; it must contain `host_api`.                                                                                       |
| `permissions`      | No       | Minimal capability set, described below.                                                                                                     |
| `entrypoints`      | No       | UI and native-sidecar entrypoints.                                                                                                           |
| `contributions`    | No       | Connection, workbench, filesystem, connection-menu, result-view, command/menu, and MCP tool-surface declarations.                            |
| `localizations`    | No       | Locale-specific names, descriptions, fields, and action labels.                                                                              |

### Engines, Permissions, and Entrypoints

```json
{
  "engines": { "dbx": ">=0.6.0", "host_api": "1" },
  "permissions": [
    "host.workbench",
    "host.events",
    "host.filesystem",
    "host.binary",
    "host.plans:read",
    "host.schema:read",
    "host.storage",
    "host.clipboard:read",
    "host.data:read",
    "host.network:https://s3.example.com:443"
  ],
  "entrypoints": {
    "ui": { "root": "ui", "entry": "ui/index.html" },
    "backend": {
      "protocol_versions": [1],
      "transport": "stdio-jsonl",
      "executable": "bin/linux-x64/backend"
    }
  }
}
```

* `engines.dbx` is an optional DBX version range; `engines.host_api` is required.
* `host.workbench` opens another workbench, `host.events` receives host events, `host.binary` enables binary channels, and `host.filesystem` enables filesystem entrypoints.
* `host.plans:read` allows reading estimated execution plans for DBX's own connections (see [Estimated Execution Plans](#estimated-execution-plans)). It is read-only: it never grants SQL execution, writes, DDL, or actual plans, and it is the only plan scope that exists.
* `host.schema:read` allows `host.getTableMetadata` to read narrow table schema metadata from an already-open connection; it grants no arbitrary SQL, writes, or implicit reconnect (see [Table Schema Metadata](#table-schema-metadata)).
* `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory.
* `host.ai` lets a plugin use the global DBX AI panel for Ask snapshot conversations, optional Agent conversations backed by live plugin tools, and workbench-specific quick-question recommendations. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional and check `capabilities.ai` / `capabilities.aiRecommendations` at runtime.
* `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
* `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
* `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
* `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
* `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.

### Connection Providers

A connection provider declares a form; DBX renders the form and owns the connection lifecycle, while the plugin handles its protocol. `binding` gives a field its meaning: `config` is persisted as external configuration, `secret` goes to the Secret Store, and `name`, `host`, `port`, `username`, `password`, and `database` map to standard connection fields.

```json
{
  "type": "connection-provider",
  "id": "com.example.files.connection",
  "label": "Example Service",
  "icon": "assets/connection.svg",
  "database_type": "example-files",
  "description": "Connect to an Example Service.",
  "fields": [
    { "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
    { "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
    { "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
    { "key": "region", "label": "Region", "type": "radio", "options": [{ "label": "US", "value": "us" }, { "label": "EU", "value": "eu" }], "binding": "config" },
    { "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
  ],
  "workbench": "com.example.files.main",
  "capabilities": ["test", "connect", "disconnect"],
  "actions": [
    { "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }
  ]
}
```

Field types are `text`, `password`, `number`, `boolean`, `select`, `radio`, and `textarea`. `select` and `radio` require non-empty `options`, each with a `label` and string `value`. `binding: "port"` requires `number`. Never put passwords, tokens, or private keys in config, workbench context, events, or logs.

Fields require `key`, `label`, and `type`; optional fields are `description`, `placeholder`, `required`, `default`, `options`, and `binding`. Defaults must match the field type. A password field without an explicit binding defaults to `secret`. `database_type` is a plugin-defined identifier, not an extension to DBX's built-in database enum.

Fields can also be conditional. `visible_when` and `required_when` take a clause `{ "field": "mode", "one_of": ["custom"] }` — matching when the sibling holds one of the listed values, compared as strings so `[false]` and `["false"]` both match a boolean `false` — and compose clauses with `all_of`, `any_of`, and `not`:

```json
{
  "key": "sudo_command",
  "label": "Sudo command",
  "type": "text",
  "visible_when": {
    "all_of": [
      { "field": "sudo_source", "one_of": ["custom"] },
      { "field": "read_only", "one_of": [false] }
    ]
  }
}
```

Conditions cascade: while the field a clause reads is hidden, the clause does not count, so a hidden option's stored default never surfaces a grandchild field. DBX evaluates the same expression for the dialog and for save/test/connect validation, an unset or empty value never matches, and composite trees are capped at 8 levels and 64 nodes.

Text, password, and textarea fields can offer a **local file action** — for example an SSH private key or a keystore — with `picker`:

```json
{
  "key": "private_key_path",
  "label": "Private key path",
  "type": "text",
  "binding": "config",
  "picker": { "kind": "file", "accept": [".pem", ".key"], "content_field": "private_key" }
}
```

The desktop app opens a native picker and stores the chosen absolute path, which the plugin backend (same machine) can read. The browser build cannot resolve a client path, so the action becomes an upload: DBX reads the file and stores its content in the declared `content_field` sibling, and clears the path field — picking a path clears the uploaded content and vice versa, so the two alternatives never disagree. `kind` also accepts `directory` (desktop only), `accept` takes up to 16 extension or MIME filters, and uploads are capped at 1 MiB.

When a plugin needs the user mid-flight — a bastion's keyboard-interactive MFA code, a host-key confirmation, a choice of account — it can call the Host API method `host/requestUserInput` (Host API 1.1) and receive the typed answer. The request goes through the same blocking dialog DBX uses for its own prompts, including during `connection/test` and `connection/connect`:

```json
{
  "jsonrpc": "2.0",
  "id": "prompt-1",
  "method": "host/requestUserInput",
  "params": { "prompt": "Verification code (6 digits)", "title": "JumpServer login", "echo": false, "timeoutSecs": 300 }
}
```

The result is `{ "action": "submit", "value": "123456" }`, `{ "action": "cancel" }`, or `{ "action": "timeout" }`; only `submit` carries a value and the plugin must fail closed on the other two. Plugin-initiated requests use string ids while DBX keeps numeric ids, and DBX pauses the request deadline of the call waiting on a prompt, so a user typing a code is not mistaken for a connect timeout. An error code of `-32001` means no user interface is attached (headless/MCP), `-32602` invalid params, `-32601` unsupported method — degrade gracefully in every case. Gate the call on `plugin/initialize` advertising `hostApiVersion` `1.1.0` (or `host.requestUserInput` in `host.features`). With the Rust SDK that is `HostClient::supports("host/requestUserInput")` plus `HostClient::request_user_input(&UserInputPrompt::secret("Verification code"))` from `dbx_plugin_sdk::host_client()`.

The fixed lifecycle methods are `connection/test`, `connection/connect`, and `connection/disconnect`; custom form actions use `connection/action`. Parameters include `provider`, `connection`, and `runtime`, plus `action: { id }` for actions. Keep sessions keyed by `connection.id` and connect to `runtime.host`/`runtime.port`, which include DBX tunnel/proxy resolution. Hydrated secrets are provided only to backend lifecycle requests; the frontend uses `connectionId` to access an established session.

Multi-endpoint protocols (Kafka `advertised.listeners`, cluster discovery) declare `proxy_route` on the contribution. With transport layers configured, DBX then delivers a SOCKS5 route in the payload instead of a static forward, keeping `runtime.host`/`runtime.port` at the logical endpoint while the plugin dials every advertised endpoint through `runtime.proxy` (`{ "type": "socks5", "host": "...", "port": 1080, "username": "...", "password": "..." }`; an SSH final hop exposes its dynamic SOCKS5 endpoint, a SOCKS5 proxy layer is used directly, and proxy credentials ride the same encrypted channel as connection secrets and must never be logged). Without the flag, transport layers keep the static-tunnel behavior, which requires a single remote endpoint from the standard `host`/`port` fields; DBX rejects plugin connections that would tunnel to an empty endpoint instead of timing out silently.

Action `when` accepts `always`, `create`, or `edit`; `variant` accepts `default`, `outline`, `secondary`, `destructive`, or `ghost`; `timeout_ms` is 1–120000. `requires_valid_form` requires a complete form and `close_on_success` closes the dialog on success. Return `{ "success": true, "message": "...", "fieldValues": { "port": 443 } }` to update declared fields; `success: false` is a failure.

### Other Contributions

| Type                  | Required fields                                                             | Purpose                                                                                                                                       |
| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `workbench`           | `type`, `id`, `label`                                                       | Registers a workbench opened from the sidebar or plugin entry.                                                                                |
| `filesystem-provider` | `type`, `id`, `label`, `schemes`                                            | Registers a virtual filesystem; `root_uri` can be `s3://bucket`, with optional `read`, `write`, `delete`, `rename`, and `mkdir` capabilities. |
| `context-menu`        | `type`, `id`, `label`, `menu: "connection"` or `"table"`, optional `action` | Adds a backend action or opens a declared Workbench from a Sidebar Tree context menu.                                                         |
| `result-view`         | `type`, `id`, `label`                                                       | Registers a query or task result view.                                                                                                        |

A `menu: "table"` contribution is currently exposed when the user opens a context menu on a concrete table node in the Sidebar Tree. A legacy contribution without `action` invokes the required backend entrypoint as `contextMenu/<contribution-id>` with the table identity:

```json
{
  "table": {
    "connectionId": "connection-id",
    "database": "example",
    "schema": "public",
    "table": "users"
  }
}
```

`database` and `schema` are optional and are omitted when unavailable. The context contains no credential, connection string, or raw connection configuration. Object Browser can reuse this identity contract in a later contribution surface, but it is not a table menu surface in this release.

A context-menu contribution can instead declare a host-handled Workbench action:

```json
{
  "type": "context-menu",
  "id": "vendor.example.generate",
  "label": "Generate test data",
  "menu": "table",
  "action": { "type": "open-workbench", "workbench": "vendor.example.main" }
}
```

`action.workbench` must reference a `workbench` contribution in the same plugin. The host opens it directly, without invoking the sidecar, and passes the current invocation context as Workbench context. For `menu: "table"`, this is the `TableContext` object above directly (not the backend `{ "table": ... }` envelope); for `menu: "connection"`, it is the existing non-secret connection summary (`id`, `dbType`, `name`, `database`). The host may also provide the standard `connectionId` for tab association. No host, port, username, password, connection string, or raw connection configuration is exposed. Reopening the Workbench refreshes its context with the latest invocation.

Only legacy context-menu contributions without `action` require a backend entrypoint; their `{ "message": "..." }` response continues to show a toast.

Reference contributions by ID: a connection provider's `workbench` must point to a declared workbench and `filesystem_provider` to a declared filesystem provider. A contribution icon takes precedence over the plugin-level icon.

Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/<contribution-id>` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context. The SDK exposes the Host-selected contribution id as `dbxPlugin.contributionId` after initialization, so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads.

### Filesystem Protocol

Implement these backend methods when using the DBX generic file manager. A custom workbench can use them through its own RPC calls as well; declaring a contribution does not implement business logic.

| Method                       | Parameters and return values                                                                                  |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `filesystem/list`            | `providerId`, optional `connectionId`, `uri`, optional `cursor`, `limit`; returns `{ entries, nextCursor? }`. |
| `filesystem/read`            | The same identity fields, `uri`, `maxBytes`; returns `{ dataBase64, contentType?, truncated, etag? }`.        |
| `filesystem/write`           | `uri`, `dataBase64`, `create`, `overwrite`, optional `etag`; requires `write`.                                |
| `filesystem/createDirectory` | `uri`; requires `mkdir`.                                                                                      |
| `filesystem/delete`          | `uri`, `recursive`; requires `delete`.                                                                        |
| `filesystem/rename`          | `sourceUri`, `targetUri`, `overwrite`; requires `rename`.                                                     |

Every method carries `providerId` and optional `connectionId`. Entries contain a single filename `name`, full `uri`, `kind` (`file`, `directory`, `symlink`, or `other`), and optional `size`, `modifiedAt`, and `contentType`. Names cannot include `/` or `\\`, or equal `.`/`..`; put full paths in `uri`. Omit `nextCursor` on the last page and never repeat a consumed cursor. Image previews need the actual MIME type and base64 bytes, not UTF-8-decoded binary content.

Mutations return `{ success, message?, entry? }`; inline read/write payloads are capped at 4 MiB. Use a chunked transfer protocol with progress/cancellation for large files, not one giant JSON/base64 value.

### Localization

Use locale keys such as `zh-CN` and `en` in `localizations`. You can override the plugin name, description, and every contribution's labels, descriptions, fields, and actions. This does not translate text inside the plugin UI.

```json
{
"localizations": {
  "zh-CN": {
    "name": "示例文件",
    "contributions": {
      "com.example.files.connection": {
        "label": "示例服务",
        "fields": { "token": { "label": "访问令牌" } }
      }
    }
  }
}
}
```

## Workbench Context Contract

Workbench context is a JSON data snapshot across the DBX/plugin boundary. The host recursively removes Vue reactivity and sends an independent copy, so plugins must not expect Vue refs, proxies, DOM nodes, functions, component instances, or credentials.

Context may contain `null`, booleans, finite numbers, strings, arrays, and plain objects. Undefined object properties are omitted and undefined array entries become `null`. Dates, Maps, Sets, symbols, bigints, non-finite numbers, circular references, and custom class instances are rejected with an error. The UTF-8 encoded context is limited to 2 MiB.

Connection provider fields using `binding: "config"` are persisted in `external_config`; fields using `binding: "secret"` are persisted in the Secret Store. Plugin connection configuration keeps `external_config` through create, edit, save, and reconnect flows.

The host also authors three reserved fields on every workbench context: `workbenchId` (a stable host-generated instance id), `restored`, and `surface`. `surface` tells the UI which container it is running in — `"tab"` (the main multi-tab workbench), `"dock"` (the bottom panel), or `"window"` (a floating desktop widget window, see [Floating Windows](#floating-windows)). The same plugin and contribution can be open on several surfaces at once, each with its own context and lifecycle; unknown values should fall back to `"tab"` behavior.

## Frontend Host API

The UI runs in an isolated iframe. DBX exposes the bridge as `window.dbxPlugin`; do not import DBX Vue/Tauri modules or assume that Node.js, local files, or arbitrary network access are available.

When multiple Workbench or result-view contributions share one UI entrypoint, use the Host-selected identity to route the UI:

```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
```

`contributionId` is independent of `context`; for example, a Workbench opened from Plugin Center can have an empty `{}` context. The SDK records the identity before resolving `ready`, making it deterministic to read afterward. The legacy `dbx-plugin-init` event still carries the full init payload as a notification, but it is one-shot and must not be the only source of authoritative state.

The following custom `objects/list` and `objects/changed` methods must be implemented by your backend. The asset example assumes the package contains `ui/assets/empty-state.svg`:

```js
await window.dbxPlugin.ready;

const contributionId = window.dbxPlugin.contributionId;
const context = window.dbxPlugin.context;
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));

await window.dbxPlugin.notify("objects/changed", { count: result.items.length });
const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
```

| API                                                                          | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ready`                                                                      | Waits for Host initialization; start application logic after `await dbxPlugin.ready`.                                                                                                                                                                                                                                                                                                                                                                                                            |
| `contributionId`                                                             | Read-only identity of the contribution opened by the Host; available after `await ready` and independent of `context`.                                                                                                                                                                                                                                                                                                                                                                           |
| `context` / `onContext(fn)`                                                  | Reads or observes the current workbench context.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `locale`                                                                     | Reads the current DBX locale; the plugin translates its own UI.                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `theme`                                                                      | Reads `appearance` and DBX design tokens for light/dark support.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `request(method, params)`                                                    | Calls a Host API method, including `host.getContext` and `ui.readAsset`.                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `invoke(method, params, { timeoutMs })`                                      | Sends an RPC request to the plugin's Sidecar.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `notify(method, params)`                                                     | Sends a notification without a business result.                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `sendBinary(channel, data)` / `onBinary(fn)`                                 | Sends or receives binary data; requires framed transport and `host.binary`.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `readAsset(path)` / `readAssetUrl(path)`                                     | Reads a packaged asset within the plugin resource root.                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `openWorkbench(id, context)`                                                 | Opens another workbench from this plugin; requires `host.workbench`.                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `closeWorkbench()`                                                           | Closes the surface hosting this UI — a workbench tab, a dock entry, or this plugin's floating window. It is the same path as the `Ctrl/Cmd+W` shortcut, so a plugin can offer its own close or minimize affordance.                                                                                                                                                                                                                                                                              |
| `executeCommand(commandId, context?)`                                        | Executes one of this plugin's own declared commands through the same path as a menu placement — re-checks `enablement`, applies singleton reuse, and follows the declared `presentation` (panel commands dock, tab commands open tabs). `context` merges over the command context and scopes `instance_key` `{{path}}` placeholders, so `instance_key: "logs:{{connectionId}}"` yields one panel instance per connection. Resolves `{ error }` for expected outcomes; requires `host.workbench`. |
| `openFilesystem(id, context)`                                                | Opens a filesystem entry; requires `host.filesystem`.                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `getPlanCapabilities(connectionId)`                                          | Reads what the host and this connection can plan; requires `host.plans:read`.                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })`   | Returns the estimated plan for `sql`; requires `host.plans:read`.                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `getTableMetadata({ connectionId, database?, schema?, table })`              | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`.                                                                                                                                                                                                                                                                                                                                                                                                 |
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | Runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`.                                                                                                                                                                                                                                                                                                                                                                                      |
| `capabilities`                                                               | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request.                                                                                                                                                                                             |
| `ai.openConversation({ title, prompt, context, send?, mode? })`              | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false` and `mode` defaults to `ask`. `mode: "agent"` uses live plugin tools when context includes an open plugin connection; requires `host.ai` and is advertised as `capabilities.ai`.                                                                                                                                                                                                                            |
| `ai.setRecommendations({ context, items })`                                  | Replaces the current workbench's default recommendations and refreshes the global AI panel; up to five items are shown and `{{path.to.value}}` placeholders are supported; requires `host.ai` and `capabilities.aiRecommendations`.                                                                                                                                                                                                                                                              |
| `ai.clearRecommendations()`                                                  | Clears recommendations for the current workbench; requires `host.ai` and `capabilities.aiRecommendations`.                                                                                                                                                                                                                                                                                                                                                                                       |
| `onInit(fn)` / `onEvent(fn)`                                                 | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`.                                                                                                                                                                                                                                                                                                                                                                               |
| `storage`                                                                    | Persistent per-plugin key-value state; requires `host.storage`; see below.                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `fileTransfer`                                                               | Streams local files, downloads, and (desktop) OS drops; see below.                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `clipboard`                                                                  | System clipboard access from the sandbox; see below.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `floating`                                                                   | Opens and drives a floating desktop widget window (desktop hosts only); see [Floating Windows](#floating-windows).                                                                                                                                                                                                                                                                                                                                                                               |

Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals.

`readAsset` paths are relative to `ui.root`: `assets/empty-state.svg` resolves to the package's `ui/assets/empty-state.svg`. Call `URL.revokeObjectURL(assetUrl)` when done, and call the unsubscribe functions returned by `onContext`/`onEvent` when a component is destroyed.

### Estimated Execution Plans

A plugin can read the **estimated** execution plan of a query without owning a driver, a connection pool, or a credential. It sends its own SQL and a connection reference; DBX builds the `EXPLAIN` statement with its own dialect rules, applies the same read-only safety gate it uses for its own plan view, and runs it on the host connection. The plugin receives only the raw plan, which it parses, normalizes, and visualizes itself.

```json
{
  "permissions": ["host.plans:read"]
}
```

```js
if (window.dbxPlugin.capabilities.planApi) {
  const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId);
  if (capabilities.supports.estimatedPlan) {
    const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 });
    render(plan.rawPlan, plan.format);
  }
}
```

`getPlanCapabilities(connectionId)` returns `{ dbType, dbVersion?, supports: { estimatedPlan }, limits: { maxTimeoutMs, maxPlanBytes } }`:

* `supports.estimatedPlan` is `false` when this connection's dialect has no estimated plan path in DBX; disable the feature instead of calling `explainPlan`.
* `limits.maxTimeoutMs` already accounts for the connection's own query timeout, and a requested `timeoutMs` is clamped to it. `limits.maxPlanBytes` caps the plan payload.
* `dbVersion` is present only when DBX already learned the product version for this connection; the host never probes the server on the plugin's behalf.
* The call only reads the stored connection config. It does not connect, but the connection must already be open: a saved connection that is currently disconnected is rejected instead of being opened for the plugin.

`explainPlan(request)` returns `{ dbType, dbVersion?, format, rawPlan, truncated, warnings }`:

* `format` is `json`, `xml` (SQL Server `ShowPlanXML`), or `text`. `rawPlan` is a parsed JSON document for `json` and the plan text otherwise.
* `warnings` carries `plan_not_json` when the server answered with something that is not JSON (the payload is then reported as `text` instead of pretending it is JSON), `plan_truncated` when the host cut the plan to respect `maxPlanBytes`, and `plan_rows_truncated` when the driver stopped collecting plan rows. `truncated` is `true` whenever the host cut the plan for either reason.

The plan API is bounded by design:

* **Estimated plans only.** `mode` must be `"estimated"`; any other value is rejected. Actual plans (`EXPLAIN ANALYZE`, `SET STATISTICS XML`) execute the statement and are not part of this API.
* **The plugin never supplies SQL to execute.** Only the source `sql` is accepted and the host builds the `EXPLAIN` statement itself, so a plugin cannot pass an `EXPLAIN` statement, a driver command, or an execution mode.
* **Read-only targets only.** The same gate DBX uses for its own plan view rejects multi-statement input, DDL, DML, and dangerous keywords. Oracle is the one dialect where DBX also plans DML, because `EXPLAIN PLAN FOR` does not execute it.
* **No credentials, no result set.** The response carries the plan and metadata only; a password, credential, connection string, or driver internals never cross this boundary.
* **The connection must already be open.** DBX does not connect on a plugin's behalf, and it does not create one for the plugin. Both `getPlanCapabilities` and `explainPlan` reject a saved-but-disconnected connection with `Connection is not open`; only a connection DBX already holds open can be planned.

Gate on capability rather than probing: read `dbxPlugin.capabilities.planApi` from the init message (an older host omits it), then confirm per-connection support with `getPlanCapabilities` before calling `explainPlan`. Submitting `EXPLAIN` text of your own is neither necessary nor accepted.

The plan API is Host API 1.2. A plugin that cannot work without it declares the floor in its manifest with `"engines": { "host_api": "^1.2" }`. The manifest range is a compatibility floor and `capabilities.planApi` is the runtime check; keep both.

### Table Schema Metadata

A plugin can read narrow schema metadata for one table on a DBX connection that is already open, without owning a driver, connection pool, credential, or SQL string. The request reuses the canonical `PluginTableContext` and contains table identity only:

```json
{
  "permissions": ["host.schema:read"]
}
```

```js
if (window.dbxPlugin.capabilities.schemaMetadataApi) {
  const metadata = await window.dbxPlugin.getTableMetadata({
    connectionId,
    database,
    schema,
    table: "users"
  });
  for (const column of metadata.columns) {
    renderColumn(column.name, column.dataType, column.nullable);
  }
}
```

`getTableMetadata({ connectionId, database?, schema?, table })` returns:

```json
{
  "columns": [
    {
      "name": "id",
      "dataType": "integer",
      "nullable": false,
      "precision": 32,
      "default": "nextval('users_id_seq'::regclass)"
    }
  ],
  "fieldCapabilities": {
    "length": "supported",
    "precision": "supported",
    "scale": "supported",
    "default": "supported"
  }
}
```

* `columns` contains only `name`, `dataType`, `nullable`, and optional `length`, `precision`, `scale`, and `default`. It never returns comments, keys/indexes, credentials, connection strings, driver objects, or arbitrary SQL results.
* Optional values remain `null` or are omitted when no value exists; they are never fabricated as `0` or an empty string. `fieldCapabilities` uses `supported`, `unsupported`, and `unknown` to distinguish provider support, an explicitly unavailable field, and missing reliable provenance; plugins must not treat `unknown` as supported.
* `database` and `schema` are optional and omitted when unavailable; do not use empty strings as public identity. `connectionId` and `table` must be non-empty identity values no longer than 256 characters.
* This API is read-only. DBX reuses only an already-open Host connection/session; a plugin cannot trigger an implicit reconnect, create a pool, or execute arbitrary SQL. A saved-but-disconnected connection is rejected with `Connection is not open`; a requested database without a matching open session is also rejected instead of being routed to another connection.

This API is part of Host API 1.3. A plugin that cannot work without it should declare `"engines": { "host_api": "^1.3" }` and still read `capabilities.schemaMetadataApi`, because older hosts omit that field. Without `host.schema:read`, the host rejects the request before it reaches the backend.

### Read-only Data Queries

A plugin can run one read-only SQL statement on a DBX connection the user granted to it, without a driver, a pool, a credential, or a connection string. Declare the permission and gate the call on the capability:

```json
{
  "engines": { "host_api": "^1.4" },
  "permissions": ["host.data:read"]
}
```

```js
if (window.dbxPlugin.capabilities.dataApi) {
  const result = await window.dbxPlugin.queryData({
    connectionId,
    database,
    sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
    maxRows: 200
  });
  renderChart(result.columns, result.rows);
}
```

`queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` returns:

```json
{
  "dbType": "postgres",
  "columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
  "rows": [["paid", 1204], ["refunded", 37]],
  "truncated": false,
  "elapsedMs": 12
}
```

* **Consent per connection.** The first query for a connection opens a host dialog that names the plugin and the connection. An allow is persisted and listed under the plugin in Plugin Center → Installed, where the user can revoke it at any time; a denial is remembered for the workbench session. A plugin cannot grant itself access.
* **Read-only, one statement.** The host accepts exactly one statement that DBX's SQL risk classifier rates read-only — the same gate as MCP read-only access and the AI agent. Writes, DDL, `SELECT … FOR UPDATE`, multiple statements, and session database switches such as `USE` are rejected. Pass `database` instead of switching.
* **Open connections only.** Like the plan and schema metadata APIs, DBX never connects on the plugin's behalf: a saved but closed connection is rejected with `Connection is not open`. Redis, MongoDB, search engines, and other non-SQL connections are not served.
* **Bounded.** `maxRows` defaults to 500 and is capped at 5000; the serialized rows are capped at 8 MiB; `timeoutMs` is clamped to the connection timeout and a 60-second ceiling. `truncated` is `true` whenever rows were cut.
* **No credentials.** The response carries columns and rows only. A revoked grant fails with an error that starts with `PLUGIN_DATA_ACCESS_NOT_GRANTED`; the next query asks the user again.

This API is Host API 1.4. Keep both the `engines.host_api` floor and the `capabilities.dataApi` runtime check; web hosts that cannot show a consent dialog deny the request instead of granting it.

### System Clipboard

The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:

* `window.dbxPlugin.copy(text)` / `window.dbxPlugin.clipboard.writeText(text)` write to the system clipboard. Both ride the same ungated bridge method: a write has the user's data as its input and is the low-risk half of the surface.
* `window.dbxPlugin.clipboard.readText()` reads the system clipboard. It requires the `host.clipboard:read` permission — a read hands user data (passwords, tokens) to plugin code with no further user interaction — and is Host API 1.3: declare `"engines": { "host_api": "^1.3" }` if your workbench cannot function without it.

Gate reads on the init capability rather than probing: `capabilities.clipboardRead` (and `capabilities.clipboardWrite` for writes) are absent on older hosts, which makes them falsy. A rejected read (missing permission, web host without native clipboard access) rejects its Promise — degrade gracefully, e.g. fall back to a keyboard-paste path instead of failing the interaction.

```js
await window.dbxPlugin.ready;
const canRead = !!window.dbxPlugin.capabilities.clipboardRead;
const canWrite = !!window.dbxPlugin.capabilities.clipboardWrite;

async function pasteFromClipboard() {
  if (!canRead) return null;
  try {
    return await window.dbxPlugin.clipboard.readText();
  } catch {
    // Permission missing or the clipboard read failed: keep the keyboard path.
    return null;
  }
}
```

### File Transfer and OS Drops

Desktop hosts stream local files into plugin sandboxes through `window.dbxPlugin.fileTransfer`. Handles are opened only after explicit user consent: a native open/save dialog (`pick` / `beginSave`) or a file dropped from the OS onto this plugin's workbench area (`onDrop`). Transfers are chunked, so multi-gigabyte files never load into memory.

```js
const fileTransfer = window.dbxPlugin.fileTransfer;
if (!fileTransfer) {
  // Web host: fall back to <input type="file"> and sidecar disk writes.
}

// Upload: the user picks files, the plugin streams chunks to its sidecar.
const { files } = await fileTransfer.pick({ multiple: true });
for (const file of files) {
  let offset = 0;
  for (;;) {
    const chunk = await fileTransfer.read(file.handleId, offset, 256 * 1024);
    await uploadChunk(file.name, offset, chunk.dataBase64); // your own RPC
    if (chunk.eof) break;
    offset += chunk.length;
  }
  await fileTransfer.cancel(file.handleId);
}

// Download: open a save target, stream chunks, then flush with finish.
const target = await fileTransfer.beginSave({ name: "export.csv", size });
await fileTransfer.write(target.handleId, 0, bytes);
await fileTransfer.finish(target.handleId);

// OS drag & drop (desktop only): dropped files arrive as opened handles.
const offDrop = fileTransfer.onDrop((files) => uploadAll(files));
const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
```

| API                                        | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pick({ multiple })`                       | Native open dialog; resolves opened read handles `{ handleId, name, size, contentType }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `read(handleId, offset, length?)`          | Streams a chunk `{ dataBase64, length, eof }` from a read handle.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `beginSave({ name, contentType?, size? })` | Native save dialog + write handle; resolves `{ handleId, chunkBytes }`, or `null` when the user cancels.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `write(handleId, offset, data)`            | Writes one chunk (transferred binary or base64); resolves `{ written, nextOffset }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `finish(handleId)` / `cancel(handleId)`    | Flush-closes a write handle / discards any handle.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `onDrop(fn)`                               | Files dropped onto this workbench arrive as read handles. A dropped folder is expanded into the regular files it contains (dotfiles and dot-directories like `.git` are skipped); each expanded file carries `relativePath` — its '/'-separated path starting with the dropped folder's own name (`folder/nested/a.csv`) — so the plugin can rebuild the dragged structure and tell same-named files under different dropped roots apart. Listeners receive `(files, { dropId, truncated })`: `dropId` groups this drop's entries (cancellation and progress bookkeeping), `truncated` is `true` when the 2000-file or 8-depth expansion cap cut the delivery short. |
| `onDragState(fn)`                          | Whether an OS drag is currently over this workbench; drive drop overlays with it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |

Desktop and web hosts differ in what the namespace can do — gate on the additive `capabilities.fileTransfer` flags (`pick`, `beginSave`, `read`, `drop`, `folderExpansion`) instead of probing calls. On desktop, `pick` / `beginSave` use native dialogs and `onDrop` receives OS drops; dropped entries are opened lazily and streamed in chunks, so very large folders and multi-gigabyte files never exhaust handles or memory, and picking more files than the shared handle registry holds downgrades them to lazily-opened entries instead of dropping any. On the web host, `pick` falls back to a browser file input, `beginSave` buffers in memory and downloads through the browser, `onDrop` / `onDragState` never fire (`drop`/`folderExpansion` are `false` there), and handles carry no `relativePath`. `onDrop` only fires for drops that land inside this plugin's workbench area; drops elsewhere keep the host's own behavior.

### Persistent UI State

The sandbox has an opaque origin, so `localStorage` throws there. Workbenches that need to survive a reload or an app restart — last visited folder, panel sizes, drafts — use `window.dbxPlugin.storage` instead. It requires the `host.storage` permission and is advertised as `capabilities.storage` in the init message; gate on both before use.

```js
if (window.dbxPlugin.capabilities.storage) {
  const storage = window.dbxPlugin.storage;
  await storage.set("layout", { sidebar: "collapsed", sort: "name" });
  const layout = await storage.get("layout"); // { sidebar: "collapsed", sort: "name" }, or null when unset
  await storage.delete("layout");
}
```

| API               | Purpose                                                          |
| ----------------- | ---------------------------------------------------------------- |
| `get(key)`        | Resolves the stored JSON value, or `null` when the key is unset. |
| `set(key, value)` | Persists any JSON value (`undefined` normalizes to `null`).      |
| `delete(key)`     | Removes one key; deleting an unset key succeeds.                 |

Keys are strings up to 256 characters; values are capped at 256 KiB serialized and the whole store at 1 MiB — this is UI state, not a data sink. Entries live in the plugin's own `plugin-data/<id>` directory, isolated per plugin, and survive upgrades and uninstall. Bulk data, caches, and anything the sidecar produces belong in the sidecar's data directory instead (see [Native Sidecars and RPC](#native-sidecars-and-rpc)). On the web host the same API is backed by the browser profile, so identical code works in both hosts.

### Analyse plugin data in DBX AI

Declare `"permissions": ["host.ai"]` in the manifest. A plugin can open the global AI panel directly, or register quick recommendations for each Workbench. Both paths use DBX's model selector, history, follow-up messages, cancellation and export.

#### Declare default Workbench recommendations

Add optional `ai.recommendations` under a `workbench` contribution. `label` is shown in the global DBX AI panel, `prompt` is sent when the user clicks it, and lower `order` values appear first.

```json
{
  "type": "workbench",
  "id": "com.example.files.main",
  "label": "Files",
  "ai": {
    "recommendations": [
      {
        "id": "health",
        "label": "Check {{resource.name}} health",
        "prompt": "Analyze the health and risks of {{resource.kind}}/{{resource.name}}",
        "order": 10
      }
    ]
  }
}
```

Placeholders are resolved from the current Workbench context. Object properties and array indexes are supported, such as `{{resource.name}}` and `{{items.0.status}}`. A recommendation with an unresolved placeholder is hidden; paths containing `__proto__`, `prototype`, or `constructor` are rejected. At most five recommendations are displayed.

#### Update recommendations when the resource changes

When the plugin switches pages or resources, it can publish runtime recommendations. Runtime `items` replace the Manifest defaults; an empty array clears the current recommendations. Runtime context usually needs to contain only resource fields. The Host preserves the Workbench's `connectionId`, database, and instance identity so a click remains bound to the correct plugin connection.

```js
function updateRecommendations(resource) {
  if (!dbxPlugin.capabilities.aiRecommendations) return;

  return dbxPlugin.ai.setRecommendations({
    context: { resource },
    items: [
      {
        id: "health",
        label: `Check ${resource.name} health`,
        prompt: "Analyze the health and risks of {{resource.kind}}/{{resource.name}}",
        order: 10,
      },
      {
        id: "events",
        label: "Review recent events",
        prompt: "List the important recent events for this resource and suggest next steps",
        order: 20,
      },
    ],
  });
}

await dbxPlugin.ai.clearRecommendations();
```

Recommendations appear in the global DBX AI conversation window, without adding a separate AI entry point inside the plugin page. Clicking one creates a new Agent conversation with the selected text and sends it immediately; the Agent uses tools exposed by the current open plugin connection to retrieve live data. The recommendation list belongs only to the current Workbench context and is not written to conversation history; the selected prompt is saved normally after it is sent.

#### Open an Ask or Agent conversation directly

`ai.openConversation` remains useful for a plugin button or another explicit action. `mode` defaults to `ask`, where the Host copies the plugin's JSON snapshot into the conversation; the snapshot is retained with the conversation and later resource refreshes do not replace it. To use live tools, pass `mode: "agent"` and include the `connectionId` of the current open plugin connection in the context:

```js
await dbxPlugin.ai.openConversation({
  title: "Watchlist analysis",
  prompt: "Compare the current quotes and explain risks and missing data.",
  context: {
    connectionId,
    resource: { kind: "watchlist", name: "primary" },
    dataTimestamp,
  },
  mode: "agent",
  send: true,
});
```

`title` allows 200 characters and `prompt` allows 32000. `context` must be a plain JSON object and is limited to 2 MiB. An Agent can use only tools exposed by the Host for that plugin connection; the plugin receives no model response or model configuration and gains no database execution capability. Older plugins without recommendations continue to use the existing AI API, and older hosts omit the new capability fields, so disable only the unavailable entry points and keep the rest of the plugin usable.

### Floating Windows

A floating window is one of your plugin's workbenches hosted in its own frameless, always-on-top desktop window instead of inside the DBX shell — the surface a small always-visible widget wants (a player capsule, a session badge, a quick picker). It stays on top of other applications and can be dragged anywhere on the desktop. Released mid-screen it stays where it landed; released on or past a work-area edge — however hard it was shoved — it snaps flush onto that edge and docks there. Docking never hides the widget the moment it lands: it stays a fully readable capsule, tucks off-screen behind a thin strip about 2.5 seconds after the cursor leaves it, and slides back out while the cursor is over that strip. Reopening a widget that was left docked behaves the same way (capsule first, then tuck); one that was simply summoned is left as a capsule even though its default placement touches an edge. Gate on `capabilities.floating`: the web host has no windows, and neither does a desktop host that cannot manage them.

```js
if (window.dbxPlugin.capabilities.floating) {
  // From a tab or dock workbench: open the widget. One window per
  // (plugin, workbench contribution), so a second call focuses the existing one.
  const { windowId } = await window.dbxPlugin.floating.open({
    contributionId: "capsule",
    title: "Now playing",
    width: 260,
    height: 60,
    context: { queueId: currentQueueId },
  });
}

// Inside the widget (context.surface === "window"): drive its own window.
grip.addEventListener("pointerdown", (event) => {
  grip.setPointerCapture(event.pointerId);
  dragging = false;
});
grip.addEventListener("pointermove", async (event) => {
  if (dragging || Math.hypot(event.clientX - downX, event.clientY - downY) < 4) return;
  dragging = true;
  // The host takes over: it follows the native cursor and moves the window.
  await window.dbxPlugin.floating.beginDrag();
});
grip.addEventListener("pointerup", async () => {
  if (!dragging) return;
  dragging = false;
  await window.dbxPlugin.floating.endDrag(); // snaps + remembers the position
});
```

| API                               | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `floating.open(options)`          | Opens (or focuses) the floating window for one of this plugin's `workbench` contributions. `options`: `contributionId` (required), `title`, `width`/`height` in logical pixels, `x`/`y` in physical screen pixels, `alwaysOnTop` (default `true`), `skipTaskbar` (default `true`), `resizable` (default `false`), `context`. Resolves `{ windowId, reused }`; requires `host.workbench`.                                                                                                                                                       |
| `floating.close(contributionId?)` | Closes this plugin's floating window — one contribution, or every floating window of this plugin when the id is omitted. Inside a widget, calling it without an argument closes the widget itself.                                                                                                                                                                                                                                                                                                                                             |
| `floating.beginDrag()`            | Hands the window to the host's drag loop. Call it from `pointermove` once your drag threshold trips, while the button is still down. The host reads the native cursor itself from here on, so there is no per-move call to make.                                                                                                                                                                                                                                                                                                               |
| `floating.endDrag({ snap? })`     | Ends the drag. With `snap` (default `true`) the host pulls the window flush onto a work-area edge it reached or passed — docking it there, capsule first and tucked behind a thin strip once the cursor has been away — or clamps it fully on screen otherwise, and remembers the position (and the dock) for the next open. Resolves the final rect in physical pixels. Optional in practice: the host also ends a drag on its own once the cursor goes quiet, so a widget that never sees the pointerup cannot leave the window glued to it. |
| `floating.setSize(width, height)` | Resizes the hosting window in logical pixels; the host clamps to its own bounds.                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

The widget decides *when* a drag starts (which element is a grip, how far the pointer must travel first); the host moves the window and owns the snap and the remembered position. There is deliberately no per-move bridge call: the host polls the native cursor, which keeps the widget under the pointer even when the webview is busy, and ends the drag on a quiet cursor if the pointerup never arrives. Geometry calls (`beginDrag`/`endDrag`/`setSize`) only work inside a floating window and reject elsewhere, so a tab can never move a window it is not in.

A few rules make the surface predictable:

* **One window per contribution.** The window id is derived from the plugin and contribution ids, so `floating.open` is idempotent: the second call focuses the window that is already open instead of stacking widgets.
* **The window is opaque.** The host paints no chrome of its own, so the widget is what the user sees: give your page a background plus rounded corners and it reads as a floating card once the host sizes the window to the content. Per-pixel transparency (a shaped widget with see-through corners) is deliberately not offered — whether a transparent region still receives mouse input differs per platform, and a widget the user is meant to drag has to stay clickable everywhere.
* **Shell navigation is forwarded.** A floating window has no tab strip, dock, or panels, so `openWorkbench` and `openFilesystem` from it open in the main DBX window, which raises itself. `executeCommand` is not offered there at all: every command action opens a shell surface, so it would resolve successfully and show nothing.
* **Position survives restarts** per plugin and contribution, and is discarded when it no longer lands on a connected monitor (an unplugged display falls back to the work-area default rather than opening off-screen).
* **The widget is a full workbench.** It gets the same bridge, permissions, context contract, and sidecar RPC as a tab; `context.surface` is `"window"` and `context.workbenchId` is the window id. It boots its own copy of your UI, so shared state has to travel through your sidecar (or `storage`), not through module memory.

### Themes, Layout, and Static Assets

DBX injects theme tokens on the document root and updates `data-dbx-theme`. Custom libraries, including Svelte and shadcn-svelte, should consume these variables; parent-page CSS does not cross the iframe:

```css
body {
  margin: 0;
  background: var(--color-background, #fff);
  color: var(--color-foreground, #18181b);
}
button {
  background: var(--color-primary, #2563eb);
  color: var(--color-primary-foreground, #fff);
  cursor: pointer;
}
```

Listen with `window.addEventListener("dbx-plugin-env", handler)` for locale/theme changes. Initialize from `dbxPlugin.locale`/`theme`, then refresh your state on events. DBX includes lightweight `dbx-btn` and `dbx-input` styles, but the development host does not emulate the complete component kit; custom token-based styles work more consistently in both.

#### What the theme payload carries

The env message's `theme` is `{ appearance, tokens, editor? }`. `appearance` is `"light" | "dark"`; `tokens` are the resolved root design variables swept by prefix (`--color-*`, `--radius-*`, `--font-*`); `editor` is a structured snapshot of the SQL editor settings that have no CSS-token carrier. Delivered settings:

| DBX setting                                           | Delivered as                                                             |
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
| Light/dark/system mode                                | `theme.appearance`                                                       |
| Palette + custom color sets                           | `--color-*` tokens                                                       |
| Corner style                                          | `--radius-*` tokens                                                      |
| UI font family                                        | `--font-sans` token                                                      |
| Editor (mono) font family                             | `--font-mono` token + `theme.editor.fontFamily`                          |
| Editor font size                                      | `theme.editor.fontSize`                                                  |
| SQL editor syntax theme                               | `theme.editor.theme`                                                     |
| UI scale (host window zoom)                           | not delivered — plugins own their own zoom                               |
| Data-grid font / type colors, editor background image | not delivered — DB-domain (`--dbx-*` tokens are excluded from the sweep) |

Treat `terminal.fontSize`-style concerns as plugin-owned: the host never dictates your font size; consume `theme.editor.fontSize` as a default only if it suits your UI. All payload changes push live on the env channel — font-family edits via the theme revision, size/theme via the explicit editor watcher — so no refresh is needed.

Build the UI as packaged static assets, not references to a Vite server or CDN scripts. The host converts the entry and assets into sandbox-loadable content. For CSP errors, verify build outputs and confinement under `ui.root`, and reproduce with the matching CLI version. Use plugin-owned dialogs for delete/rename rather than relying on native `alert`/`confirm`/`prompt` inside the sandbox.

#### Lazy loading and code splitting

The host inlines the entry script into the sandbox document; further `ui/` assets stay addressable at runtime through the `dbx-plugin` scheme — `dbx-plugin://localhost/<plugin-id>/<path-under-ui.root>` (WebView2 maps it to `http://dbx-plugin.localhost/<plugin-id>/…`). The host injects the platform-correct form as the sandbox document's `<base>` and allows it in the resource CSP, so dynamic `import()` of code-split chunks works in packaged plugins — provided the build emits **relative** asset URLs: set `base: "./"` (Vite) or `publicPath: "./"` (webpack) so chunk and CSS asset URLs resolve against the injected base instead of the host origin. Absolute `/assets/…` references cannot carry the plugin id and will 404. The scheme serves files only, confined to each plugin's `ui.root`, and responses are never cached across plugin updates.

#### Inlined stylesheets

The host reads every `<link rel="stylesheet">` that points inside `ui.root` and replaces it with a `<style>` element carrying the same attributes, so a plugin can still address its sheets at runtime (`document.querySelectorAll("style[data-skin-css]")`) and toggle them. One difference matters: `disabled` does not reflect from the content attribute on a `<style>` element the way it does on a `<link>`, and the state cannot survive as an IDL property through the host's re-serialization either. A plugin that ships a disabled stylesheet must re-apply `sheet.disabled = true` itself after load; every other attribute (ids, `data-*` markers, `media`) arrives unchanged.

#### Fullscreen

`requestFullscreen()` inside the sandbox depends on the frame's Permissions-Policy, which the host delegates (`allow="clipboard-write; fullscreen"`). A plugin cannot grant it to itself, so feature-detect `document.fullscreenEnabled` and fall back to an in-page maximized layout when it is `false` — an older host that does not delegate would otherwise reject every fullscreen request.

## Native Sidecars and RPC

Use a native backend only when the browser sandbox cannot provide the capability, such as S3/SSH protocols, system credentials, long-lived connections, or CPU-heavy work. A Sidecar is not an OS sandbox and runs with the current user's privileges.

The Rust and Go SDKs implement Sidecar Protocol v1:

```json
{"jsonrpc":"2.0","id":1,"method":"plugin/initialize","params":{"host":{"protocolVersions":[1]}}}
{"jsonrpc":"2.0","id":2,"method":"objects/list","params":{"prefix":"docs/"}}
```

Responses must reuse the request `id`; success uses `result` and failures use a JSON-RPC `error`. Initialization includes `protocolVersion`, `capabilities`, and `plugin: { id, version }`. ID/version must match the Manifest and the protocol must be supported by both sides, otherwise `Sidecar identity or protocol does not match manifest` is reported. Backend capabilities are not the Manifest's Host permission list.

```rust
use dbx_plugin_sdk::{PluginEmitter, PluginError, PluginHandler, PluginMetadata, PluginServer, RequestContext};
use serde_json::{json, Value};

struct Plugin;

impl PluginHandler for Plugin {
    fn handle(&self, _context: RequestContext, method: &str, _params: Value, _emitter: &PluginEmitter) -> Result<Value, PluginError> {
        match method {
            "example/ping" => Ok(json!({ "ok": true })),
            _ => Err(PluginError::method_not_found(method)),
        }
    }
}

fn main() -> std::io::Result<()> {
    let metadata = PluginMetadata::new("com.example.files", env!("CARGO_PKG_VERSION"));
    PluginServer::new(metadata, Plugin).serve()
}
```

Sidecar rules: reserve stdout for protocol messages and write logs to stderr; correlate concurrent requests by ID; make connect/disconnect idempotent; design timeouts, cancellation, and chunk acknowledgements for long tasks; never expose secrets through events, context, or error messages.

Every sidecar starts with `DBX_PLUGIN_DATA_DIR` set to a persistent directory reserved for this plugin (`plugin-data/<id>`, a sibling of the installed package). It survives version upgrades and uninstall, is shared by all of the plugin's sidecar runs, and is the right home for caches, tokens, and databases the backend manages — create it before the first write; the host does not pre-create it. The Go SDK exposes it as `dbxpluginsdk.DataDir()` / `EnsureDataDir()`. Workbench UIs reach a small JSON-backed store inside the same directory through `host.storage` instead of touching these files (see [Persistent UI State](#persistent-ui-state)).

The example can replace the Rust template's `backend/src/main.rs`; keep its Cargo package version equal to the Manifest. Go templates use `dbxpluginsdk.NewServer(metadata, handler).Serve()`. The current Go SDK supports JSONL; the Rust SDK also supports framed transport. Changing a Go Manifest's transport alone does not implement binary framing. See the [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) and [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk) for complete interfaces.

## Tools for the DBX AI Assistant

A plugin with a native backend can expose tools that the built-in DBX AI assistant calls in Agent mode, so a question like "why is the orders API slow?" can combine database tools with your plugin's metrics, logs, or shell access. The sidecar implements two methods:

* `mcp/tools` returns `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }` in MCP tool format. DBX passes `{ "connectionId" }` for the connection it is binding, so a plugin may hide write tools on read-only connections.
* `mcp/call` receives `{ "tool", "arguments", "lifecycle" }` and returns an MCP `CallToolResult` (`{ "content": [{ "type": "text", "text": "..." }], "isError": false }`). `lifecycle` is the same payload as `connection/connect` for the open connection — credentials resolved by the host and the runtime endpoint after SSH or proxy layers — so tools never take secrets as arguments.

What the host adds on top:

* **Opt-in, revocable.** A plugin contributes AI tools only after the user enabled it in Plugin Center (Installed → Built-in AI tools); installing alone never exposes anything. The same panel lists the tools and marks which ones need approval, and switching a plugin off there turns it off for good. A manifest `mcp` contribution with `ai_tools: false` opts a plugin out of this surface even when opted in.
* **Open connections only.** Tools are offered for the plugin connections the user currently has open. DBX binds the connection itself: it strips `connectionId` / `connectionName` from the schema the model sees and injects the bound `connectionId` into the forwarded arguments when your schema declares it. With several open connections the model picks one through an added `dbx_connection` argument.
* **Approval by default.** Only tools whose entry sets `"annotations": { "readOnlyHint": true }` run without asking. Every other call pauses the run and shows the exact arguments to the user, who allows it once or denies it; an unanswered request is denied after five minutes. Mark genuinely read-only tools, and keep your own guards (read-only connection flags, two-phase confirmations) — the approval covers model mistakes, not your plugin's own safety rules.
* **Portable schemas.** Tool names are exposed as `<prefix>__<tool>` (for example `ssh__ssh_exec`), and schemas are reduced to the subset every supported model provider accepts: `type`, `description`, `properties`, `required`, `items`, string `enum`, and numeric/length/item bounds. Keep argument names to letters, digits, and underscores.
* **Bounded results.** Calls time out after 120 seconds and results are compacted before they reach the model. Start long jobs as background tasks and return a handle instead of blocking.

The built-in agent treats tool output as untrusted data. Return facts, not instructions, and never echo secrets in results.

## Create a Plugin

Install the precompiled CLI without Rust or a DBX source checkout:

```bash
npm install --global @dbx-app/plugin-cli
dbx-plugin --help
```

Or run it directly through `npx`:

```bash
npx @dbx-app/plugin-cli create my-ui-plugin --template frontend
```

The npm package selects the precompiled binary for the current platform and bundles the matching Rust and Go plugin SDK sources. Frontend-only plugins do not need Rust or Go; native toolchains are required only to compile the plugin's own sidecar.

Create a project:

```bash
# Frontend only and cross-platform
dbx-plugin create my-ui-plugin --template frontend

# Svelte + Vite and cross-platform
dbx-plugin create my-svelte-plugin --template svelte

# Rust sidecar plus frontend
dbx-plugin create my-rust-plugin --template rust

# Go sidecar plus frontend
dbx-plugin create my-go-plugin --template go
```

Choose `frontend` for plain HTML or a self-managed Vue/React UI, `svelte` for the Svelte 5 + Vite starter, or `rust`/`go` when a native Sidecar is needed. `--backend none|svelte|rust|go` is a template alias and `--language rust|go` remains a native-project compatibility alias. Use `--yes` for scripts and CI:

```bash
dbx-plugin create my-plugin \
  --template svelte \
  --id com.example.my-plugin \
  --name "My Plugin" \
  --publisher example \
  --description "A DBX plugin." \
  --version 0.1.0 \
  --yes
```

The Svelte template writes Vite output to `ui/` and emits `DBX_UI_BUILD_SUCCESS` after a successful build:

```bash
cd my-svelte-plugin
npm install
npm run build
dbx-plugin dev --port 5190
```

Develop, test, and version the generated project in the plugin's own source repository. Do not copy an ordinary plugin source tree into `t8y2/dbx` merely to publish it.

## Build Review Candidates

From the plugin project root, run:

```bash
dbx-plugin package .
```

The command produces **unsigned review candidates** only:

```text
dist/<plugin-id>-<version>-<target>.dbxp
dist/<plugin-id>-<version>-<target>.artifact.json
```

Frontend-only projects default to the `universal` target; Rust/Go projects use the current platform target. Use `--output-dir` to change the output location and `--artifact-url` to record the eventual HTTPS URL:

```bash
dbx-plugin package . --target universal --output-dir dist --artifact-url https://downloads.example.com/my-plugin.dbxp
```

The CLI packages only directories declared by `[package].include` in `dbx-plugin.toml` and rejects symlinks, traversal paths, `.dbx-dev`, oversized files, and unsafe output locations. Do not use `dist/` or development data as a nested package input.

Official plugin authors do not create or possess the DBX Store private key. The generated GitHub Release workflow builds candidates for each target and merges their metadata into `release-candidates.json`.

To install an unsigned package during local development, explicitly enable **Allow unsigned development packages** in Plugin Center. This option never relaxes official Marketplace verification.

## Local Development and Verification

Run the standalone browser development host from the plugin root when you need live UI and Sidecar diagnostics; it does not start the DBX desktop application:

```bash
dbx-plugin dev --path . --port 5190
```

Open the `http://127.0.0.1:5190/` URL printed by the command. Use `--port 0`, or let a busy port fall back to an available one. Move development data outside the project with `--data-dir`:

```bash
dbx-plugin dev --path . --data-dir /tmp/my-plugin-dev
```

`dbx-plugin dev` does not install project dependencies. Configure frontend builds in `dbx-plugin.toml`:

```toml
[dev]
ui_build = ["npm", "run", "build"]
ui_watch = ["npm", "run", "build:watch"]
```

When `ui_watch` is configured, it must print a standalone `DBX_UI_BUILD_SUCCESS` line only after a complete successful build has written every output. Do not print it on failure or from an unconditional exit hook.

The debug page can switch locale, theme, auto-reload, and diagnostic views. It keeps only the latest 500 in-memory entries, redacts passwords, tokens, and Manifest secret fields, and omits or truncates binary and oversized values. `.dbx-dev/` may contain plaintext credentials: keep it in `.gitignore` and never share it.

Read redacted diagnostics from a script:

```bash
curl -sS 'http://127.0.0.1:5190/api/diagnostics?after=0&limit=100&level=error'
```

Pass `nextAfter` and `instanceId` from the response to the next request. The development host implements a supported Host API subset; validate installation, signing, the real Secret Store, desktop lifecycle, and production permissions in DBX itself.

### Local Acceptance Checklist

* Remove unused permissions and verify that declared permissions match actual calls.
* Test empty configuration, invalid credentials, timeouts, offline behavior, and Sidecar restarts.
* Test DBX light/dark themes and `zh-CN`/`en`; the host does not translate plugin UI text.
* Test reconnect, closing a workbench, opening the same workbench twice, and empty context.
* Check packaged paths, executable permissions, and that no development data or secrets enter the artifact.

## Complete Official Marketplace Flow

### Step 1: Publish Source and a Candidate Release

Create a version tag and GitHub Release in the plugin's source repository. The Release should contain:

* one unsigned `.dbxp` candidate for each supported target;
* the corresponding `.artifact.json` files;
* merged `release-candidates.json`;
* release notes and the matching source tag.

Candidate packages may live in GitHub Releases, a CDN, or object storage. Do not commit `.dbxp` binaries to Git history.

### Step 2: Let dbx-store Create the Candidate PR

The `dbx-store` synchronizer periodically checks public plugin repositories registered in `automation/plugin-sources.json` with `autoUpdate: true`. It reads the newest Release's `release-candidates.json` and creates or updates exactly one:

```text
candidates/<plugin-id>.json
```

For an auto-updated plugin, authors **do not need to create a manual Issue or PR for every version**, and do not configure Marketplace signing secrets in the plugin repository. The source Release, candidate metadata, and an accessible source tag are the important inputs. For a first listing or an intentional Marketplace metadata change, follow the [`dbx-store` contribution guide](https://github.com/t8y2/dbx-store/blob/main/CONTRIBUTING.md).

`.dbx-store.json` is optional Marketplace metadata. Use it for first registration or deliberate changes to the store name, tags, license, homepage, or similar listing fields. A version-only release does not need a new `.dbx-store.json`; it is not the runtime Manifest and must never contain a private signing key.

Example:

```json
{
  "name": "Example Files",
  "description": "Browse files from an example service.",
  "icon": "assets/plugin.svg",
  "tags": ["files", "storage"],
  "permissions": ["host.workbench"],
  "source": "https://github.com/example/dbx-plugin-files",
  "homepage": "https://github.com/example/dbx-plugin-files",
  "license": "Apache-2.0",
  "releaseNotes": "Initial release."
}
```

Allowed store fields are `name`, `description`, `icon`, `tags`, `permissions`, `source`, `homepage`, `license`, `releaseNotes`, and `localizations`. A package-relative `icon` is converted to an HTTPS URL under the source tag during synchronization. `autoUpdate` belongs to the repository registration in `dbx-store/automation/plugin-sources.json`, not this file.

Automation only creates or updates a candidate PR; it **never approves, signs, or merges** it. The PR targets [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) on `main`, not `t8y2/dbx`.

If the plugin is not registered for automatic synchronization, fork `dbx-store` and open **one** PR against `main` containing `publishers/<publisher-id>.json` for a first publisher submission and `candidates/<plugin-id>.json`. There is no separate submission Issue. Before signing, CI intentionally stays red with `open candidate(s) awaiting DBX Store signing`; the signing workflow writes the finalized catalog back to the PR and makes it green. Authors can submit a PR to register their public repository for synchronization or ask a maintainer to register it; no App private key belongs in the plugin repository.

The candidate's essential shape is below. It references unsigned assets from the plugin repository and must not contain an official `signingKeyId`:

```json
{
  "schemaVersion": 1,
  "id": "com.example.files",
  "publisher": "example",
  "version": "0.1.0",
  "name": "Example Files",
  "source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
  "license": "Apache-2.0",
  "targets": [{
    "target": "universal",
    "url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
    "sha256": "<64 hexadecimal SHA-256 characters>",
    "size": 123456
  }]
}
```

For a new version, submit only the new version's candidate data. Never hand-edit generated `plugins/<plugin-id>.json` or `catalog/index.json`.

### Step 3: DBX Store Reviews and Signs

Maintainers review the source, Manifest, permissions, candidate SHA-256 values, sizes, and native behavior. After approval, the protected DBX Store workflow:

1. downloads the candidate using its reviewed SHA-256 and size;
2. confirms that it is unsigned and matches the expected plugin ID and version;
3. adds an Ed25519 signature using the DBX Store repository key;
4. publishes the final `.dbxp`, final artifact metadata, and signing receipt.

Plugin authors never receive the official repository private key.

### Step 4: Merge the Candidate PR

After signing, the workflow writes finalized `plugins/<plugin-id>.json` and generated `catalog/index.json` back to the same candidate PR. It publishes the signed package, artifact metadata, and signing receipt to a `dbx-store` Release. Once checks pass, a maintainer reviews and merges the PR.

Do not include the following in a submission or update PR:

* `.dbxp` binaries;
* a copied plugin source tree;
* Ed25519 private keys, tokens, or download credentials;
* unsigned candidate URLs as final install artifacts;
* a self-assigned `verified: true` value.

## Update an Existing Plugin

Every update uses a new semantic version. Existing Release assets cannot be replaced:

1. Change the source and increment `manifest.json`'s `version`; never reuse a published version.
2. Publish a new source tag and candidate Release; existing Release assets are immutable.
3. Wait for the synchronizer to create or update `automation/plugin-release/<plugin-id>/<version>`.
4. A maintainer reviews, signs, and merges the PR; the catalog then contains the new version and target artifacts.

If the repository is not registered for automatic synchronization, submit `candidates/<plugin-id>.json` manually according to the `dbx-store` contribution guide. For an update, submit only the new version's candidate data; do not hand-edit generated `plugins/` or `catalog/` files.

Fix plugin-code problems in the plugin source repository. Only catalog metadata, review status, final URLs, hashes, sizes, and Marketplace copy belong in `dbx-store`.

## Signatures and Publisher Identity

DBX currently uses one repository signature:

* `publisher` records authorship and Marketplace ownership;
* `signingKeyId` identifies the repository key that published the final installable package;
* DBX Store reviews and signs official plugins centrally;
* custom and private repository operators manage their own repository keys;
* developer signatures and dual signatures are not current listing requirements.

Human review determines whether a plugin may appear in the Marketplace. Ed25519 repository signing ensures that the reviewed package was not replaced afterward. A signature does not replace source review and is not an operating-system sandbox for native sidecars.

Official Marketplace plugin authors must not run `keygen` or invent a `signingKeyId`. Private or custom repository operators can use the advanced CLI signing tool:

```bash
dbx-plugin keygen company.plugins.release
source .dbx-repository-signing-key.env
```

The command creates a protected 32-byte Ed25519 seed environment file (mode `0600` on Unix) and prints the Key ID and Base64 public key; never commit the private file. A custom repository must add the public key to its own DBX trust configuration. The official Marketplace private key remains in a protected `dbx-store` Environment Secret, while DBX ships the public key in its official trust list.

`artifact.json` fields `target`, `url`, `sha256`, and `size` bind the exact bytes. Official signing adds `signingKeyId` only afterward. Published assets are immutable; any byte change requires a new plugin version and another review.

## Related Documentation

* [JDBC Plugin and Database Drivers](/en/docs/plugins)
* [Contribute to the DBX Core Repository](/en/docs/contributing)
* [`t8y2/dbx` plugin platform source](https://github.com/t8y2/dbx/tree/main/plugins)
* [`t8y2/dbx-store` official Marketplace repository](https://github.com/t8y2/dbx-store)

