Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing plugins

Everything in the baseline is a plugin, and so is everything you add. There are two kinds:

System pluginAgent plugin
Written byyou, in your repothe model at run time (or a human through the same API)
Runsin-process, activate(ctx) with ctx.kernelin the executor, activate(cap), JSON-only bridge
Trustsystemagent — cannot touch system-only keys/points, needs the judge
Installed bycreateBaseline({ 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>/contractX_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

PointOwnerItemAgent may contribute
routehttp{ method, path, handler(req) }:param, *; return JSON or a Response; throw HttpError for a statusyes (needs a human by default)
schedulescheduler{ name, everyMs, handler({ name, now, lastRun }) } — runs when the host ticks; last run persistedyes (needs a human by default)
admin-authorizeradmin-api{ name, authorize(req) → boolean } — admits a request to mutating admin routes; @sms/plugin-auth contributes auth-session (an admin session)no (system)
grouprbac{ name, description?, implies?[] } — membership is data (rbac-admin.grant)no (system)
accessrbac{ model, group, read?, write?, create?, unlink? } — the access matrixno (system)
rulerbac{ name, model, groups?, domain, ops? } — record rule; rbac.ruleDomain() ANDs global rules with the OR of the user’s group rulesno (system)
modelorm{ name, fields: { name: spec }, displayField?, order? } — a table with relations, computed and related fields (see Models)yes
model-fieldorm{ model, name, spec } — adds a column to a model declared elsewhere (waits for it if needed)yes
model-methodorm{ model, name, handler(env, ids, args), access? } — computes, buttons, RPCyes (needs a human by default)
model-hookorm{ model, on: create/write/unlink, when: before/after, handler(env, { ids, values, records }) }yes (needs a human by default)
model-onchangeorm{ model, field, handler(env, values) → patch }yes
viewmeta{ 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
actionmeta{ id, name, model, views[], viewIds?, domain?, context?, groups? } — ids are globalyes
menumeta{ id, label, parent?, action?, sequence?, icon?, groups? } — roots are apps; empty branches are prunedyes
automationautomation{ id, label, model, trigger: create/write/create_or_write/unlink/schedule, watch?, filter?, everyMinutes?, action: { kind: method/update/message/handler, … }, active? } — actions run as sudoyes (needs a human by default)
audit-trackaudit{ model, fields[] } — log field changes of that model as audit_message rowsyes
collectioncollections{ name, fields[] } — types text · integer · real · boolean · jsonyes
collection-fieldcollections{ collection, field } — adds a columnyes
collection-hookcollections{ collection, on: create/update/remove, handler(record, previous) }yes
toolagent-loop{ name, description, input_schema, handler(input) }yes
promptagent-loop{ section, text, order }yes
gatejudge`{ name, run(ctx) → undefinedreason }` — runs before any agent plugin mounts
invariantjudge{ name, check(kernel) } — runs after every mount; a throw rolls the mount backno

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().