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

Building a UI

The baseline ships no HTML. The admin-api plugin exposes JSON routes and your application owns the frontend — React, HTMX, a Slack bot, a CLI — whatever your product already is. This keeps the agent’s reach and the human’s approval surface identical no matter how you render them.

Routes (@sms/plugin-admin-api, prefix /api by default)

RoutePurposeToken
GET /api/stateeverything an admin screen needs: plugins[] (id, version, trust, state, enabled, error, waitingFor), pending[] (approvals with manifest + reason), collections[] (schemas), points[], keys[], routes[], schedules[] (name, owner, everyMs, lastRun, lastError), gates[]
GET /api/catalogwhat the model sees: keys, extension points with their contributions, plugins
POST /api/chat { session?, text }one turn: { reply, steps: [{ tool, input, output, error? }] }
POST /api/chat/stream { session?, text }the same turn as server-sent events (see below)
GET /api/chat/:session · DELETE …history / reset· / ✔
POST /api/plugins/:id/enable · /disable · DELETE /api/plugins/:idlive undo / redo / remove (agent plugins only; system ids are refused)
POST /api/approvals/:id/approve { by? } · /reject { by?, reason? }the human half of the judge
GET /api/events?limit=100audit log, newest first (plugin/*, fiber/*, judge/*, chat/*)
GET /api/collections[/name[/id]] (+ POST/PATCH/DELETE)generic CRUD from the collections plugin
GET /api/orm/meta[/:model]resolved model specs (fields, kinds, relations) — render forms and lists from these
GET /api/orm/:model?domain=[…]&fields={…}&limit=&offset=&order=search + read → { length, records }
POST /api/orm/:model { …values } · GET/PATCH/DELETE /api/orm/:model/:idcreate / read one (?fields=) / write / unlink
POST /api/orm/:model/group { domain, groupBy, aggregates } · /onchange { values, changed } · /call/:method { ids, args }readGroup, form onchange, model methods

The ORM routes take their OpContext from createOrmPlugin({ contextOf }) (an auth plugin’s session, for example); with the default {} every request is anonymous and only the guard decides.

One round-trip: /api/rpc (createBaseline({ rpc: true, meta: true }))

RouteBody → result
POST /api/rpc{ model, method, args?, kwargs? } or an array of them: the caller is resolved once, calls run in order, and the answer is always 200 with { result } / { error: { message, kind } } per call (an array in → an array out). Methods: search_read { domain, fields, limit, offset, order } · read { ids, fields } · create { values } · write { ids, values } · unlink { ids } · read_group · onchange { values, changed } · defaults · call { method, ids, args } · fields · rights (→ { read, write, create, unlink } as rbac will enforce them)
GET /api/rpc/meta{ models, menus, actions, user } — menus/actions come from @sms/plugin-meta (empty without it), user from auth
GET /api/rpc/meta/:model{ spec, views?, rights }views only when meta is on and something was contributed
/api/audit/:model/:id/messages · /activities[/:aid] · /followers · /follow · /unfollow · GET /api/audit/activities/minemessages, activities and followers of one record (@sms/plugin-audit); 404 when the record is not visible to the caller
your own route contributionsanything else

Token: authorization: Bearer <adminToken> or x-admin-token. Configure it in createBaseline({ adminToken }). When your app has its own users, a system plugin can admit its admin sessions instead of (or as well as) the token by contributing to the admin-authorizer point (system-only):

contributes: { 'admin-authorizer': [{ name: 'session', authorize: (req) => isAdminSession(cookieValue(req, 'sid')) }] }

With neither a token nor an authorizer, mutating routes are open (local development only). The baseline deliberately knows only “may mutate / may not” — per-user permissions live in your plugins.

Users and sessions (@sms/plugin-auth)

Install createAuthPlugin({ bootstrapAdmin: { login, password } }) and the baseline has users: PBKDF2 passwords (WebCrypto — works in a Worker), cookie sessions, and an admin-authorizer so an admin’s session drives every /api/* route above without the bearer token.

RoutePurpose
POST /api/auth/login { login, password }{ user, token }; sets the sms_session HttpOnly cookie (Secure behind https)
POST /api/auth/logoutends the session, clears the cookie
GET /api/auth/session{ user } or 401

Your own routes ask identity.whoami(req) (key identity, injectable by agent plugins too — it never returns secrets). User management (identity-admin: create, setPassword, setAdmin, deactivate, activate) is a system-only key. Groups and permissions are @sms/plugin-rbac’s job.

Groups and permissions (@sms/plugin-rbac)

RouteReturnsAdmin
GET /api/rbac/groupsdeclared groups { name, description?, implies?, owner }
GET /api/rbac/users/:id/groups{ direct: string[], all: string[] } (all expanded through implies)
PUT /api/rbac/users/:id/groups { groups }replaces the user’s direct groups

“Admin” here means the identity provider (your auth plugin) says isAdmin for the session — not the admin token. In your own routes: await rbac.assertCan({ userId, model, op }) (403 on failure) and AND await rbac.ruleDomain({ userId, model, op }) into the query.

Errors

Every baseline route answers errors as { error: { message, kind } } with a matching status (validation 400, auth 401, forbidden 403, not_found 404, conflict 409, internal 500 — the last one also carries plugin, the owner of the failing route). Use the same shape in your own routes: throw new HttpError(404, 'no such invoice') or return errorJson('bad id', 400) from @sms/plugin-http.

A minimal chat + approvals screen

const api = (path, init = {}) => fetch(path, { ...init, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init.headers } })
async function send(text) {
  const r = await (await api('/api/chat', { method: 'POST', body: JSON.stringify({ session: userId, text }) })).json()
  render(r.reply, r.steps)               // show tool calls: plugin_define → verdict, etc.
  refresh()
}
async function refresh() {
  const s = await (await fetch('/api/state')).json()
  renderPending(s.pending)               // approve/reject buttons → POST /api/approvals/:id/approve
  renderPlugins(s.plugins)               // enable/disable toggles for trust === 'agent'
  renderCollections(s.collections)       // tables from schema; rows from /api/collections/:name
}

Showing agent-made UI safely

Agent plugins cannot contribute HTML to the baseline (there is no such point). If your product wants agent-authored panels, define your own extension point in a system plugin (ui-panel: { title, html }), mark it sensitivePoints so a human approves each one, and render the HTML in a sandboxed iframe or through a strict sanitizer. The kernel will validate, record and undo those contributions like any other.

Streaming, long chats

POST /api/chat returns when the turn is done (tool loops included; maxSteps in the agent-loop options). POST /api/chat/stream is the same turn as text/event-stream: one data: <json> frame per AgentEvent (@sms/plugin-agent-loop):

eventpayloadwhen
message{ message: { role, content } }each transcript message as it is appended (user text, assistant turn, tool results)
text_delta / thinking_delta{ text }model output / reasoning as the driver streams it (drivers without stream() send the text in one delta; thinking only where the provider exposes it)
tool_use{ block }a complete tool call, before it runs
tool_start / tool_end{ id, name, input } / { id, step }around each tool handler
done / error{ result: { reply, steps } } / { message }terminal — exactly one of them ends the stream

Read it with fetch + ReadableStream (cookies and the bearer token work; EventSource cannot POST): split on blank lines, JSON.parse after data: . The turn keeps running server-side if the client disconnects, and turns on one session run one after another. Your own routes get the same stream from agent.chatStream(session, text) and can return sse(events) from @sms/plugin-http; the agent key is system-only, so such a route lives in one of your system plugins. For durable transcripts implement AgentSessionStore (load on a cache miss, append per message) and pass a factory as createBaseline({ sessionStore: (kernel) => store }) — it runs before your plugins install, so load() may read any key from the kernel it is given.