Plugins Tutorial
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
Section titled “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, 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
Section titled “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
Section titled “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.pyvski-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_startasync 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
Section titled “2. Discover and instantiate”The server scans PLUGINS_DIR at boot; after adding a directory, rescan:
scan = await client.plugins.scan() # {"plugins": 1, "errors": []}catalog = await client.plugins.list_plugins() # note-bot is thereDeclare the collection the plugin watches and the instance in the IaC YAML (see IaC):
version: 1database: default
collections: notes: type: base fields: - {name: text, type: text}version: 1database: default
plugins: notes_main: plugin: note-bot enabled: true # starts immediately config: shout: falsevski applyPoll until it is running (usually under a second):
vski plugins instances show notes_main# status: running, pid > 0# 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
Section titled “3. Events”Create a note and watch the plugin react:
await client.collection("notes").create({"text": "first note"})vski plugins instances logs notes_main --limit 20# info: ... [create] first noteDelivery 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
Section titled “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:
vski plugins instances invoke notes_main summary# { counts: { create: 1, update: 0, delete: 0 }, db: "default" }The same call from code:
out = await client.plugins.invoke("notes_main", "summary")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
Section titled “5. Change config without restarting the server”Flip shout on:
updated = await client.plugins.update_instance( "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 NOTEOnly 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
Section titled “6. Clone for another database”Same plugin, second database, different config: no code, no image:
version: 1
databases: team-b: {description: Team B}version: 1database: team-b
collections: notes: type: base fields: - {name: text, type: text}version: 1database: team-b
plugins: notes_team_b: plugin: note-bot enabled: true config: shout: truevski applyapply creates databases and collections before plugin instances, so the
subscription registers after the collection exists. If you ever create the
collection after the instance, restart it and the subscription re-resolves:
vski plugins instances restart notes_team_b --db team-bawait client.db("team-b").collection("notes").create({"text": "hello from b"})vski plugins instances invoke notes_team_b summary --db team-b# { counts: { create: 1, ... }, db: "team-b" }7. Wire a trigger to a plugin function
Section titled “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. Wire it in the collection’s
options block and apply:
collections: notes: options: triggers: - action: all url: "plugin://notes_main/reset" method: POSTvski applyNow every note event also calls reset (silly, but it demonstrates the
mechanic). Check the delivery in the webhook logs:
logs = await client.webhook_logs.list(collection="notes")# → row with url "plugin://notes_main/reset", status "success",# response_body = the function's return valueRemove the trigger when done (options: {triggers: []} and apply).
8. Crash behavior
Section titled “8. Crash behavior”Kill the process the hard way and watch the supervisor recover it:
vski plugins instances show notes_main # note the pidkill -9 <pid>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
Section titled “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.