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)
| Route | Purpose | Token |
|---|---|---|
GET /api/state | everything 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/catalog | what 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/:id | live 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=100 | audit 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/:id | create / 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 }))
| Route | Body → 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/mine | messages, activities and followers of one record (@sms/plugin-audit); 404 when the record is not visible to the caller |
your own route contributions | anything 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.
| Route | Purpose |
|---|---|
POST /api/auth/login { login, password } | { user, token }; sets the sms_session HttpOnly cookie (Secure behind https) |
POST /api/auth/logout | ends 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)
| Route | Returns | Admin |
|---|---|---|
GET /api/rbac/groups | declared 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):
| event | payload | when |
|---|---|---|
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.