Writing plugins
Everything in the baseline is a plugin, and so is everything you add. There are two kinds:
| System plugin | Agent plugin | |
|---|---|---|
| Written by | you, in your repo | the model at run time (or a human through the same API) |
| Runs | in-process, activate(ctx) with ctx.kernel | in the executor, activate(cap), JSON-only bridge |
| Trust | system | agent — cannot touch system-only keys/points, needs the judge |
| Installed by | createBaseline({ plugins }) | judge.submit() via plugin_define / POST /api/chat |
Both share the same manifest and the same two effects: provide(key, value) and
contribute(point, item). The kernel records each and derives its inverse — never write teardown.
A system plugin
import { type CollectionsService, type SystemPlugin, json } from '@sms/baseline'
export const invoicesPlugin: SystemPlugin = {
manifest: {
id: 'erp.invoices', // stable; agent plugins can never take a system id
version: '0.1.0',
inject: ['collections'], // keys read via ctx.get — activation waits until they exist
contributes: { // declarative: applied by the kernel, no code needed
collection: [{ name: 'invoice', fields: [
{ name: 'customer', type: 'text', required: true },
{ name: 'amount', type: 'real', required: true },
{ name: 'paid', type: 'boolean', default: false },
] }],
prompt: [{ section: 'application', order: 20, text: 'Invoices live in the `invoice` collection.' }],
},
},
async activate(ctx) {
const collections = ctx.get<CollectionsService>('collections')
await ctx.contribute('tool', {
name: 'overdue_invoices', description: 'Unpaid invoices past due',
input_schema: { type: 'object', properties: {} },
handler: async () => (await collections.list('invoice', { where: { paid: false } })),
})
await ctx.contribute('route', {
method: 'GET', path: '/api/invoices/summary',
handler: async () => json({ count: (await collections.list('invoice', { limit: 1000 })).length }),
})
},
}
Rules that the kernel enforces: you may only get keys you inject, only provide keys you declare in
provide, only during activation. Contributions are validated by the point owner and can be rejected
(your fiber fails; siblings keep running). Disabling the plugin undoes everything in reverse order.
Depending on other plugins: contracts, inject, optional
A plugin never imports another plugin’s provider. Each one publishes its definitions at
@sms/plugin-<x>/contract — X_KEY, X_POINT constants, the service and item types, validators and pure
helpers — and that is the only subpath a plugin may import from a sibling (pnpm quality enforces it). Your own
plugins follow the same shape: src/contract.ts with the definitions, src/index.ts re-exporting it plus the
provider. Composition roots (createBaseline, your app) import the roots.
import { ORM_KEY, type OrmService } from '@sms/plugin-orm/contract'
import { AUDIT_KEY, type AuditService } from '@sms/plugin-audit/contract'
manifest: {
id: 'erp.invoices', version: '1',
inject: [ORM_KEY], // hard: activation waits for it, deactivation follows it
optional: [AUDIT_KEY], // soft: used when present — read with ctx.kernel.getValue(AUDIT_KEY)
}
inject is what the kernel reconciles on (a fiber activates only when every injected key exists and is
unloaded before its provider). optional declares a soft dependency so it is visible — in FiberView, in the
admin API, and to the gate, which fails a plugin that uses another plugin’s contract at runtime or calls
getValue(key) without declaring the key in one of the two.
Extension points shipped
| Point | Owner | Item | Agent may contribute |
|---|---|---|---|
route | http | { method, path, handler(req) } — :param, *; return JSON or a Response; throw HttpError for a status | yes (needs a human by default) |
schedule | scheduler | { name, everyMs, handler({ name, now, lastRun }) } — runs when the host ticks; last run persisted | yes (needs a human by default) |
admin-authorizer | admin-api | { name, authorize(req) → boolean } — admits a request to mutating admin routes; @sms/plugin-auth contributes auth-session (an admin session) | no (system) |
group | rbac | { name, description?, implies?[] } — membership is data (rbac-admin.grant) | no (system) |
access | rbac | { model, group, read?, write?, create?, unlink? } — the access matrix | no (system) |
rule | rbac | { name, model, groups?, domain, ops? } — record rule; rbac.ruleDomain() ANDs global rules with the OR of the user’s group rules | no (system) |
model | orm | { name, fields: { name: spec }, displayField?, order? } — a table with relations, computed and related fields (see Models) | yes |
model-field | orm | { model, name, spec } — adds a column to a model declared elsewhere (waits for it if needed) | yes |
model-method | orm | { model, name, handler(env, ids, args), access? } — computes, buttons, RPC | yes (needs a human by default) |
model-hook | orm | { model, on: create/write/unlink, when: before/after, handler(env, { ids, values, records }) } | yes (needs a human by default) |
model-onchange | orm | { model, field, handler(env, values) → patch } | yes |
view | meta | { id, model, kind: form/list/kanban/search, arch, priority? } or an extension { id, model, kind, inherit, ops: [{ target, position, nodes?, attrs? }] } (createBaseline({ meta: true })) | yes |
action | meta | { id, name, model, views[], viewIds?, domain?, context?, groups? } — ids are global | yes |
menu | meta | { id, label, parent?, action?, sequence?, icon?, groups? } — roots are apps; empty branches are pruned | yes |
automation | automation | { id, label, model, trigger: create/write/create_or_write/unlink/schedule, watch?, filter?, everyMinutes?, action: { kind: method/update/message/handler, … }, active? } — actions run as sudo | yes (needs a human by default) |
audit-track | audit | { model, fields[] } — log field changes of that model as audit_message rows | yes |
collection | collections | { name, fields[] } — types text · integer · real · boolean · json | yes |
collection-field | collections | { collection, field } — adds a column | yes |
collection-hook | collections | { collection, on: create/update/remove, handler(record, previous) } | yes |
tool | agent-loop | { name, description, input_schema, handler(input) } | yes |
prompt | agent-loop | { section, text, order } | yes |
gate | judge | `{ name, run(ctx) → undefined | reason }` — runs before any agent plugin mounts |
invariant | judge | { name, check(kernel) } — runs after every mount; a throw rolls the mount back | no |
Keys: database and objects (system↔system; the host provides them), http, collections, scheduler, model (system provides), judge, agent
(system↔system), identity (auth; read-only, agent may inject), identity-admin (auth; system↔system), rbac /
rbac-admin, orm, meta (compiled views, actions, menu tree — view() is undefined when nothing was
contributed; there is no default view generation) and audit (post(model, id, { body, kind }, ctx)). Define your own with ctx.kernel.defineKey/definePoint and mark trust on them — that
is how you keep the agent out of something (never by hiding an object).
Models
@sms/plugin-orm (createOrmPlugin({ contextOf?, prefix? }), install after httpPlugin) turns a model
contribution into a table m_<name> on the database key, additively migrated on every boot:
contributes: {
model: [{
name: 'invoice',
fields: {
number: { kind: 'char', required: true },
partner: { kind: 'many2one', target: 'contact', onDelete: 'restrict' },
partner_email: { kind: 'related', path: 'partner.email' },
state: { kind: 'selection', options: [{ value: 'draft' }, { value: 'paid' }], default: 'draft' },
lines: { kind: 'one2many', target: 'invoice_line', inverse: 'invoice' },
tags: { kind: 'many2many', target: 'tag' },
total: { kind: 'computed', returns: 'float', depends: ['lines.subtotal'], compute: 'compute_total' },
},
displayField: 'number',
order: 'number desc',
}],
'model-method': [{
model: 'invoice', name: 'compute_total',
// computes get `{ records }` (scalar values of `ids`) and answer `{ [id]: value }` — no I/O needed,
// so an agent plugin can contribute one from inside the executor
handler: (env, ids, { records }) => Object.fromEntries(records.map((r) => [r.id, /* … */ 0])),
}],
}
Kinds: char text int float bool date datetime json selection many2one one2many many2many computed related.
Every model has id, display (from displayField, default name), created_at/updated_at,
created_by/updated_by. Defaults accept $uid, $now, $today.
System plugins talk to the orm key (system-only):
const orm = ctx.get<OrmService>('orm')
const ids = await orm.search('invoice', [['state', '=', 'draft'], '|', ['total', '>', 100], ['partner.name', 'ilike', 'acme']], ctx, { limit: 20 })
const [inv] = await orm.read('invoice', ids, { number: {}, partner: { email: {} }, lines: { subtotal: {} } }, ctx)
await orm.write('invoice', ids, { lines: [{ op: 'create', values: { qty: 2 } }] }, ctx) // x2many commands: create/update/delete/link/unlink/set or a bare id list
await orm.readGroup('invoice', { domain: [], groupBy: ['state', 'created_at:month'], aggregates: { sum: 'sum:total' } }, ctx)
orm.on('write', ({ model, ids, values }) => { /* automation, audit */ })
Domains are prefix notation (& implicit, |, !), ops = != < <= > >= in not in like ilike (a like
value without % is a literal substring — %, _, \ are escaped and it is wrapped in %…%; a value
containing % is used as a raw pattern); dotted
paths cross relations. ctx is { userId?, isAdmin?, sudo? }; sudo bypasses the guard. An access-control
plugin narrows every operation with orm.setGuard({ can({ ctx, model, op }), domain({ ctx, model, op }) }).
orm.spec(model) is the resolved schema a UI can render forms from; REST lives under /api/orm
(docs/building-a-ui.md). Handlers owned by agent plugins run in the executor and get env = { model, ctx };
system handlers also get env.orm.
Automation and audit (@sms/plugin-automation, @sms/plugin-audit)
Both are ordinary plugins: [...] entries, installed after the ORM and rbac. createAutomationPlugin() owns the
automation point: on create/write/unlink of a model (optionally only when a watched field changed and
the record still matches filter) or every everyMinutes (the scheduler ticks automation.tick, last runs are
kept in au_automation_run), it calls a model method, writes values, posts an audit note (when audit is
provided — otherwise a no-op) or runs an inline handler — all as sudo, guarded against re-entering itself.
createAuditPlugin({ contextOf, group? }) provides audit and the audit_message / audit_activity /
audit_follower models (user references are plain ints), tracks the fields listed in audit-track
contributions, cascades on unlink and serves /api/audit/.... group contributes the rbac access rows and the
“own activities” rule for that group; without it, grant access yourself (under rbac the models are closed by default).
Your own extension point
activate(ctx) {
const kernel = ctx.kernel!
kernel.definePoint({
name: 'report',
description: 'A report the finance page shows: { title, query }',
validate: (item) => { if (typeof (item as { title?: unknown }).title !== 'string') throw new Error('title required') },
})
kernel.observePoint<Report>('report', {
added: (c) => reports.set(c.id, c.item), // react
removed: (c) => reports.delete(c.id), // the exact inverse — the kernel calls it on undo
})
}
removed must be a true inverse of added; the kernel does the bookkeeping, you do the reaction.
Gates and invariants: your safety policy
await ctx.contribute('gate', {
name: 'no-finance-writes',
run: ({ manifest }) => manifest.contributes?.['collection-hook']?.some((h) => (h as { collection: string }).collection === 'ledger')
? 'agent plugins may not hook the ledger' : undefined,
})
await ctx.contribute('invariant', {
name: 'ledger-intact',
check: (kernel) => { if (!kernel.has('ledger')) throw new Error('ledger service withdrawn') },
})
Both points are system-only: the agent cannot see, add or remove them. Pair them with
judge.sensitiveScopes / sensitivePoints in createBaseline to decide what needs a human (default:
scope net and DEFAULT_SENSITIVE_POINTS = route, schedule, model-method, model-hook, automation).
Agent plugins
The model defines them through the plugin_define tool; you can submit the same shape yourself with
kernel.getValue<JudgeService>('judge').submit(manifest, 'me'). Ids must start with agent.. Code is an ES
module with no imports:
export default async function activate(cap) {
await cap.contribute('tool', {
name: 'high_value_invoices', description: 'Invoices over 10k',
input_schema: { type: 'object', properties: {} },
handler: async () => (await cap.invoke('collections', 'list', 'invoice', { limit: 500 })).filter((i) => i.amount > 10000),
})
}
cap.get / invoke return JSON snapshots; functions inside contributed items become host-callable refs and stop
working the instant the plugin is disposed. In the QuickJS executor the code also has a memory limit and a
CPU deadline per slice; there are no timers, no fetch, no process.
Testing your plugins
@sms/baseline/testkit boots a real baseline (in-memory SQLite, Node executor, scripted model, approvals
skipped) and gives you a fetch-style call:
import { call, createTestBaseline } from '@sms/baseline/testkit'
const app = await createTestBaseline({ plugins: [invoicesPlugin], turns: [() => 'pong'] })
expect((await call(app, '/api/collections/invoice', { body: { total: 3 } })).status).toBe(200)
expect((await call(app, '/api/chat', { body: { text: 'hi' } })).json.reply).toBe('pong')
await app.tick() // run due schedules
await app.close()
Auth and rbac are off by default (open admin routes, sudo ORM); pass auth: { iterations: 1000, bootstrapAdmin }
to test permissions. Pass store / executor to test on the exact drivers production uses — for Cloudflare,
openDurableObjectStore(fakeSqlStorage()) from @sms/store-do/testing with QuickJSExecutor.
Persistence
Installed/enabled state is an event log; bundles are content-addressed blobs. Collection tables are kept
when a declaration is withdrawn, so disable/enable never loses data. Your system plugins are re-installed
on every boot from code; agent plugins are re-mounted from the log by kernel.restore().