Skip to content

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.

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, the “cloud function with zero latency” experience),
  • a cron tick,
  • two instances in two different databases (same code, different config) proving the clone story.
  • 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:
Terminal window
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.

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.

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 there

Declare the collection the plugin watches and the instance in the IaC YAML (see IaC):

vski/default/collections.yaml
version: 1
database: default
collections:
notes:
type: base
fields:
- {name: text, type: text}
vski/default/plugins.yaml
version: 1
database: default
plugins:
notes_main:
plugin: note-bot
enabled: true # starts immediately
config:
shout: false
Terminal window
vski apply

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

Terminal window
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: statusMessage and the captured logs tell you why (missing Python module, bad entry point, …).

Create a note and watch the plugin react:

await client.collection("notes").create({"text": "first note"})
Terminal window
vski plugins instances logs notes_main --limit 20
# info: ... [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.

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

Terminal window
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 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.

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

vski/databases.yaml
version: 1
databases:
team-b: {description: Team B}
vski/team-b/collections.yaml
version: 1
database: team-b
collections:
notes:
type: base
fields:
- {name: text, type: text}
vski/team-b/plugins.yaml
version: 1
database: team-b
plugins:
notes_team_b:
plugin: note-bot
enabled: true
config:
shout: true
Terminal window
vski apply

apply 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:

Terminal window
vski plugins instances restart notes_team_b --db team-b
await client.db("team-b").collection("notes").create({"text": "hello from b"})
Terminal window
vski plugins instances invoke notes_team_b summary --db team-b
# { counts: { create: 1, ... }, db: "team-b" }

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: POST
Terminal window
vski apply

Now 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 value

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

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

Terminal window
vski plugins instances show notes_main # note the pid
kill -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.

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