3 Plugins
Anton Nesterov edited this page 2026-10-24 15:46:18 +02:00

Plugins are supervised Python processes that run beside the vski server, in the same container or machine. They are the "system around vski" made first-class: embeddings indexers, graph syncs, crawlers, chat bots — real, versioned Python code — with configuration, lifecycle, logs and invocation exposed through the admin API and dashboard instead of a process manager.

Plugins are experimental and disabled by default.

This is not a hosted function runtime. The server never installs or manages Python dependencies. Plugins are reusable source components (think shadcn/ui, not npm packages): you copy one into your distribution, verify its dependencies against the rest of your environment, tune the config schema for your use case, and build one image that just works.

Architecture

┌──────────────────────────── container / machine ───────────────────────────┐
│                                                                            │
│  vski server (Go)                                                          │
│   ├─ plugin catalog      ← discovers vski-plugin.json in PLUGINS_DIR       │
│   ├─ supervisor          ← spawns, restarts (backoff), health-checks       │
│   ├─ /api/plugins*       ← admin control plane (catalog, instances,        │
│   │                        lifecycle, invoke, logs)                        │
│   └─ stdio JSON-RPC      ← initialize / ping / invoke / shutdown           │
│        │                                                                   │
│        ├── python -m vski.plugin.runner   (plugin instance, warm)          │
│        │     ├─ your handlers  @on_event / @function / @cron               │
│        │     ├─ realtime WS subscribe (consumer group, at-least-once)      │
│        │     └─ vski SDK client  → http://127.0.0.1:<port> (records,       │
│        │        graphs, embeddings, SQL — the normal REST API)             │
│        └── python -m vski.plugin.runner   (another instance, same code,    │
│              different config — e.g. the same bot cloned per chat)         │
└────────────────────────────────────────────────────────────────────────────┘
  • Plugin = a directory with a vski-plugin.json manifest + Python code. Discovered from PLUGINS_DIR at boot and on POST /api/plugins/scan.
  • Plugin instance = one supervised process: plugin + database + JSON config + enabled flag. The cloneable unit — "another bot for another group" is a new instance, not a new deployment.
  • Invocation goes over a stdio pipe to a warm process — no network hop, no cold start. Event handlers subscribe over the existing realtime WebSocket using a per-instance consumer group (at-least-once delivery).

Enabling

The official image ships no Python. To run plugins, bring your own interpreter and enable the feature:

Env Default Meaning
PLUGINS_ENABLED false Master switch. When off, all /api/plugins* endpoints answer 503.
PLUGINS_DIR DATA_DIR/plugins Directory containing plugin folders.
PLUGIN_PYTHON python3 Interpreter used to spawn python -m vski.plugin.runner. It must have vski-sdk (>= 0.5.0) and your plugins' dependencies installed.

Replicas never supervise plugins (like webhooks and realtime, plugins run on the master only); instance status stays readable through the synced registry.

Building a plugin-enabled distribution

FROM vski.sh/x/vski:latest

# Your locked Python environment. Resolve dependency conflicts here, once,
# by hand — vski never touches pip.
RUN apt-get update && apt-get install -y python3 python3-pip && rm -rf /var/lib/apt/lists/*
RUN python3 -m pip install --break-system-packages \
      vski-sdk==0.5.0 httpx==<locked> <your-plugin-deps>

# Your system around vski: copy the plugin source you tuned
COPY plugins/ /app/data/plugins/

ENV PLUGINS_ENABLED=true

Everything ships as one image; configuration changes (API keys, chat ids, schedules) are made through the dashboard/API and restart only the one affected process — never the server, never the container.

The manifest (vski-plugin.json)

{
  "name": "embeddings-indexer",
  "version": "0.1.0",
  "title": "Embeddings Indexer",
  "description": "Watches records and keeps vector embeddings in sync.",
  "runtime": {
    "entry": "indexer.main:app",
    "python": ">=3.11"
  },
  "deps": ["requirements.txt"],
  "config": {
    "collection": { "type": "string", "required": true },
    "model":      { "type": "string", "required": true },
    "apiKey":     { "type": "string", "secret": true }
  },
  "subscribe": { "collections": ["documents"] },
  "functions": {
    "reindex": { "description": "Full re-embedding pass" }
  }
}
  • runtime.entry — package.module:attribute pointing at a vski.plugin.Plugin object. The server spawns the interpreter inside the plugin directory.
  • deps — metadata only. The server never installs anything; declare dependencies so you (and future tooling) know what the plugin expects.
  • config — the instance config schema; the dashboard renders it as hints. secret: true fields are API keys and similar.
  • subscribe.collections — informational declaration of watched collections; the authoritative subscriptions are the ones the code registers (see below).

Writing a plugin

# hello/main.py
from vski.plugin import Plugin

app = Plugin()

@app.on_start
async def start(ctx):
    ctx.log.info("starting, configured greeting: %s", ctx.config.get("greeting"))

@app.on_event("notes")            # realtime subscribe, per-instance consumer group
async def on_note(ctx, event):    # at-least-once (ACKed), survives restarts
    record = event.get("record") or {}
    await ctx.client.collection("notes", ctx.db).update(record["id"], {"processed": True})

@app.function("reindex")          # callable over the warm stdio pipe
async def reindex(ctx, params):
    return {"done": True}

@app.cron("*/5 * * * *")          # 5-field cron, no external deps
async def sweep(ctx):
    ...

ctx carries everything a handler needs:

  • ctx.client — an AsyncVskiClient already authenticated and scoped to the instance's database (ctx.db). Records, graphs, embeddings, SQL — the full SDK surface.
  • ctx.config — the instance's config dict.
  • ctx.log — a logger writing to stderr; the server captures it (ring buffer + persisted _plugin_logs in stats.db) and the dashboard shows it.
  • ctx.instance, ctx.plugin — names.

Logs go to stderr; stdout is reserved for the JSON-RPC protocol — never print() in a plugin, use ctx.log.

How the process is spawned

The server runs $PLUGIN_PYTHON -m vski.plugin.runner with cwd set to the plugin directory and these environment variables:

Env Meaning
VSKI_PLUGIN_URL http://127.0.0.1:<server port>
VSKI_PLUGIN_TOKEN A freshly-minted API key with role plugin, created at process start and deleted when the process exits — key material lives only in memory and the child env
VSKI_PLUGIN_DB / VSKI_PLUGIN_INSTANCE / VSKI_PLUGIN_ID Instance identity
VSKI_PLUGIN_CONFIG The instance config as JSON
VSKI_PLUGIN_ENTRY The manifest entry point

The plugin's API key is a normal authenticated principal: collection and graph access rules apply to it (default-deny). Give it access with rules the same way you would for any service (@request.auth.roles etc.).

Instances: control without restarts

All endpoints are admin-only. TS SDK (client.plugins.*, v2.2.0+), Python SDK (client.plugins.*, v0.5.0+) or plain HTTP:

Method Path Meaning
GET /api/plugins Catalog (parsed manifests)
POST /api/plugins/scan Rescan PLUGINS_DIR
GET /api/plugin-instances?db= Instances (+ live runtime info)
POST /api/plugin-instances Create {name, plugin, dbName?, config, enabled}
GET /api/plugin-instances/:name?db= One instance + runtime snapshot
PATCH /api/plugin-instances/:name?db= Patch {enabled?, config?, plugin?} — a config change bumps configVersion and restarts that one process
DELETE /api/plugin-instances/:name?db= Stop + remove
POST /api/plugin-instances/:name/start|stop|restart?db= Lifecycle
POST /api/plugin-instances/:name/invoke?db= {function, params, timeoutSeconds?} → warm call over the pipe (default 30s, max 120s)
GET /api/plugin-instances/:name/logs?db=&level=&limit= Captured log lines (newest first, 7-day retention)
// TS SDK
const instance = await client.plugins.createInstance({
  name: "indexer_prod",
  plugin: "embeddings-indexer",
  config: { collection: "documents", model: "nomic-embed-text", apiKey: "sk-..." },
  enabled: true,
});
await client.plugins.invoke("indexer_prod", "reindex", { page: 1 });

// change the embedding API key: one PATCH, one process restarts, server stays up
await client.plugins.updateInstance("indexer_prod", {
  config: { collection: "documents", model: "nomic-embed-text", apiKey: "sk-new" },
});

Instance states: stopped → starting → running, failed (with statusMessage) on spawn/handshake errors or crashes. Crashes restart with exponential backoff (1s → 60s, reset after 5 minutes of uptime); the restarts counter tracks crash restarts. A 10s ping health-checks each process; an unresponsive one is killed and restarted.

Triggers and cron: plugin:// targets

Collection triggers and HTTP cron jobs accept a local plugin target instead of a URL:

{
  "options": {
    "triggers": [
      { "action": "create", "url": "plugin://indexer_prod/on_record", "method": "POST" }
    ]
  }
}

Pipeline plugin steps call plugin functions the same way — "target": "indexer_prod/reindex" — as part of a declarative pipeline instead of a trigger.

The webhook engine recognizes the plugin://<instance>/<function> scheme and dispatches over the warm pipe — same retry policy, same _webhook_logs pipeline, zero network hop. A cron HTTP job with plugin:// in its command works the same way (and resolves the job's database for the instance).

Security model

  • Plugin code is trusted code: it ships inside your container, exactly like workflow workers. There is no sandbox; don't run plugins you haven't read.
  • Code arrives via the filesystem only (volume or image) — there is no upload endpoint.
  • The control plane is admin-only; instance API keys hold the plugin role and are subject to collection/graph access rules.
  • Config values (including secrets) are stored in the instance row in default.db. Treat the database file as sensitive, same as JWT_SECRET.

Examples

examples/plugins/ in the platform repository — copy them as starting points:

  • hello — minimal plugin exercising every handler type; also the e2e fixture.
  • embeddings-indexer — event-driven chunking + embedding via an OpenAI-compatible endpoint, reindex/stats functions (formalizes the Embeddings Tutorial "next steps").
  • graph-sync — mirrors a collection into a light graph with properties.ref links (formalizes the Graphs Tutorial sync pattern).
  • calendar-index — indexes a collection's records into a calendar timetree (day/ISO-week nodes, on_day/in_week edges); see the Calendar Timetree recipe.

Troubleshooting

Symptom Cause / fix
503 ... PLUGINS_ENABLED on every plugin endpoint Feature disabled — set the env vars from Enabling.
Status failed: failed to start python3 ... executable file not found No Python in the image / wrong PLUGIN_PYTHON.
Status failed: stderr shows ModuleNotFoundError: No module named 'vski' (or another module) The interpreter lacks vski-sdk or the plugin's dependencies — install them in your image; the server won't.
Status failed: initialize handshake failed Process starts but never answers the protocol — check captured logs for import errors in your plugin code.
plugin instance '...' is not running (start it first) on invoke Instance stopped or disabled — start it, or check whether it crash-looped.
Events not reaching an @app.on_event handler The subscription resolves against the collections registry — the collection must exist. The SDK resubscribes on reconnect; restart the instance after creating the collection.
Two instances of the same plugin in one db Names are unique per database; clone into another database with the same plugin + different config.