1 Plugins Tutorial
Anton Nesterov edited this page 2026-10-22 02:22:27 +02:00

This tutorial builds a real plugin end to end: a note watcher that reacts to new records, exposes functions you can call from the dashboard, and runs on a schedule — then clones it for a second database without touching the server. Everything shown here is verified against a live server.

Read the Plugins reference first for the architecture; this page is the hands-on path.

What you'll build

A note-bot plugin with:

  • an event handler that logs every create/update/delete on a notes collection and counts them,
  • a summary function returning the counters (callable over the warm pipe — this is the "cloud function with zero latency" experience),
  • a cron tick,
  • two instances in two different databases — same code, different config — proving the clone story.

0. Prerequisites

  • vski server built from an image with Python and vski-sdk>=0.5.0 installed (the official image has no Python — the Plugins page has the Dockerfile snippet).
  • Server running with:
PLUGINS_ENABLED=true \
PLUGINS_DIR=/app/data/plugins \
PLUGIN_PYTHON=python3 \
DATA_DIR=/app/data \
  ./vski
  • Admin access to the dashboard or an SDK client.

1. The plugin directory

A plugin is a plain Python package plus a manifest. Create $PLUGINS_DIR/note-bot/:

note-bot/
├── vski-plugin.json
└── notebot/
    ├── __init__.py
    └── main.py

vski-plugin.json:

{
  "name": "note-bot",
  "version": "0.1.0",
  "title": "Note Bot",
  "description": "Counts note activity per database.",
  "runtime": { "entry": "notebot.main:app", "python": ">=3.11" },
  "deps": [],
  "config": {
    "shout": {
      "type": "boolean",
      "required": false,
      "default": false,
      "description": "UPPERCASE the log lines"
    }
  },
  "functions": {
    "summary": { "description": "Returns event counters" },
    "reset": { "description": "Zeroes the counters" }
  }
}

notebot/main.py:

from vski.plugin import Plugin

app = Plugin()
counts = {"create": 0, "update": 0, "delete": 0}


@app.on_start
async def start(ctx):
    ctx.log.info("note-bot online in db '%s'", ctx.db)


@app.on_event("notes")
async def on_note(ctx, event):
    action = event.get("action", "?")
    counts[action] = counts.get(action, 0) + 1
    text = (event.get("record") or {}).get("text", "")
    if ctx.config.get("shout"):
        text = text.upper()
    ctx.log.info("[%s] %s", action, text)


@app.function("summary")
async def summary(ctx, params):
    return {"counts": counts, "db": ctx.db}


@app.function("reset")
async def reset(ctx, params):
    counts.update({"create": 0, "update": 0, "delete": 0})
    return {"counts": counts}


@app.cron("*/5 * * * *")
async def heartbeat(ctx):
    ctx.log.info("alive: %s events so far", sum(counts.values()))

Notice what the plugin does not contain: no reconnect logic, no websocket code, no API client setup, no daemonization. The runner does all of it.

2. Discover and instantiate

The server scans PLUGINS_DIR at boot; after adding a directory, rescan:

const scan = await client.plugins.scan();          // { plugins: 1, errors: [] }
const catalog = await client.plugins.list();       // note-bot is there

Create the collection the plugin watches, then an instance:

await client.settings.collections.create({
  name: "notes",
  fields: [{ name: "text", type: "text" }],
});

const instance = await client.plugins.createInstance({
  name: "notes_main",
  plugin: "note-bot",
  dbName: "default",
  config: { shout: false },
  enabled: true,          // starts immediately
});

Poll until it is running (usually under a second):

const status = await client.plugins.getInstance("notes_main");
// status.status === "running", status.pid > 0
// status.runtime => { running: true, uptimeSeconds: 3, ... }

In the dashboard the same flow is Settings → Plugins: catalog cards, "New Instance", the table shows the live status chip.

If status is failed, open the instance row — statusMessage and the captured logs tell you why (missing Python module, bad entry point, ...).

3. Events

Create a note and watch the plugin react:

await client.collection("notes").create({ text: "first note" });

const logs = await client.plugins.logs("notes_main", { limit: 20 });
// → [{ level: "info", message: "... [create] first note", ... }]

Delivery is at-least-once through a per-instance realtime consumer group: if the plugin is down when the record is created, the event is redelivered when it comes back.

4. Warm invocation

Call summary — the request travels a stdio pipe to the already-running process, so there is no network hop and no cold start:

const out = await client.plugins.invoke("notes_main", "summary");
// { counts: { create: 1, update: 0, delete: 0 }, db: "default" }

You can also invoke from the dashboard's instance drawer (function picker + params box + result view) — handy as a poor man's console.

5. Change config without restarting the server

Flip shout on:

const updated = await client.plugins.updateInstance("notes_main", {
  config: { shout: true },
});
// updated.configVersion === 2
// the process restarts; status goes running again with a NEW pid

await client.collection("notes").create({ text: "second note" });
// logs now show: [create] SECOND NOTE

Only that one process restarted — the server, other plugins and your API traffic never noticed. This is the whole point of instances: config is data, not deployment.

6. Clone for another database

Same plugin, second database, different config — no code, no image:

await client.settings.databases.create({ name: "team-b" });
await client.plugins.createInstance({
  name: "notes_team_b",
  plugin: "note-bot",
  dbName: "team-b",
  config: { shout: true },
  enabled: true,
});

await client.db("team-b").settings.collections.create({
  name: "notes",
  fields: [{ name: "text", type: "text" }],
});
// restart so the subscription registers after the collection exists
await client.plugins.restartInstance("notes_team_b", "team-b");

await client.db("team-b").collection("notes").create({ text: "hello from b" });
await client.plugins.invoke("notes_team_b", "summary", undefined, { db: "team-b" });
// { counts: { create: 1, ... }, db: "team-b" }

7. Wire a trigger to a plugin function

Triggers can target a plugin function instead of an HTTP URL — same retry pipeline, but dispatched over the local pipe:

await client.settings.collections.update("notes", {
  options: {
    triggers: [
      { action: "all", url: "plugin://notes_main/reset", method: "POST" },
    ],
  },
});

Now every note event also calls reset (silly, but it demonstrates the mechanic). Check the delivery in the webhook logs:

await client.webhookLogs.list({ collection: "notes" });
// → row with url "plugin://notes_main/reset", status "success",
//   response_body = the function's return value

Remove the trigger when done (options: { triggers: [] }).

8. Crash behavior

Kill the process the hard way and watch the supervisor recover it:

kill -9 <pid from getInstance>

Within a few seconds the instance is running again with a new pid and a bumped restarts counter, and invocation works immediately. Crashes repeat with exponential backoff (1s doubling to 60s) so a broken plugin cannot spin-loop the machine.

Where to go next

  • Copy examples/plugins/graph-sync from the platform repo to mirror a collection into a light graph — it is the same skeleton as note-bot with real write-back.
  • Copy examples/plugins/embeddings-indexer to automate the indexing step of the Embeddings Tutorial.
  • Remember the model: plugin = reusable source you own. Tune the config schema, pin your dependencies in your distribution image, and keep the whole system in one repo that builds to one container.