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
notescollection and counts them, - a
summaryfunction 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.0installed (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 —statusMessageand 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-syncfrom the platform repo to mirror a collection into a light graph — it is the same skeleton asnote-botwith real write-back. - Copy
examples/plugins/embeddings-indexerto 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.
- 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