Skip to content

Plugins

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.

┌──────────────────────────── 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).

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

EnvDefaultMeaning
PLUGINS_ENABLEDfalseMaster switch. When off, all /api/plugins* endpoints answer 503.
PLUGINS_DIRDATA_DIR/pluginsDirectory containing plugin folders.
PLUGIN_PYTHONpython3Interpreter 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.

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.

{
"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).
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 and must never be print() in a plugin, use ctx.log.

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

EnvMeaning
VSKI_PLUGIN_URLhttp://127.0.0.1:<server port>
VSKI_PLUGIN_TOKENA 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_IDInstance identity
VSKI_PLUGIN_CONFIGThe instance config as JSON
VSKI_PLUGIN_ENTRYThe 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.).

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

MethodPathMeaning
GET/api/pluginsCatalog (parsed manifests)
POST/api/plugins/scanRescan PLUGINS_DIR
GET/api/plugin-instances?db=Instances (+ live runtime info)
POST/api/plugin-instancesCreate {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)
vski/default/plugins.yaml
version: 1
database: default
plugins:
indexer_prod:
plugin: embeddings-indexer
enabled: true
config:
collection: documents
model: nomic-embed-text
apiKey: sk-... # rotate here and vski apply: one process restarts, server stays up
# Python SDK
out = await client.plugins.invoke("indexer_prod", "reindex", {"page": 1})
# or patch at runtime: one PATCH, one process restarts, server stays up
await client.plugins.update_instance(
"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.

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).

  • 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/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.
SymptomCause / fix
503 ... PLUGINS_ENABLED on every plugin endpointFeature disabled; set the env vars from Enabling.
Status failed: failed to start python3 ... executable file not foundNo 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 failedProcess 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 invokeInstance stopped or disabled; start it, or check whether it crash-looped.
Events not reaching an @app.on_event handlerThe subscription resolves against the collections registry, so the collection must exist. The SDK resubscribes on reconnect; restart the instance after creating the collection.
Two instances of the same plugin in one dbNames are unique per database; clone into another database with the same plugin + different config.