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.jsonmanifest + Python code. Discovered fromPLUGINS_DIRat boot and onPOST /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:attributepointing at avski.plugin.Pluginobject. 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: truefields 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— anAsyncVskiClientalready 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_logsin 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
pluginrole 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 asJWT_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/statsfunctions (formalizes the Embeddings Tutorial "next steps"). - graph-sync — mirrors a collection into a light graph with
properties.reflinks (formalizes the Graphs Tutorial sync pattern). - calendar-index — indexes a collection's records into a calendar
timetree (day/ISO-week nodes,
on_day/in_weekedges); 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. |
- Getting Started
- JS/TS SDK
- Authentication
- Databases
- Collections
- Graphs
- Rules and Access Control
- Granular Access Control
- Triggers
- Plugins
- Realtime
- Full Text Search
- Vector Embeddings
- Workflows
- Pipelines
- Cron
- Webhooks
- Migrations
- Replication
- File Sync
- Backup
- Distributed Realtime
- Tutorial: Embeddings
- Tutorial: Graphs
- Recipe: Calendar Timetree
- Tutorial: Plugins