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, 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:
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
vski apply
Poll 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
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 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:
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
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.
6. Clone for another database
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
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:
vski plugins instances restart notes_team_b --db team-b
await 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
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
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).
8. Crash behavior
Kill the process the hard way and watch the supervisor recover it:
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.
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
- Command Line
- Infra as Code
- Project Boilerplate
- SDKs
- 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