Plugins¶
Plugins are the in-process counterpart to webhooks: instead of pushing events to an external service, a plugin runs inside kasas, in a sandboxed language VM, and reacts to the same committed events. They let developers extend kasas — tax, budgeting, forecasting, notifications — without bloating the core ledger. Three runtimes ship today, all pure Go (no cgo, so the single-static-binary, multi-arch build is preserved):
- Lua (gopher-lua)
- JavaScript / TypeScript (goja, with esbuild stripping TypeScript types and downleveling modern syntax at load)
- WASM (wazero) — the home for
plugins written in Go (via the plugin SDK), or any other
language that compiles to
wasip1
Source: internal/plugins.
Requires events.enabled and plugins.enabled.
Safe by construction¶
Plugins run asynchronously, after commit (like webhooks, not like the synchronous rules engine): a slow or crashing plugin can never block or corrupt a sync. Each plugin runs on its own goroutine with a per-hook timeout, a non-reentrant VM touched by only that one goroutine, and a panic that becomes a recorded error rather than a crash.
Installing a plugin¶
Drop a directory into the plugins folder (plugins.dir, default /data/plugins):
/data/plugins/
budgeting/
plugin.toml # manifest (required)
main.lua # entrypoint (manifest `entrypoint`, default main.lua)
The manifest declares the lifecycle hooks the plugin implements and the capabilities it needs:
name = "budgeting" # must match the directory name
version = "0.1.0"
description = "Auto-categorize spending"
runtime = "lua"
entrypoint = "main.lua"
# Event hooks fire on matching events: OnTransactionCreate (transaction.created),
# OnTransactionUpdate (transaction.updated), OnTransactionDelete (transaction.deleted),
# OnSyncComplete (sync.completed). The OnUninstall lifecycle hook (no event) runs once
# at uninstall so the plugin can clean up (see "Uninstalling" below);
# OnPageRender/OnPageAction back an optional dashboard page (see "Dashboard pages"
# below); OnFetch is a scheduled source producer (see "Providing a source" below).
hooks = ["OnTransactionCreate", "OnTransactionUpdate"]
# Capabilities the host grants and enforces: transactions:read, labels:write,
# extensions:write, ui:page, net:fetch, source:provide.
capabilities = ["transactions:read", "labels:write"]
[config] # configurable keys + their DEFAULTS, exposed as kasas.config
keyword = "coffee"
A plugin implements its declared hooks as global functions and acts through the
capability-checked kasas host API:
function OnTransactionCreate(txn)
if string.find(string.lower(txn.description), kasas.config.keyword, 1, true) then
kasas.apply_labels(txn.id, { category = "food" }) -- routes through the normal
kasas.log("info", "tagged", { id = txn.id }) -- emitter: emits label.applied
end
end
function OnTransactionUpdate(txn) OnTransactionCreate(txn) end
OnTransactionDelete(txn) fires when a transaction is deleted (a manual delete, or
each row removed by an account cascade). The txn argument is the
row's last-known snapshot — id, labels, extensions, and all — because the row is
already gone by the time the hook runs. That snapshot is the source of truth inside
the handler: kasas.get_transaction(txn.id) returns nothing and host writes against
the id fail, so react from the data you are handed (e.g. tidy up an external mirror,
or log the removal). The same hook is exposed in every runtime
(kasas.OnTransactionDelete(fn) in Go, OnTransactionDelete(txn) in JS/TS).
How a hook runs¶
sequenceDiagram
autonumber
participant Bus as Event bus
participant M as Manager
participant Q as Per-plugin queue
participant VM as Sandboxed VM (Lua)
participant Host as Capability host
participant E as Emitter
Bus->>M: event committed
M->>M: find plugins whose hooks match the type
M->>Q: enqueue HookEvent (non-blocking — drop if full)
Q->>VM: Invoke(hook, event) — per-hook timeout
VM->>Host: kasas.apply_labels(id, {...})
Host->>Host: capability check (labels:write)
Host->>E: Record → write + emit label.applied + history
VM-->>M: ok / error (panic → recorded error)
M->>DB: record last_status / last_error
Because a plugin's writes go through the same emitter as a REST or rules edit,
they produce the normal label.applied / extension.set
events and history — and flow onward
to webhooks and other consumers. A plugin is a first-class participant in the
event stream, not a side channel.
The host API¶
The kasas table is the only way a plugin touches your data, and every method
that needs a capability checks it up front in a single host facade — so every
runtime (Lua today, JS/WASM later) inherits the same enforcement:
| Function | Capability |
|---|---|
kasas.get_transaction(id) |
transactions:read |
kasas.search(query, limit?) |
transactions:read |
kasas.apply_labels(id, {k=v,…}) |
labels:write |
kasas.remove_labels(id, {k,…}) |
labels:write |
kasas.set_extension(id, key, value) |
extensions:write |
kasas.remove_extension(id, key) |
extensions:write |
kasas.fetch({url, method, headers, body, timeout_ms}) |
net:fetch (host-mediated, allowlisted — see Network access) |
kasas.log(level, msg, {k=v,…}) |
— (always allowed) |
kasas.config |
— (the effective config: manifest defaults + user overrides) |
kasas.set_config({k=v,…}) |
— (always allowed; a plugin only configures itself) |
A call to a host function the plugin wasn't granted returns an error. The table shows
the Lua names; the JavaScript/TypeScript runtime exposes the same methods in
idiomatic camelCase (kasas.getTransaction, kasas.applyLabels, …) — see
JavaScript & TypeScript below — and the Go SDK in
exported PascalCase (kasas.GetTransaction, kasas.ApplyLabels, …) — see
Go (WASM).
Network access (net:fetch)¶
By default a plugin has no network access — that is what makes it safe to
install. The net:fetch capability (ADR 0002)
unlocks the whole class of integration plugins (importers, enrichers, notifiers,
mirrors) in-process, but it does so the same way every other capability works:
the plugin never opens a socket — it calls a host method, and the host performs
the request under rules the host owns. Network egress is declared, narrow,
enforced, and observable, never blanket and never raw.
Declare the allowlist in the manifest¶
net:fetch comes as a unit with a [net].allow block: the exact hosts the plugin
may reach. Declaring the capability without a non-empty allowlist is a manifest
error, so the install/enable prompt can make a specific claim ("this plugin talks
to: paperless.lan") instead of a generic "uses the network" warning.
capabilities = ["transactions:read", "extensions:write", "net:fetch"]
[net]
# Egress is default-deny. A plugin may only reach hosts it declares here (bare
# hostnames — no scheme, port, or path), and the gate/dashboard surface this exact
# list at review and enable time. A request to any other host is refused.
allow = ["paperless.lan", "api.merchant.example.com"]
Call kasas.fetch¶
One host method, mirrored across runtimes like the rest of the host API. It returns
the response { status, headers, body, truncated }; truncated is true when the
body hit the size cap.
-- Lua
local r = kasas.fetch{ url = "https://api.merchant.example.com/receipts/" .. id,
method = "GET", headers = { Authorization = "Bearer …" },
timeout_ms = 5000 }
if r.status == 200 then kasas.set_extension(id, "merchant.receipt", r.body) end
// JS/TS
const r = kasas.fetch({ url: `https://api.merchant.example.com/receipts/${id}`,
method: "GET", headers: { Authorization: "Bearer …" },
timeoutMs: 5000 });
What the host enforces¶
Every call goes through the capability-checked host facade, then a per-plugin egress gate:
- Allowlist. The URL host must be on the manifest's
[net].allowlist, or the request is refused (not silently dropped). Redirects are re-checked — a permitted host cannot302a plugin onto an undeclared one — and the redirect count is bounded. - The SSRF rule, tuned for self-hosting. A blanket "block all private IPs"
guard would break the primary use case (a NAS or
*.lanAPI on192.168.x.x). Instead: a host that resolves to a public address is allowed; a host that resolves to a private/loopback/link-local/metadata address (RFC 1918,127.0.0.0/8,::1,169.254.169.254, CGNAT100.64/10, …) is refused unless the operator granted that specific host for that specific plugin at enable time. The address is resolved and pinned at request time and the resolved IP is re-validated, so DNS rebinding cannot swap a permitted IP for an internal one. - Caps. A per-request timeout (a call may ask for less, never more than the
host's
plugins.net.timeout), a response-size cap, a per-plugin request rate limit, and a max redirect count — all host-owned, none plugin-raisable. - Logging. Every attempt (allowed or refused) is recorded — plugin, method, host, status, bytes, duration — to the structured log, a Prometheus counter, and an operator-visible egress log on the Plugins page.
Enabling collects the private-host grants¶
Like every plugin, a net:fetch plugin is
installed disabled and enabled only by an admin.
Enabling additionally surfaces its declared egress list; for any host on your
private network/LAN you tick "allow private/LAN access", and that per-plugin,
per-host grant is recorded next to the plugin's config (the net_grants column)
and shown on the Plugins page. Public hosts on the allowlist need no grant. The
same grants can be passed through the API/MCP:
# Enable with a private-host grant (admin/dashboard token).
curl -X POST -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" \
-H 'Content-Type: application/json' -d '{"net_grants":["paperless.lan"]}' \
http://localhost:8080/api/v1/plugins/2/enable
# Read a plugin's recent egress log.
curl -s -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" \
http://localhost:8080/api/v1/plugins/2/egress
The MCP surface mirrors this: enable_plugin takes an optional net_grants, and
plugin_egress_log returns the egress trail.
Configuring the egress caps¶
The ceilings are host config (and runtime-editable settings), so an operator bounds every plugin's egress regardless of what a manifest asks for:
[plugins.net]
timeout = "10s" # cap on one request (a plugin may ask for less)
max_response_bytes = 5242880 # 5 MiB body read cap
rate_per_minute = 60 # per-plugin request budget
max_redirects = 5 # each hop re-checked against the allowlist
This keeps the single static binary and the language-agnostic runtime seam: it is one new host method, not a new runtime or a raw socket exposed to guest code.
Providing a source (source:provide)¶
Every capability so far lets a plugin read or annotate a transaction that already
exists. The source:provide capability (ADR 0005)
lets a plugin originate transactions — ingest a bank, card, or API kasas
doesn't ship — but only as a producer: its OnFetch hook returns a batch and
the ingestion engine persists it through the exact
same path as SimpleFIN, with the same dedup, events,
rules auto-labeling, history, and
provenance. There is no host method that writes a
row — creation is only "return a batch the engine persists", so a buggy or hostile
producer is contained: the worst it can do is return a batch the engine rejects.
It is the most powerful capability kasas exposes (it writes to the ledger's core, not just its annotations), so it is the top trust tier — never auto-listed in the marketplace, sideload-or-manual-review only, and WASM is the recommended runtime for its memory-isolation. Enabling stays admin-only, like every plugin.
Declare the source in the manifest¶
source:provide comes as a unit with a [source] block and the OnFetch hook
(each requires the others). A remote-pulling producer also declares
net:fetch:
name = "acme-card"
runtime = "lua"
hooks = ["OnFetch"] # the scheduled producer
capabilities = ["net:fetch", "source:provide"]
[net]
allow = ["api.acme.example"]
[source]
type = "acme-card" # the human label shown on the Sources page
archetype = "pull" # scheduled; the engine calls OnFetch on the sync schedule
Implement OnFetch¶
The engine calls OnFetch on the sync schedule (and on demand), passing
the since/cursor it threads to every source, and the hook returns an
ImportBatch — the same neutral shape built-in sources build:
-- the engine persists what this returns; it never writes a row itself
function OnFetch(req) -- req.since (unix), req.cursor
local r = kasas.fetch{ url = "https://api.acme.example/txns?since=" .. req.since }
-- ... parse r.body ...
return {
source = "acme-card", -- a human label; the host stamps the real one
accounts = {
{ external_id = "acme-1", name = "ACME Card", currency = "USD",
transactions = {
{ external_id = "9f2a", amount = "-12.50", date = 1718000000,
description = "Blue Bottle", payee = "Blue Bottle Coffee" },
} },
},
}
end
The host owns the two things a plugin cannot be trusted with: it namespaces every
id the plugin returns (so a plugin row can never collide with another source's),
and it stamps the provenance as plugin:<name> — the source field above is a
human label only. A plugin therefore cannot impersonate simplefin or forge another
source's rows, and its rows are read-only to the manual-edit API (a 409, like a
synced row): the plugin owns them through re-emission and dedup.
The plugin source then behaves like any built-in one: it appears on the Sources page, syncs with the rest, and reports status. Uninstalling the plugin purges the rows it produced (a plugin owns its rows, so removing the plugin removes them — and since they are read-only, uninstall is their only removal path).
Configuring a plugin¶
The manifest's [config] block is the schema of what an end user may
configure: it declares every configurable key together with its default. The
user can then override those defaults through either of two equivalent
surfaces, and both end up in the same place:
-
A config TOML file, edited by hand. Each plugin owns one override file that lives next to its directory (so a marketplace update — an atomic directory swap — never wipes it):
/data/plugins/ coffee-budget/ # the plugin's files (replaced on update) coffee-budget.config.toml # the user's overrides (survives updates)# coffee-budget.config.toml — keys must exist in the manifest's [config]. keyword = "espresso" category = "coffee"The file is read when the plugin loads; after editing it, hit Reload on the Plugins page (or
POST /api/v1/plugins/{id}/reload) to apply. -
The plugin's own dashboard page, if the developer builds one. A page can include a
formblock whose submitted values arrive inOnPageAction, where the plugin persists them withkasas.set_config:
kasas.set_config (JS: kasas.setConfig) validates every key against the
[config] defaults — an unknown key is an error, and each value is coerced to
its default's type (so the string "25" from a form lands as a number when the
default is numeric). On success it overwrites <name>.config.toml, updates
the live kasas.config value in place, and returns the new effective config.
The file is therefore always the single source of truth: what the dashboard
saved is exactly what the user sees (and may re-edit) in the TOML.
Precedence, lowest to highest: manifest [config] defaults →
<name>.config.toml overrides. A broken override file (bad TOML, an unknown
key, a value of the wrong type) fails the plugin's load with a clear error on
the Plugins page rather than silently running on defaults. Uninstalling a
plugin deletes its override file along with its directory.
JavaScript & TypeScript¶
Set runtime = "js" in the manifest. The entrypoint defaults to main.js; for
TypeScript, point it at a .ts file — esbuild strips the types (and downlevels
modern syntax) at load, so no build step or node_modules is required:
name = "budgeting"
runtime = "js"
entrypoint = "main.ts" # or main.js (the default)
hooks = ["OnTransactionCreate", "OnTransactionUpdate"]
capabilities = ["labels:write", "extensions:write"]
[config]
keyword = "coffee"
Hooks are plain top-level functions (same names as everywhere else); the host API
is the kasas global, in camelCase. Data passed to a hook uses kasas's canonical
snake_case field names (account_id, …) — the same shape you would get from the REST
API — and date is a JavaScript Date.
function classify(txn: KasasTransaction): void {
if (txn.description.toLowerCase().includes(kasas.config.keyword)) {
kasas.applyLabels(txn.id, { category: "food" }); // routes through the normal
kasas.setExtension(txn.id, "budgeting.flagged", true); // emitter, like a REST edit
}
}
function OnTransactionCreate(txn: KasasTransaction) { classify(txn); }
function OnTransactionUpdate(txn: KasasTransaction) { classify(txn); }
For editor autocomplete and type-checking, drop this kasas.d.ts next to your
plugin. It is dev-time only — esbuild discards all types at load:
// kasas.d.ts — ambient types for kasas JavaScript/TypeScript plugins.
/** A transaction, in kasas's canonical snake_case shape. */
interface KasasTransaction {
id: string;
account_id: string;
/** Decimal string (never a float) so cents are never lost, e.g. "-4.50". */
amount: string;
pending: boolean;
date: Date;
description: string;
payee: string;
memo: string;
labels: Record<string, string>;
extensions: Record<string, unknown>;
}
/** The summary passed to OnSyncComplete. */
interface KasasSyncSummary {
accounts: number;
new_transactions: number;
updated_transactions: number;
auto_labeled: number;
duration: string;
}
/** The capability-checked host API. A method throws if the capability isn't granted. */
interface Kasas {
getTransaction(id: string): KasasTransaction | null; // transactions:read
search(query: string, limit?: number): KasasTransaction[]; // transactions:read
applyLabels(id: string, labels: Record<string, string>): void; // labels:write (merge)
removeLabels(id: string, keys: string[]): void; // labels:write
setExtension(id: string, key: string, value: unknown): void; // extensions:write
removeExtension(id: string, key: string): void; // extensions:write
/** Host-mediated HTTP request, allowlisted to the manifest's [net].allow hosts. */
fetch(req: KasasFetchRequest): KasasFetchResponse; // net:fetch
log(level: "debug" | "info" | "warn" | "error", message: string,
fields?: Record<string, unknown>): void; // always allowed
/** Effective config: manifest [config] defaults + the user's overrides. */
config: Record<string, any>;
/** Persist config overrides (validated against the [config] defaults),
overwrite <name>.config.toml, refresh kasas.config, and return it. */
setConfig(changes: Record<string, unknown>): Record<string, any>; // always allowed
}
/** An outbound HTTP request for kasas.fetch. timeoutMs may only shorten the host's
configured per-request timeout, never exceed it. */
interface KasasFetchRequest {
url: string;
method?: string; // default "GET"
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
}
/** The host's reply. body is the response body up to the size cap; truncated is
true when it was cut at the cap. */
interface KasasFetchResponse {
status: number;
headers: Record<string, string>;
body: string;
truncated: boolean;
}
declare const kasas: Kasas;
Annotate your hook parameters with KasasTransaction / KasasSyncSummary (don't
re-declare the hook functions — you define them). console.log/info/warn/error/debug
also work and route to kasas's structured logging.
Bundled dependencies¶
A hand-written plugin is still a single file. But "single file" no longer means
"no dependencies": per ADR 0001
a JS/TS plugin may depend on third-party libraries, provided they are bundled at
submission time into the single entrypoint. The author develops against npm
packages and ships a pre-bundled main.js; the host still sees, hashes, and runs
exactly one file. esbuild — already the host's loader — is the bundler, so this
needs no new capability and no change to the build: a bundled lodash can sort an
array but still cannot open a socket, because the sandbox is unchanged.
The host does not bundle at load (it only strips types and downlevels syntax, as before); the bundle is produced ahead of time. The community marketplace gate verifies a bundled submission by reproducing it from a linked source repository and comparing hashes — so review shifts from "read the artifact" to "the artifact is provably this source." WASM plugins already bundle by construction (a compiled module statically links its dependencies); Lua remains single-file.
Go (WASM)¶
Set runtime = "wasm" in the manifest. The entrypoint defaults to main.wasm — a
compiled WebAssembly module, executed by wazero
(pure Go, like the other runtimes). For Go authors there is a first-party plugin
SDK; a plugin is an ordinary Go program:
name = "budgeting"
runtime = "wasm"
hooks = ["OnTransactionCreate", "OnTransactionUpdate"]
capabilities = ["transactions:read", "labels:write", "extensions:write"]
[config]
keyword = "coffee"
package main
import (
"strings"
kasas "github.com/paulmeier/kasas/pluginsdk/kasas"
)
func init() {
kasas.OnTransactionCreate(classify)
kasas.OnTransactionUpdate(classify)
}
func classify(t *kasas.Transaction) error {
if strings.Contains(strings.ToLower(t.Description), kasas.ConfigString("keyword")) {
if err := kasas.ApplyLabels(t.ID, map[string]string{"category": "food"}); err != nil {
return err
}
return kasas.SetExtension(t.ID, "budgeting.flagged", true) // routes through the
} // normal emitter
return nil
}
func main() {} // required by the build mode, never runs
Build it with the standard Go toolchain (Go 1.24+), no extra tools:
The module is a WASI reactor: kasas runs your init functions once at load
(that is where hooks are registered — main never runs), then invokes exported
hooks per event. The SDK mirrors the Lua/JS host API one-to-one —
GetTransaction, Search, ApplyLabels, RemoveLabels, SetExtension,
RemoveExtension, Fetch, Log, Config/ConfigString/SetConfig — with the
same capability gates, and ships typed page builders
(kasas.Page, kasas.Stat(...), kasas.Form(...), …) for dashboard pages.
fmt.Println output lands in the plugin log at info level (stderr at error
level).
Failure semantics match the other runtimes, with one twist worth knowing:
- A handler panic is recovered by the SDK inside the module and surfaces as a recorded hook error — the instance keeps running.
- A timeout (
plugins.hook_timeout) or anos.Exitkills the module instance; kasas transparently re-instantiates it from the compiled module on the next invocation. In-memory guest state is reset (treat globals as a cache, not a store — durable state belongs in labels, extensions, or config).
Other languages & the ABI¶
The WASM runtime is not Go-specific — the ABI (v1) is four host functions and a
handshake, all JSON over linear memory, implementable from Rust, Zig, TinyGo, or
anything else that targets wasip1:
Import (kasas module) |
Meaning |
|---|---|
input(ptr, cap) -> n |
copy the invocation payload (length arrived as the hook argument) |
output(ptr, len) |
set the result envelope: {"ok":true}, {"ok":true,"page":{…}}, or {"ok":false,"error":…} |
host_call(ptr, len) -> n |
run a host op {"op":"apply_labels", …}; returns response length |
read_response(ptr, cap) -> n |
copy (and consume) that response: {"ok":true,"data":…} or {"ok":false,"error":…} |
The guest exports _initialize (the reactor initializer), kasas_describe() —
which must output {"ok":true,"abi":1,"hooks":["OnTransactionCreate",…]} so
kasas can verify at load that every declared hook is really implemented — and one
export per hook, named exactly like the hook, with signature
(payload_len: u32). Host ops mirror the host API: get_transaction, search,
apply_labels, remove_labels, set_extension, remove_extension, fetch,
log, get_config, set_config.
Dashboard pages¶
A plugin can extend the dashboard with its own sidebar entry and page, without
shipping any frontend code. The page is declarative: the plugin's OnPageRender
hook returns a JSON-shaped page document (a list of typed blocks), kasas
validates and normalizes it server-side, and the dashboard renders the blocks with
its own components. Plugin output is always treated as text — a plugin cannot
inject markup, scripts, or styles.
Declare the page in the manifest. The [ui] block, the OnPageRender hook, and
the ui:page capability come as a unit (the manifest is rejected if any of the
three is missing):
hooks = ["OnTransactionCreate", "OnPageRender", "OnPageAction"]
capabilities = ["transactions:read", "ui:page"]
[ui]
title = "Coffee Budget" # sidebar label + default page heading (max 40 chars)
icon = "chart" # curated icon name; bell, calendar, chart, coin, flag,
# gauge, heart, list, puzzle (default), star
The page appears in the sidebar at /ext/<plugin-name> while the plugin is
enabled and loaded (disable the plugin — or revoke ui:page — and the entry
disappears). The hook receives a request (req.plugin, req.action,
req.params) and returns the page:
function OnPageRender(req)
local matches = kasas.search(kasas.config.keyword, 100)
local rows = {}
for _, t in ipairs(matches) do
rows[#rows + 1] = { t.description, t.amount }
end
return {
title = "Coffee Budget",
blocks = {
{ type = "stat", label = "Matches", value = #matches, hint = "tagged so far" },
{ type = "table", columns = { "Description", "Amount" }, rows = rows },
{ type = "actions", actions = { { id = "rescan", label = "Re-scan", style = "primary" } } },
},
}
end
function OnPageAction(req) -- req.action == "rescan", req.params == {…}
-- do the work (with the plugin's granted capabilities), then re-render:
return OnPageRender(req)
end
Block types: heading and text (a text string), stat (label, value,
optional hint), keyvalue (items of {key, value}), table (columns plus
rows of cell arrays), actions (buttons of {id, label, style?, params?} that
POST back to OnPageAction), form (see below), and divider. Scalar values may
be strings, numbers, or booleans — they are normalized to strings. An empty Lua table is
accepted wherever a list is expected (a table with zero rows renders as an
empty table), since Lua cannot distinguish an empty array from an empty object.
Documents are bounds-checked (≤256 KiB, ≤200 blocks, ≤1000 table rows, …) and an
unknown block type is an error, so a typo surfaces immediately on the page.
Form blocks let a page collect input — the building block of an in-dashboard
settings panel (see Configuring a plugin). A form is
{id, fields, submit_label?}; each field is {name, label, kind?, value?,
placeholder?, help?, options?} with kind one of text (default), number,
toggle, or select (which requires options). Submitting POSTs the form's
id as the action with every field's current value (a string; toggles send
"true"/"false") in req.params under the field's name — at most 16 fields,
matching the action-param bound:
function OnPageRender(req)
return {
blocks = {
{ type = "heading", text = "Settings" },
{ type = "form", id = "save_settings", submit_label = "Save",
fields = {
{ name = "keyword", label = "Keyword", value = kasas.config.keyword,
help = "transactions whose description contains this are tagged" },
{ name = "category", label = "Category", value = kasas.config.category },
} },
},
}
end
function OnPageAction(req)
if req.action == "save_settings" then
kasas.set_config({ keyword = req.params.keyword, category = req.params.category })
end
return OnPageRender(req)
end
Contract & tiers: treat OnPageRender as read-only — it runs whenever
someone views the page (GET /api/v1/plugins/pages/{name}, read tier). Mutations
belong in OnPageAction (POST /api/v1/plugins/pages/{name}/action, write
tier), and both hooks run on the plugin's single worker under
plugins.hook_timeout, serialized with its event hooks. Render failures are
recorded as plugin health (visible on the Plugins page) and reported on the page
itself.
# The sidebar entries (read tier).
curl -s localhost:8080/api/v1/plugins/pages
# -> {"pages":[{"name":"coffee-budget","title":"Coffee Budget","icon":"chart"}]}
# Render a page (runs OnPageRender).
curl -s localhost:8080/api/v1/plugins/pages/coffee-budget
# -> {"name":"coffee-budget","page":{"title":"Coffee Budget","blocks":[…]}}
# Press a button (runs OnPageAction; write tier).
curl -s -X POST localhost:8080/api/v1/plugins/pages/coffee-budget/action \
-H 'Content-Type: application/json' -d '{"id":"rescan"}'
The runtime seam¶
The manager, host facade, and capability model are language-agnostic. A language plugs in by implementing two small interfaces — that is exactly how JS and then WASM landed, with no core logic touched:
type Runtime interface {
Name() string // "lua" / "js" / "wasm"
Load(ctx, Manifest, dir string, Host) (Instance, error)
}
type Instance interface {
Invoke(ctx, Hook, HookEvent) error
Close() error
}
The Lua runtime opens a gopher-lua VM with only safe libraries (base, table,
string, math), removes every escape hatch (dofile, loadfile, load,
require, module, newproxy), routes print to structured logging, injects the
kasas table, and resolves the declared hook functions. Each Invoke sets the
VM's context so a runaway pure-Lua loop is interrupted at the deadline.
The JS/TS runtime transpiles the entrypoint with esbuild, then runs it in a fresh
goja VM — which exposes no require, filesystem, or network by default — injects the
camelCase kasas object plus a console shim, and resolves the declared hooks as
global functions. A watcher goroutine calls the VM's Interrupt at the deadline, so
a runaway while (true) {} is stopped just like a Lua loop.
The WASM runtime compiles the module once with wazero and instantiates it as a
WASI reactor with no preopened directories (so the entire WASI
filesystem/network surface is dead on arrival), binds the four ABI host
functions, and verifies the kasas_describe handshake against the manifest's
hook list. Deadlines interrupt even a tight compiled loop (wazero closes the
module instance); the instance is then re-instantiated from the compiled module
on the next invocation.
Enabling is opt-in & admin-only¶
A plugin is third-party code, so discovery and execution are separated:
- The
plugins.enabledswitch only makes plugins discoverable. Discovered plugins start disabled. - Enabling one loads and runs its code, so that action is admin-only (the dashboard token, never an API key).
curl -s -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" http://localhost:8080/api/v1/plugins
# -> {"plugins":[{"id":1,"name":"budgeting","state":"disabled","hooks":[…],…}]}
curl -X POST -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" \
http://localhost:8080/api/v1/plugins/1/enable
# -> {"id":1,"name":"budgeting","enabled":true,"loaded":true,"state":"loaded",…}
| Surface | Operations |
|---|---|
| REST | GET /api/v1/plugins, GET /api/v1/plugins/{id}, admin POST /api/v1/plugins/{id}/{enable,disable,reload}, admin DELETE /api/v1/plugins/{id} (uninstall) |
| MCP | list_plugins, get_plugin, enable_plugin, disable_plugin, reload_plugin, uninstall_plugin |
| Dashboard | The Plugins page: status/health, enable/disable toggle, reload, uninstall |
The plugins table stays lean — name, runtime, granted capabilities, config, and
run health — while the code lives on disk under plugins.dir.
The community marketplace¶
Installing a plugin by hand means dropping a directory into plugins.dir. The
marketplace automates discovery and installation from the
kasas-plugins community registry — a
repository whose submission pipeline gates every plugin (per-language static
analysis, a single self-contained entrypoint — hand-written or a
verified dependency bundle, capability review) before it
is listed, so a user can install with confidence.
kasas reads the registry's published, machine-readable index (index.json),
which lists each plugin with its manifest metadata, capability tier, trust tier
(see below), and a per-file SHA-256 plus an aggregate content hash. Installing
fetches those files, verifies every hash before writing a byte, and writes the
plugin into plugins.dir — so what lands on disk is exactly what was reviewed in the
registry, independent of the transport.
# Browse the catalog (admin/dashboard token).
curl -s -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" \
http://localhost:8080/api/v1/plugins/registry
# -> {"available":true,"plugins":[{"name":"coffee-budget","capability_tier":"write",
# "tier":"verified","installed":false,"update_available":false,...}]}
# Install (downloads + integrity-verifies, then registers it DISABLED).
curl -X POST -H "Authorization: Bearer $KASAS_DASHBOARD_TOKEN" \
http://localhost:8080/api/v1/plugins/registry/coffee-budget/install
# -> {"id":2,"name":"coffee-budget","state":"disabled",...}
Installing is admin-only and never runs code: a freshly installed plugin is registered disabled, exactly like one dropped in by hand — enabling it (which loads and runs it) stays the separate, deliberate action described above. An install over an existing plugin (an update) atomically swaps the files and reloads it only if it was already running.
Trust tiers¶
Every listed plugin carries an explicit trust tier (ADR 0003) — a legible, escalating signal of how far you are extending trust, computed by the registry gate from the plugin's declared capabilities (never self-assigned). The Marketplace page groups and badges plugins by it:
| Tier | What it may do | What you see |
|---|---|---|
| Verified | reads, labels, extensions, a dashboard page — capabilities the gate can prove sealed (no network, no disk) | The default. "Sealed: cannot reach the network or disk." Auto-listed once the gate's checks pass. |
| Connected | the above + net:fetch |
The exact hosts it may reach are shown before install; a registry maintainer reviewed that list, and enabling collects any private/LAN grants. |
| Unlisted | a capability outside the reviewed set | Never auto-listed — the registry refuses to publish it, so it appears only if you sideload it by hand. |
The tier is a communication and review construct layered on the capability checks
the host already enforces — it changes how loudly the risk is shown and how hard the
gate looks, not the opt-in, admin-only posture or
the per-capability enforcement in the host facade. net:fetch is the
capability that distinguishes "labels my transactions" from "reads everything and can
POST it out," so it gets its own tier rather than hiding inside "write."
Configure it under [plugins.registry] (effective only when plugins.enabled is
true; on by default, pointing at the official registry):
[plugins.registry]
enabled = true
url = "https://raw.githubusercontent.com/paulmeier/kasas-plugins/main/registry/index.json"
ref = "main" # used to build raw file-download URLs
| Surface | Operations |
|---|---|
| REST | admin GET /api/v1/plugins/registry, POST /api/v1/plugins/registry/{name}/install |
| MCP | browse_plugin_registry, install_plugin |
| Dashboard | The Marketplace page: browse grouped by trust tier, capability-tier warning, Connected-tier egress hosts, one-click install |
Uninstalling & the cleanup hook¶
Disabling a plugin only stops it; uninstalling removes it entirely. Because a
plugin may have created data (labels, schema extensions), the plugin — not kasas —
owns undoing that. Every plugin therefore implements an OnUninstall lifecycle
hook, and kasas runs it at uninstall time:
function OnUninstall()
-- Undo what this plugin created. It runs with the plugin's granted
-- capabilities, so it can remove its own labels/extensions via the kasas API.
kasas.log("info", "budgeting: cleaning up")
end
OnUninstall is a lifecycle hook, not an event hook: it has no triggering
event and is never dispatched off the bus — it is invoked exactly once, when the
plugin is removed. Uninstall:
- stops the plugin if it is running;
- loads a fresh, isolated instance (with the plugin's granted capabilities) and
runs
OnUninstallunder the per-hook timeout; - deletes the plugin's files from
plugins.dirand removes its registration.
The plugin is always removed, even if its OnUninstall errors or times out —
a buggy cleanup can never trap a plugin as un-removable. The hook's error is
reported back (in the REST/MCP response and the dashboard) so you know cleanup may
have been incomplete. Community plugins are required to declare OnUninstall
(the registry gate rejects those that don't); a
hand-dropped plugin without it is still removable, just without self-cleanup.
Sandbox & limits (v1)¶
All three runtimes run with no filesystem or process access, and no network
access by default, plus a single, self-contained entrypoint — the Lua VM opens
only safe libraries, the goja VM exposes none of those globals by default (eval
and the Function constructor binding are removed too), and the WASM module gets a
WASI with no preopens, so every path and socket operation fails by construction.
The one sanctioned exception is the net:fetch capability
(Network access): even then a plugin opens no socket —
it calls the host, which performs an allowlisted, SSRF-checked, logged request. The
submission gate keeps rejecting every other network construct. Each hook is bounded by
plugins.hook_timeout. The "single entrypoint" is about a single reviewable,
hashed artifact, not about forbidding dependencies: a JS/TS plugin may
bundle third-party libraries into that one file (ADR 0001),
and the host loads it the same way — there is still no runtime import/require,
no node_modules, and no module loader inside the sandbox.
WASM is the strongest of the three: isolation is enforced by the WebAssembly memory model rather than by withholding APIs, and guest linear memory is hard capped at 1 GiB per plugin. The Lua and JS VMs are not hard memory sandboxes (a buggy plugin can still allocate without bound), so the v1 trust model remains operator-installed, opt-in plugins — sourced by hand or, with the same disabled-by-default posture, from the gated community marketplace above. Pick the WASM runtime when you want the strongest isolation for third-party code.