@terminus-ai/app-sdk
v0.0.5
Published
Browser SDK for user-owned data, collaboration, connectors, and managed app resources on Terminus.
Maintainers
Readme
@terminus-ai/app-sdk
The browser SDK for Terminus apps: the person's own files and records, shared spaces with their members and read cursors, rooms and presence, durable streams, notifications, automations, the public web, connectors, services and the app's own server code — over one same-origin door, with the person's session never in reach of app code.
import { db, files, ready, spaces } from "@terminus-ai/app-sdk";
const app = await ready();
if (app.guest) showSignIn(); // a guest: their own things stay on this device
else greet(app.user.displayName);
const notes = await db.collection("notes").live({ sort: "-updatedAt" });
notes.subscribe((records) => render(records));
await notes.put("welcome", { title: "Hello", updatedAt: Date.now() });
const { files: recent } = await files.list({ prefix: "exports/", limit: 20 });
const team = await spaces.create({ name: "Launch", memberHandles: ["bob", "carol"] });Importing the root module has no side effects: the same-origin bridge is
created on first use. Script-tag apps import the auto entry, which installs
window.terminus and window.TerminusSDK eagerly:
<script type="module">
import "@terminus-ai/app-sdk/auto";
const app = await window.TerminusSDK.ready();
</script>Runtime protocol major version 1 is negotiated by ready(): a host that
speaks another major is refused before any data is touched. Its wire — every
door, field, header, error code and limit — is conformance/doors.json; the
SDK speaks that snake_case wire and answers apps in camelCase. What an app or
an upstream wrote is never renamed: record values, space meta, event
payloads, presence states, notification data, job input and result,
connector, service and egress bodies and headers, and server results come
back exactly as written.
The session
ready() answers who is here. A signed-in person:
const app = await ready();
// { guest: false, user: { id, handle, displayName, avatarUrl, publicProfileUrl },
// appId, slug, name, iconUrl, installationId, releaseId, releaseVersion,
// grants, resourceGrants, platformApiVersion, sdkVersion }Guests
An app open to guests (its maker's setting, beside visibility and price)
also opens for someone who is not signed in: guest: true, user: null,
no installation. A guest's own things work, and stay on this device —
records in collections without a space (put, get, find, search,
live, db.transact), files, and storage buckets without a space — through
the same calls, with the same versions, compare-and-set and change feed; a
guest keeps up to 64 MiB of an app. Everything that needs an account —
spaces, rooms, streams, people, notifications, the web, connectors,
services, server code — refuses with a TerminusError whose code is
guest; the app keeps running and can say so. Shared capability modules
(horizontal.loadModule, loadStyle) load for a guest too, for the
capabilities the app uses.
When a feature needs an account, ask for it where it is needed:
import { ready, session } from "@terminus-ai/app-sdk";
const app = await ready();
createRoomButton.onclick = () => {
if (app.guest) return session.signIn({ returnTo: "/?tab=rooms" });
openCreateRoom();
};session.signIn({ returnTo }) takes the page through the platform's own
sign-in (or sign-up) and back to returnTo — a path on the app's own
origin; the current page when unset — signed in. The origin never changes,
so whatever the app kept in the browser is still there, and the guest's own
records, files and objects move into their account as the signed-in page
opens (moveGuestData, create-only: anything the account already has by
the same name stays as it is, and the guest's copy stays on the device).
Only a sign-in the app started moves anything by itself: on a shared
browser, session.guestData() says what a guest left here, and the app
decides — session.moveGuestData() or session.discardGuestData().
Inside a frame, sign-in opens in a tab of its own.
session.signOut() signs the person out of this app on this device. The
platform ends the session; from then on every door of the page refuses with
session_ended (the SDK's own answer, sent nowhere), and the app's other
windows — their session cookie went with it — with unauthorized. A
session the platform itself ended (the person signed out of Terminus, or it
expired) answers session_ended too; session.signIn() signs them back in
and returns them where they were. An app open to guests opens as a guest
after signing out.
Errors and limits
Every refusal is a TerminusError: the HTTP status (0 when the platform
was never reached), the platform's code, its details, and whether the
same call can succeed later. Branch on the code, never on the message:
import { TerminusError, isRetryable } from "@terminus-ai/app-sdk";
try {
await tasks.put(id, value, { expectedVersion: 3 });
} catch (error) {
if (error instanceof TerminusError && error.code === "version_conflict") return reload();
if (isRetryable(error)) return retryLater();
throw error;
}isRetryable(error) is true for network (the platform could not be
reached), 5xx answers other than server_disabled and a door the host does
not have (not_implemented, unsupported_in_dev), 408/425/429 and
still_pending (the same idempotency key is still in flight). An ended
session, a guest and every other 4xx are final. ErrorCode names the whole
catalogue.
LIMITS carries the protocol's numbers — LIMITS.transactionOperations
(64), LIMITS.pageSize (200), LIMITS.eventBatch (16), LIMITS.mentions
(8), LIMITS.bucketObjectBytes (8 MiB), LIMITS.spaceMetaBytes (4096) and
the rest of doors.json's limits — and RECORD_ID_PATTERN the record id
grammar. The SDK checks the ones it can before a request leaves the page, so
an oversized batch or a bad id fails at the call with bad_request or
payload_too_large.
Files
The person's own file plane: files this app may read and write. private
(the default zone) holds the app's own files; output holds deliverables the
person keeps. With a spaceId, a file is the caller's own in that space
(private zone only) — state other members share belongs in a space
collection, a space bucket or a durable stream instead.
import { files } from "@terminus-ai/app-sdk";
const receipt = await files.write("drafts/plan.md", text, { mediaType: "text/markdown" });
// { path, sizeBytes, sha256, mediaType, version, updatedAt }
const body = await files.read("drafts/plan.md");
const image = await files.readBlob("exports/cover.png", { zone: "output" });
let page = await files.list({ prefix: "drafts/", limit: 100 });
while (page.nextCursor) page = await files.list({ prefix: "drafts/", after: page.nextCursor });
await files.remove("drafts/old.md");files.stat(path) answers a file's metadata — sha256, version,
mediaType, schemaId, schemaVersion, observationId, provenance. For
a compare-and-set edit, stat first, then read, then write with
expectedSha256: stat.sha256 (or "missing" for a file that must not exist
yet): a change in between fails the write with version_conflict instead of
being overwritten. A file is at most 12 MiB.
Storage buckets
Nothing declares a bucket. Opened with a spaceId it belongs to that space:
every member reads it, and an object stays when whoever wrote it leaves.
Without one it is the member's own. An object is at most 8 MiB and keeps the
content type it was written with.
import { storage } from "@terminus-ai/app-sdk";
const shared = storage.bucket("attachments", { spaceId });
await shared.write("photos/1.png", blob); // a Blob's own type is stored
const photo = await shared.read("photos/1.png"); // a Blob
const { files: listed, nextCursor } = await shared.list({ prefix: "photos/", limit: 50 });
await shared.delete("photos/1.png");Like a file write, a bucket write can be create-only (expectedSha256:
"missing") or a compare-and-set against the sha256 an earlier receipt or
listing gave, and so can a delete — so members of a space do not overwrite,
or delete, each other's files. A mismatch answers version_conflict; a
delete cannot expect "missing" (it needs something to delete: bad_request).
const receipt = await shared.write(`photos/${id}.png`, blob, { expectedSha256: "missing" });
await shared.write(`photos/${id}.png`, edited, { expectedSha256: receipt.sha256 });
await shared.delete(`photos/${id}.png`, { expectedSha256: receipt.sha256 }); // version_conflict: it changedPictures on screen
The platform serves every read afresh and resizes nothing. media keeps
what the page has read: each file is read once however many views show it,
drawn on the first frame when its bytes are already here, never revoked
under a view that holds it, and let go of — oldest first — once nothing
shows it and the page holds more than its idle budget (96 MiB;
media.setIdleBudget). The key is any string that names the bytes.
import { media, storage } from "@terminus-ai/app-sdk";
const shared = storage.bucket("attachments", { spaceId });
const key = `${spaceId}/${attachment.path}`;
// In a view: `url` at once when the bytes are here, else `ready`.
const view = media.objectUrl(key, () => shared.read(attachment.path));
img.src = view.url ?? (await view.ready);
// …and when the view goes away:
view.release();
media.peek(key); // the URL if the bytes are here, synchronously (holds nothing)
media.hold(key, file); // bytes already in hand — the sender's own photo — where views look
await media.bytes(key, () => shared.read(attachment.path)); // the Blob, to save or send on
media.revoke(key); // the bytes changed: read them again next timemedia.thumbnail(blob, { maxEdge, type, quality }) makes the small copy in
the browser that already holds the picture: decoded with its own
orientation, scaled to its longest edge (never enlarged), and encoded as
type (WebP by default; a browser that cannot encode it gives a photograph
back as JPEG). Where the browser can, it reads the picture's size from its
header and decodes it at the thumbnail's size — a 48-megapixel photo never
sits in memory whole (about 190 MB); elsewhere (a worker, a browser that
ignores resize options) it decodes the whole picture and scales it. It
answers { blob, width, height, sourceWidth, sourceHeight }.
const { blob: thumbnail, width, height } = await media.thumbnail(file, { maxEdge: 640 });Records
Collections are registered lazily where app code opens them, once per page
(db.collection("tasks") inline at every call site costs one definition
round-trip). A collection is the member's own unless its definition says
ownership: "space"; definitions can add search fields, indexes,
uniqueness, relations, a bounded permission policy and validate-on-write
rules the platform checks inside every write's own transaction:
import { db } from "@terminus-ai/app-sdk";
const tasks = db.collection("tasks", {
spaceId, // a space-owned collection is read and written in one space
ownership: "space",
searchFields: ["title"],
indexes: ["status"],
unique: ["slug"],
relations: { project: { collection: "projects", field: "projectId", required: true, onDelete: "cascade" } },
permissions: { read: "space-member", create: "space-editor", update: { anyOf: ["record-creator", "space-admin"] } },
validate: [
{ rule: { eq: [{ field: "ownerId" }, { actor: "user_id" }] }, message: "a task is its writer's" },
{ rule: { forbidChange: ["ownerId"] }, message: "the owner is fixed" },
],
});
const record = await tasks.getRecord("task-1"); // { id, value, version, createdAt, updatedAt, createdByUserId, … }
await tasks.put("task-1", { title: "Ship", status: "open" }, { expectedVersion: record?.version ?? 0 });
const open = await tasks.find({ status: "open" }, { sort: "-priority", limit: 20 });
for await (const task of tasks.scan({ status: "done" })) archive(task);Every write carries an idempotency key — pass mutationId to make a retry
safe, or let the SDK pick one — and expectedVersion makes it a
compare-and-set (0: the record must not exist). Record ids match
RECORD_ID_PATTERN. find() reads one bounded page, findPage() answers its
cursors, scan() walks every page without holding them, and search() runs
a websearch-syntax query over the declared search fields. The query grammar
(where, sort, search) is evaluated locally by the
@terminus-ai/app-sdk/where module too, which throws WhereGrammarError for
exactly the inputs the platform rejects.
Several writes can commit as one unit — every compare-and-set, constraint, relation and permission check succeeds or nothing does:
await db.transact([
tasks.putOperation("task-1", { title: "Ship", projectId: "p1" }, { expectedVersion: 0 }),
{ action: "put", collection: "audit", id: "task-1-created", value: { taskId: "task-1" }, expectedVersion: 0 },
], { spaceId, transactionId: "create-task-1" });A write in a space can ring people, exactly when it commits — never on a
retry of it, never without it. notify is the row they see (by default it
rings every member not looking at the space; audience: "mentioned" rings
only the mentioned), and mentions (up to 8 handles) rings each named
member with the platform's own "mentioned you" row:
const channel = db.collection("channel_messages", { ownership: "space" });
await db.transact([channel.putOperation(message.id, message, { expectedVersion: 0 })], {
spaceId,
transactionId: message.id,
notify: { title: "Alice in #launch", body: message.text, route: `/?space=${spaceId}` },
mentions: ["bob"],
});A personal record can be delivered to every current member's own collection with one idempotent call; the platform freezes the recipients and a space-wide delivery sequence with it:
const inbox = db.collection("messages"); // each member's own
const delivered = await inbox.deliver(message.id, message, {
spaceId,
mutationId: message.clientId,
notify: { title: "Alice", body: message.text },
});
// { id, value, sequence, recipients, mutationId, syncCursor, replayed }Live queries
live() is a reactive projection over a query: it reads a snapshot, then
follows the collection's durable change feed — applying a change in place
when its frame says it follows the local cursor, reading the feed otherwise,
and draining from its cursor after the stream reconnects. Writes through it
are optimistic and leave the overlay only when their own mutation returns
through the feed. Identical live queries share one reactor. Each change is
judged against the query's where and search with the SDK's own
evaluator (the where module), so a record that stops matching leaves and
one that starts matching enters; a change whose text the evaluator cannot
read exactly as the platform does (a URL, a path, markup, a signed number)
makes the query read its window afresh instead.
const live = await db.collection("tasks", { spaceId }).live({ where: { status: "open" }, sort: "rank", limit: 50 });
const stop = live.subscribe((records, event) => render(records));
await live.put("task-9", { title: "Draft", status: "open", rank: 3 });
await live.delete("task-4");
await live.close();To open fast next time, keep what a projection held — live.snapshot() is
the committed records and the cursor they are current through — and hand it
back with resume. The projection draws those records at once, before the
collection's definition is registered or anything is read, and catches up
behind them: an unbounded query reads only what changed since (a cursor the
platform no longer holds falls back to a fresh snapshot); a bounded one
(sorted or limited) reads its window afresh and replaces what it drew.
const kept = await loadFromIndexedDb(`${app.user.id}:${app.installationId}:${app.releaseId}:tasks`);
const tasks = await db.collection("tasks").live({ resume: kept });
tasks.subscribe(() => saveToIndexedDb(key, tasks.snapshot()));Or let the projection keep it: with persist, its snapshot is kept on this
device — per person, installation, release and query (where, sort and
limit included) — and drawn from next time, offline too. It is written
when changes pause, at least every half minute while they keep coming, and
at once when the page hides or goes away or the projection closes. A device
keeps the 16 most recently opened snapshots of a person and installation,
none older than 30 days, so a query that names a day does not pile up.
const tasks = await db.collection("tasks").live({ persist: true });
const today = await db.collection("events").live({ where: { day }, sort: "startsAt", persist: true });db.forgetKept() lets go of what was kept — for a space the person left,
say. It resolves how many snapshots it forgot, and open projections among
them stop keeping:
await db.forgetKept({ spaceId }); // what was kept for one space (null: their own scope)
await db.forgetKept({ collection: "tasks" }); // for one collection
await db.forgetKept(); // everything this device keeps for them in this appA write to a record that already has one on its way waits behind it; a
later one replaces it (the latest value wins), and every replaced write
settles when the one that goes commits. A refused write rolls back, and
listeners hear rollback; one that fails retryably stays shown and is sent
again with backoff — at once when the browser comes back online.
Changing a record
update(id, fn) is read-modify-write: it reads the record, asks fn for
the next value (undefined leaves the record as it is) and writes it only
if nobody wrote in between — otherwise it reads again and asks again (five
times by default, { attempts }). A write that fails retryably is sent
again as the same request, so one update never applies fn twice. It
resolves with the record as written.
const reads = db.collection("reads");
await reads.update(spaceId, (current) => (current?.through >= through ? undefined : { through }));On a live query, update starts from what the projection shows — a write
still on its way included — and queues behind it.
Many records at once
putMany(records) writes many records, not as one unit: in order, in
transactions of at most 64. Calling it again with the same records is safe
— a chunk that already committed answers as it did, and a record that
already holds the value given counts as stored. The first refusal stops the
batch; the answer says what was stored.
const result = await operations.putMany(pending.map((op) => ({ id: op.id, value: op, expectedVersion: 0 })));
// { stored: [...ids], unstored: [...ids], chunks: [{ ids, transactionId, status, replayed, error }], error }
if (result.error?.retryable) retryLater(result.unstored);Writes that must arrive
db.outbox(name) keeps writes on this device until they reach the
platform — across a reload, a closed lid, a lost connection. An entry is an
ordered list of steps: uploads to a bucket, then the record that points at
them. Entries go one at a time, in the order they were queued. Everything an
entry carries — values, bytes — is copied when it is queued and each step's
idempotency key is fixed then (derived from the entry's id: use each id
once), so every try sends the identical request and nothing is delivered
twice.
import { db } from "@terminus-ai/app-sdk";
const outbox = await db.outbox("messages");
await outbox.enqueue({
id: message.id,
meta: { spaceId, message }, // what you draw the pending message from
steps: [
{ type: "upload", bucket: "attachments", spaceId, path: photoPath, blob: photo },
{ type: "upload", bucket: "attachments", spaceId, path: thumbPath, blob: thumbnail, optional: true },
{ type: "deliver", collection: "messages", id: message.id, value: message, spaceId, notify, mentions },
],
});
outbox.subscribe((entries, event) => render(entries)); // state: pending | sending | failed
await outbox.retry(id); // a failed entry, from the step that failed
await outbox.discard(id); // take it out, bytes and all
await outbox.flush(); // now, not at the next retrySteps are upload, put, delete, deliver and transact (the last two
ring with notify and mentions). A try that fails retryably is tried
again with backoff — at once when the browser comes back online — and the
entries behind it wait; one the platform refuses is failed, with its
lastError, and the queue moves on. An optional upload the platform
refuses is skipped. When an entry has gone, listeners hear sent with the
platform's answer to each step — a delivery's sequence and recipients, say.
An ended session stops the queue without failing anything: the next session
sends it. The outbox is kept in IndexedDB, per person, installation and
release; where the browser refuses storage it runs from memory, and
outbox.persistent is false. What an earlier release of the app queued goes
after an update like anything else — the person's writes, sent in order
with this release (its collection definitions, the entry's own fixed keys)
— and becomes failed only if this release refuses it; one the earlier
release had seen refused stays failed for the app to retry or discard.
Windows of the app — an updated one and an older one alike — share their
outbox and take turns: one sends at a time, and an entry another window sent
leaves the others with a gone event.
Spaces
A space is a shared place an app makes — a conversation, a channel, a shared
page. kind is "group" (the default) or "direct" (the creator and
exactly one other person); a space can name a parentId (another space of
the same app the creator is in) and carry meta, app-defined JSON of at
most 4096 bytes every member can read. Creating a space invites everyone it
names; an invitation is not a member until the invitee accepts it in the
trusted Terminus notification surface.
import { spaces } from "@terminus-ai/app-sdk";
const { space, invitations } = await spaces.create({
name: "#launch",
memberHandles: ["bob", "carol"],
parentId: orgSpaceId,
meta: { topic: "Launch week" },
});
const direct = await spaces.create({ kind: "direct", memberHandles: ["bob"] });
await spaces.update(space.id, { name: "#launch-crew", meta: { topic: "Shipped" } });
await spaces.invite(space.id, "dana"); // role "editor"; "admin" and "viewer" too
await spaces.updateMember(space.id, memberId, "viewer");
await spaces.removeMember(space.id, otherMemberId);
await spaces.delete(space.id); // the owner deletes the space and its app data, for everyoneA Space says what the signed-in person may do there —
capabilities.canSend, canInvite, canRemoveMembers, canManageRoles,
canUpdate, canLeave, canBlockPeer, canDelete — along with myRole
(owner, admin, editor or viewer), block state and muted. Render
those rather than recreating the permission matrix. Direct spaces refuse
group management; groups refuse peer blocking (spaces.blockPeer). A direct
space names who it is with as peer (a PublicUser): its other member, or,
until they answer, the person invited — null for a group, or once nobody
else is in it or invited — so a conversation list labels it without reading
its roster:
const title = (space) => (space.kind === "direct" ? space.peer?.displayName ?? "…" : space.name);Live views multiplex onto the page's one stream and re-read only what their own frames say changed:
const mine = await spaces.live(); // the spaces the person is in
mine.subscribe((list) => renderSidebar(list));
const roster = await spaces.liveMembers(space.id); // members + pending invitations of one space
roster.subscribe(({ members, invitations }) => renderRoster(members, invitations));
const cursors = await spaces.liveCursors(space.id); // delivery head + everyone's delivered/read cursors
cursors.subscribe(({ headSequence, members }) => renderReceipts(headSequence, members));
await cursors.update({ readThrough: cursors.snapshot.headSequence });refresh() on any of them reads everything again now — a roster both its
members and its invitations, whatever its frames said — and resolves with
the value from a read that began after the call.
A person can mute a space (iMessage's Hide Alerts) or mute every space they have not decided for; a mention of them still rings:
await notifications.mute(spaceId); // muted
await notifications.mute(spaceId, false); // rings
await notifications.mute(spaceId, null); // follows the default
await notifications.muteByDefault(true);Rooms and presence
A room is ephemeral coordination in one space: signals and presence. It
hears room_events of its own space only, and a page never hears its own
publishes. Presence states arrive with their frames and apply in place;
setPresence merges this page's rooms into one state per space and sends
it without reading first, coalescing a burst of updates:
import { rooms } from "@terminus-ai/app-sdk";
const room = await rooms.open("typing", { spaceId });
room.subscribe(({ payload, envelope }) => showTyping(envelope.from.userId, payload));
room.subscribePresence(({ online, states }) => renderCursors(states));
await room.setPresence({ editing: "task-1" });
await room.signal({ cursor: 42 }); // transient: live subscribers only
await room.close(); // clears this room's presenceA room with presence open in a space is how the platform knows a person is looking at it: a message there does not ring them while this page shows it. The SDK says which window each page is, re-says it when the page is hidden or shown, and says the window left when a room closes or the page goes away. A hidden page stops re-saying its presence, which lapses within a minute; shown again, it says it at once.
Durable streams
A durable stream is an ordered journal of app-defined entries. Every listener sees every entry exactly once and in journal order — its own appends included, which come back through the journal like everyone else's — so two members folding the same stream fold the same thing:
import { streams } from "@terminus-ai/app-sdk";
const ledger = await streams.open("kills", {
spaceId,
onEvent: (event) => (event.type === "reset" ? restore(event.snapshot) : apply(event.entry.operation)),
});
await ledger.append({ killer: me, victim }); // resolves once listeners have seen it
await ledger.checkpoint(currentScoreboard()); // through what this stream has deliveredIf an append commits but the subsequent read fails, it throws a non-retryable
TerminusError with code stream_catchup_failed and the committed receipt in
error.details.operation. Resume the reader with ledger.drain(); appending
again with a new id would create a second entry. A failed catch-up never
reports that listeners received the entry.
A checkpoint lets the platform compact what it covers; a reader behind it
receives reset with the snapshot and then only the entries after it.
Inside a listener, ledger.cursor is the cursor of the event it is
handling — the entry's own, or where a reset put the stream — so a
checkpoint taken there with the default through claims exactly what the
listener has folded.
Notifications
notifications.create notifies the signed-in person themself (never anyone
else); while they paused this app's notifications nothing is written and the
answer says paused. A replayed idempotencyKey answers the first answer.
import { notifications } from "@terminus-ai/app-sdk";
await notifications.create({ title: "Export ready", route: "/exports/1" }, { idempotencyKey: "export-1" });
let page = await notifications.list({ limit: 50 });
await notifications.markRead(page.notifications[0].id);
await notifications.pause(new Date(Date.now() + 86_400_000)); // at most 31 days; null resumesA notification opens its app on its route. When the app's window is already open it is not reloaded for that: the route arrives as an event, and an app that can show the place where it stands takes it:
import { routes } from "@terminus-ai/app-sdk";
routes.onRoute((route) => openConversation(new URL(route, location.origin).searchParams.get("space")));Automations
An app's declared automations run as durable jobs. jobs.wait answers the
moment the platform announces the job finished and polls only slowly as a
fallback; it shares its options (timeoutMs, pollIntervalMs, signal)
with services.jobs.wait:
import { jobs, lifecycle, schedules } from "@terminus-ai/app-sdk";
const queued = await jobs.run("send-reminders", { day: "2026-09-23" }, { idempotencyKey: "reminders-0923" });
const done = await jobs.wait(queued.id, { timeoutMs: 60_000 });
if (done.status === "failed") report(done.error);
const list = await schedules.list();
await schedules.setEnabled("morning", false);
const { installedDataVersion, targetDataVersion } = await lifecycle.status();The public web
net reaches public web resources through the platform's SSRF-guarded
proxy; the URLs may be runtime values. net.fetch answers text (a page, a
feed, a calendar) up to 512 KiB, or its start with partial — all a link
preview needs. net.image answers a raster image (PNG, JPEG, GIF, WebP or
AVIF, at most 5 MiB) as a Blob: an app frame cannot load another origin's
image, and should not, since every reader would be announced to it.
import { net } from "@terminus-ai/app-sdk";
const page = await net.fetch(url, { partial: true }); // { url, status, contentType, body, truncated }
const { blob } = await net.image(previewImageUrl(page.body));Connectors and services
A connector call runs with the person's own credential, which never reaches
the app. The body comes back as UTF-8 text (body) or base64
(bodyBase64); connectors.json parses a successful JSON answer:
import { connectors, services } from "@terminus-ai/app-sdk";
const repo = await connectors.json("github", "/repos/acme/app", { resourceGrantId });
const answer = await connectors.request("google-calendar", "/calendars/primary/events", { method: "POST", body: event, idempotencyKey });Services are developer-operated: the release names each @handle/slug and
operation it calls, and the provider receives a short-lived Terminus
identity, never the person's credentials. services.json parses a JSON
answer; the platform's own web and job services have typed helpers:
const created = await services.json("@acme/calendar", "create-event", { input: { title: "Launch" }, idempotencyKey });
const { results } = await services.search("@terminus/web-search", { query: "Rust HTTP clients", count: 5 });
const article = await services.fetch("@terminus/web-fetch", { url, representation: "markdown", render: "auto" });
const job = await services.executeCode("@team/code-execute", { runtime: "python", code: "print(2 + 2)" }, { idempotencyKey: "calc-1" });
const finished = await services.jobs.wait(job.jobId, { timeoutMs: 120_000 });Image generation, document conversion, bounded code execution and email
(generateImage, convertDocument, executeCode, sendEmail) submit
jobs; services.jobs.get, cancel, file and save inspect, cancel,
download and keep their results. wait also stops at reconciling: an
uncertain provider outcome must not be resubmitted. One logical invocation
is billed once even if a route tries two providers.
Capabilities, server code, egress and collect
import { collect, egress, horizontal, server } from "@terminus-ai/app-sdk";
const rendering = await horizontal.loadModule("rendering", 1); // a shared, pinned module
horizontal.require("external-previews", 1); // a policy-only capability: a marker, no I/O
const { entries } = await server.call("top", { n: 15 }); // the app's own server code
const upstream = await egress.request("sync-orders", { since }); // a published request template
const submitted = await collect.submit("feedback", { rating: 5 }, { idempotencyKey });
await collect.retract("feedback", submitted.id); // inside the undo window: gone without a traceserver.call runs one op of the app's server/ folder and answers what it
returned (an op the app calls answers within 10 s). The first
collect.submit on a channel needs the person's one-time consent: it
refuses with grant_required until they give it.
What a release is allowed to do
Named dependencies stay literal at the call site — connector slugs, service
addresses and operations, capability ids and versions, server ops, egress
templates, collect channels — and so do the calls that need a grant
(net.fetch, net.image, notifications.create). When a developer runs
terminus push, the CLI compiles the release's capabilities from those
calls (conformance/capability-calls.json lists every call and the grant it
compiles); the platform enforces that contract, and publishing happens on
the web. terminus.json never repeats what the code already says.
Logs for the app's maintainers go through log.debug/info/warn/error
(at most 60 a minute).
The raw bridge
Every entry point takes { terminus } to use a given bridge instead of the
page's own, and createClient() returns the raw bridge — a seam for tests
and harnesses, not an app API: its doors answer the same camelCase results
the namespaces above do. A harness outside a page builds one with
createBrowserBridge({ fetch, EventSource }): the bridge sends its
same-origin /_terminus/… paths through those.
Conformance and verification
conformance/ ships the runtime contract: doors.json (every door, field,
header, error and limit), app-host.json (what the app host forwards),
capability-calls.json (what each SDK call compiles into a release) and the
record, grammar and delivery vectors. The fingerprinted
conformance/manifest.json is the release boundary the backend, terminus
dev and the app host consume; changing a vector requires an intentional
manifest refresh and a coordinated consumer update.
The source is TypeScript under src/; npm run build emits dist/ (ESM +
declarations), and npm test builds first, then runs the suite against the
built output — the conformance vectors, an export-parity check between
dist/index.d.ts and the runtime module, the wire of every door, and
multi-writer scenarios for journals, rooms and live queries. Run both before
pushing code changes. Pull-request CI cancels superseded runs and skips the
suite for docs-only diffs while still completing its check.
npm run scenarios runs test/e2e/ and checks a host from the outside:
two members drive the SDK against it — no replay after a reconnect, the echo rule, recovery from
a compacted journal, replays that ring nobody, the cursor frontier, pause
and mute, two writers on one journal, spaces with meta and parents,
paging and write receipts — each namespaced by a run id and cleaned up
after. --fake runs them against an in-memory host (as npm test does);
--member alan=http://localhost:8868 --member bob=http://localhost:8869
against terminus dev --members 2; --help lists the rest.
Agent operations (SDK 0.0.3)
Apps can publish a typed interface that runs with the app window closed.
Norbert and the local terminus apps commands discover the same interface.
Keep app rules in shared modules, then call those rules from both the UI and
these operations. Reuse the app's existing SDK collection definitions and
version checks; do not expose a generic SQL, filesystem, or arbitrary HTTP tool.
import { db } from "@terminus-ai/app-sdk";
import { defineAgent } from "@terminus-ai/app-sdk/agent";
export const { agent, ops } = defineAgent({
list_notes: {
description: "Read one page of your personal notes.",
effect: "read",
input: {
type: "object",
properties: { after: { type: "string" } },
additionalProperties: false,
},
runtime: ["GET /collections/notes"],
async run(input, ctx) {
return db.collection("notes", { terminus: ctx.terminus })
.findPage({}, { after: input.after as string | undefined, limit: 25 });
},
},
});Bundle this entry as CommonJS into server/agent.js, with dependencies included
and browser assets excluded (publicDir: false in Vite). A small
server/main.js contains module.exports = require("./agent.js").
Run this build before terminus dev, validate, or push. The CLI compiles the
exported metadata into capabilities.server.agent; do not copy it into
terminus.json. Server source still obeys the platform's bounded server runtime,
entrypoint and total bundle limits. The four official apps provide full examples.
Each handler receives ctx.terminus, ctx.userId, ctx.spaceId and a stable
ctx.invocationId. The platform supplies this context; it is never accepted
from the operation's JSON input. Use invocationId for transaction/mutation IDs
and deterministic created IDs. Reads should paginate; writes should use exact
record versions or append-only operations. The platform durably claims every
invocation before execution and never repeats a mutation after an uncertain
failure. Clients can inspect the invocation status and then reconcile through
read operations.
effect is read, write, or send. Use send for actions that deliver content
or notify people. Each runtime entry is an HTTP method plus an app-runtime
path; * matches one path component. These are an execution ceiling: users must
also grant the named operation and its personal or selected-space scope. Every
request rechecks the grant, installation, release, membership and record policy.
Account, session, connector, arbitrary network and nested server-operation doors
are unavailable through this transport. App code receives no credentials.
Inputs use a bounded JSON Schema subset: objects, arrays, strings, booleans, numbers/integers, required fields, enums, length/item limits and numeric bounds. Unsupported keywords should not be used as validation guarantees. This first transport supports JSON collections, transactions and space operations; binary attachments and full external-service migrations require separate interfaces. Operations may return attachment references without transferring the bytes.
The browser runtime protocol stays at v1: existing browser door payloads are unchanged. This is an additive server capability and SDK subpath. It requires the matching backend, execution-host and CLI release; it does not make older published app releases automatically agent-accessible.
Platform data for apps (admin-data v1)
People an administrator gives access to can build their own apps on platform data: dashboards, charts and reports over the aggregates the admin console reads. An administrator opens Data access in the console (right after Overview), chooses Give access, finds the person and ticks what they may read. Access belongs to the person, never to an app:
- A read needs access for both the person using the app and the person who built it, of the kind the operation reads under. An app built by someone without access reads nothing, whoever uses it.
- An app that reads platform data is never public. Publish it Restricted (only you and the people you let in) or Unlisted (address only); publishing it public, or switching it to public, is refused, and the Creation page says why before you press Publish.
terminus devreads with your own access, so you can build against real numbers before the first publish. Draft Test windows never read platform data.
import { horizontal } from "@terminus-ai/app-sdk";
const overview = await horizontal.invoke("admin-data", 1, "overview", {
input: { days: 30 },
});
const page = await horizontal.invoke("admin-data", 1, "users", {
input: { limit: 50 },
});
const conversations = await horizontal.invoke("admin-data", 1, "conversations", {
input: { user_id: "<account UUID>" },
});
const transcript = await horizontal.invoke("admin-data", 1, "messages", {
input: { user_id: "<account UUID>", session_id: "<session UUID>" },
});Keep the capability, version, and operation literal at each call site so
terminus push compiles the exact operations into the release declaration.
Declaring an operation grants nothing by itself. No admin password, admin
token, or platform token belongs in app code.
| Operation | Access it reads under | Input and output |
| --- | --- | --- |
| overview | Analytics | days: 1–90, default 30. Returns display_timezone, aggregate users, usage, all Overview metrics (including revenue and costs), user_growth, retention, skill_growth, skills, search_usage, daily_usage, model_token_usage, key_source_token_usage, billing_trend, provider_balance_trend, and system_token_expense. These are the console’s aggregate datasets and series, suitable for charts and comparisons. No individual accounts, provider-account identifiers, credentials, or conversation content. |
| users | User activity | limit: 1–100, default 50; optional after UUID. Returns users (user_id, email, username, status, created_at) and next_after; pass it as after for the next page. Test accounts are excluded. |
| conversations | User activity | user_id UUID. Returns up to 50 recent non-empty chat_sessions with IDs, dates, status, surface and message counts. Excludes titles, search queries and message text. |
| messages | Conversation content | user_id and session_id UUIDs. Returns session_id, title, and up to 500 messages (seq, role, flattened text, created_at). The session must belong to the specified user. |
The three kinds are independent. Reading the directory and then opening a transcript needs both User activity and Conversation content, for you and for the app's builder. Every request rechecks both people's access, so a change reaches apps that are already open on their next request. All reads and access changes are audited. Responses are private and not cacheable; keep sensitive results in memory and clear them when access is refused.
Access never opens the admin console or allows any write. Anonymous or invalid sessions receive 401; missing access (on either side), undeclared operations and draft Test windows receive 403. This capability uses the existing v1 SDK transport and does not change the runtime protocol.
For a live SDK check, node test/e2e/admin-data.mjs takes ADMIN_DATA_API,
ADMIN_DATA_TOKEN, ADMIN_DATA_DENIED_TOKEN (two published-app session tokens:
the first from a person who, like the app's builder, has all three kinds of
access; the second from a person without access), ADMIN_DATA_USER_ID, and
ADMIN_DATA_SESSION_ID. It reads all four operations and verifies the other
account is refused, printing a JSON result without tokens or user content.
Hosted service handlers
@terminus-ai/app-sdk/service defines a server-side operation. Each invocation
gets a fresh SDK bridge, caller identity, cancellation signal, and (when declared)
a short-lived guarded browsing proxy. It never receives the publisher's session
or a connector secret.
import { defineService } from "@terminus-ai/app-sdk/service";
export default defineService({
echo: {
description: "Echo a message",
input: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
async run(input, ctx) { return { message: input.message, userId: ctx.userId }; },
},
});The Node host is serveService(service) from @terminus-ai/app-sdk/service/node.
It reads PORT and the platform-provided TERMINUS_RUNTIME_URL. Other hosts can
call the Fetch-compatible service.fetch(request, { runtimeUrl }) directly.
service.openapi is the generated contract used by the CLI.
Runtime access must be declared on the operation (runtime: ["GET /data/*"])
and explicitly delegated by the calling app with the same delegate option on
terminus.services.invoke. The backend rechecks the original app session and
its permissions on every callback. A direct catalog or agent call has identity
and declared network access, but no app's data context. Declaring a runtime door
alone does not grant access. Templates match one path segment per *.
Declare network: "public-web" and use await ctx.network.proxy() for guarded
browsing. The returned URL is a credential: keep it out of logs and responses.
Services use bounded request/response operations; the broker does not support
persistent event streams or nested service calls. Files and JSON responses are
supported, with an 8 MiB transport limit and at most 128 broker calls per invocation.
Updating delivered current state
An app can opt a personal collection into author-only fan-out updates:
const messages = db.collection("messages", {
spaceId,
ownership: "personal",
permissions: { update: "deny" },
deliveryUpdates: {
fields: ["text", "unsent"],
maxAgeSeconds: 900,
sealedField: "unsent",
sealWithinSeconds: 120,
},
});
const record = await messages.getRecord(id);
if (!record) throw new Error("Message no longer exists");
await messages.deliver(id, { ...record.value, text: "Edited" }, {
spaceId, expectedVersion: record.version,
});Positive expectedVersion means update; zero/omitted means first delivery.
The runtime checks original authorship, allowed fields, its creation clock and
matching replica versions. Updates reach only surviving original recipients who
have remained members. They never restore deleted copies, acquire new recipients
or ring notifications. A true sealed field permanently prevents further updates.
Ordinary put follows ordinary permissions; disable it when recipients must not
rewrite their own delivered copies. Wire definitions use delivery_updates.
A required single-record relation may name creatorField to bind a field in its
child to the referenced record's immutable platform creator. This enables child
permissions based on a channel creator without trusting a copied user ID. Parent
and child must share the owner/app/scope; create the parent before the child in a
transaction.
Generated stream operation and direct delivery mutation IDs use
t1_<Unix milliseconds>_<random> and accept retries for seven days (with at most
60 seconds future clock skew). Expired IDs are refused even after receipts are
removed. Persist the same ID while retrying; do not turn an expired retry into a
new write. Explicit caller IDs retain their permanent idempotency contract.
