App SDK

Last updated

@terminus-ai/app-sdk is how an app reaches everything outside its own bundle: the person using it, their data, the people they share with, files, services and payments. What the app is built on is declared once, in its schema, and Terminus carries out each call within what that declares. App code never handles anyone's sign-in or credentials.

On this page⌄

Install and start#

Apps made with terminus init app already depend on it and follow its newest release. To add it anywhere else, or to bring an app up to the newest release later:

npm install @terminus-ai/app-sdk@latest

terminus dev and terminus push say when a newer release is out, with the command that updates your app.

import { ready } from "@terminus-ai/app-sdk";
import app from "../schema";   // schema.ts: what the app is built on

const info = await ready();
if (info.guest) showSignInHint();   // a visitor who is not signed in
else greet(info.user.displayName);

await app.tasks.put("t1", { title: "Ship the docs" });

Importing has no side effects; the SDK connects on first use. Everything it returns is camelCase, and what you or other people wrote — record values, meta, payloads, a service's answer — comes back exactly as written. ready() resolves with who is using the app:

info.user
The person using it: id, handle, displayName, avatarUrl, publicProfileUrl. Never their email.
info.guest
true for a guest: someone who is not signed in, on an app that is open to guests (a setting beside its visibility and price). user is then null. A guest's own things work and stay on their device — records in personal collections, files, and storage buckets without a space — and so do the server operations the app opens to guests (The app's server), which is how a guest reaches the app's shared data. Everything else that needs an account — spaces, rooms, people, notifications, services, products — throws with the code guest. Ask for an account where a feature needs one, with session.signIn().
info.installationId, info.releaseId
The person's installation and the release it runs. Key anything you keep on the device by both, so an update never reads another release's copy. null for a guest.
info.iconUrl
The app's current icon.

session.signIn({ returnTo }) takes a guest — or someone whose session ended — through Terminus's sign-in (or sign-up) and back to returnTo on your app's own address, signed in. Nothing the app kept in the browser is lost, and the guest's own records, files and objects move into their account as they arrive; anything the account already has by the same name stays as it is. session.guestData.get() says what a guest left on this device, and session.guestData.move() or session.guestData.discard() settle it when someone else signs in on a shared browser.

import { ready, session } from "@terminus-ai/app-sdk";

const info = await ready();
createRoom.onclick = () => {
  if (info.guest) return session.signIn({ returnTo: "/?tab=rooms" });
  openCreateRoom();
};

session.signOut() signs the person out of the app on this device. From then on every call fails — with session_ended in the window that signed out, with unauthorized in the app's other windows — until they sign in again. An app open to guests opens as a guest after that. A page without a bundler can import @terminus-ai/app-sdk/auto instead, which installs window.TerminusSDK straight away.

The schema and its handles#

An app declares what it is built on once, in its schema: schema.ts at the root of its project (or schema.js, schema.mjs, or the file terminus.json names with schema). Its default export is schema({ … }), in up to six sections:

schema.ts

import { schema } from "@terminus-ai/app-sdk";

export default schema({
  collections: {
    tasks: { ownership: "personal", search: { fields: ["title"] }, indexes: ["status"] },
    board: { ownership: "space" },
    scores: { ownership: "app" },
  },
  services: {
    web: "@terminus/web",
    gmail: "@acme/gmail",
    calendar: { address: "@acme/calendar", reads: ["tasks"], writes: [] },
  },
  models: { writer: "@terminus/claude-sonnet-4-6" },
  products: { pro: { kind: "subscription" } },
  channels: { feedback: {} },
});

What schema() answers is the app's handles: one for each collection, service, model and product it declares, reached by that name. Import it wherever you use them — the window and server/:

src/main.js

import app from "../schema";

await app.tasks.put("t1", { title: "Ship", status: "open" });
const board = app.board.in(spaceId);                  // a space's collection, in one space
const page = await app.web.call("fetch", { url });
const inbox = await app.gmail.call("list-messages", { q: "from:alice" });
if (!(await app.pro.owned())) await app.pro.buy();
collections
Where the app keeps records. Each is a collection handle: see Collections.
services
The services it calls, by address: "@acme/calendar", or { address, reads, writes } to lend the service some of the app's collections for each call — a connector service, which works in each person's own account, among them. The handle calls any of the service's operations. See Services.
models
The models it calls, by address: writer: "@terminus/claude-sonnet-4-6". The handle chats with a chat model, embeds text with an embedding model, or starts the job of an image, music, speech, transcription or video model. See Models.
products
What it sells: { kind: "one_time" } or { kind: "subscription" }, priced on the web. See Products.
channels
Where people send the app's maintainers feedback or reports, each declared with {}. A channel makes no handle: maintainers.send names it. See Also in the SDK.
agents
The commands agents may call, each with a description, an effect and an input; a collection's own agents: "read" or "write" gives them its records. No handle. See Commands for agents.
  • A name is one handle: 1–64 lowercase letters, digits, - or _, starting with a letter, and no two sections may use the same one. A channel's name is at most 48 characters.
  • A release declares at most 32 collections, 32 services, 32 models, 32 products and 8 channels, and it uses exactly those: Terminus refuses a collection, service, model, product or channel the release does not declare. A service's or a model's address carries no version — calls reach its current release.
  • terminus push runs the file on its own and keeps what it declares with the release. It may build its declarations in code, but it reads no files and reaches no network. A declaration is fixed for the release: change it in the next one.
  • The same handles work in the window and in the app's server, each time as whoever the code runs for.

Errors and limits#

Every refusal is a TerminusError: the HTTP status, a code saying why, the details Terminus attached, 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 app.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 when the same call can succeed later: Terminus could not be reached (network, with status 0), is still working on the same request (still_pending), hit a rate limit (rate_limited and any 408, 425 or 429), or failed or was busy (a 5xx). Two 5xx answers are final all the same: not_implemented and unsupported_in_dev. So are a missing or ended session, a guest, and every other 4xx — retrying them repeats the refusal.

session_ended
The session is over: the person signed out of Terminus, it expired, or this window signed out with session.signOut(). Stop and say so — opening the app again signs them in.
unauthorized
No session at all — what the app's other windows get once it has signed out in one of them. Final too: opening the app again signs them in.
version_conflict
Someone wrote first: the version or checksum you expected is not the current one. Read again.
conflict
The request clashes with how things stand: it would break a unique field or a relation — for a delivery, in any recipient's copy — invite someone already in the space, or delete a space that still has spaces under it.
update_required
The data this write goes to has moved on to a newer version of your app: someone in the space updated, and its migration ran. This version can still read it. The desktop offers the person the update, and the outbox keeps what it holds until then. Final: retrying repeats it. See Migrations.
space_migrating
The space's data is moving to your newer version right now. The SDK waits and tries again by itself; a call that still meets it can be retried.
idempotency_conflict
A key already used for a different request — or repeated to a service another developer runs. See Retrying safely.
forbidden
Not allowed: the person's role in the space, a block, a collection's permissions, or something the release does not declare.
collection_undeclared
A collection schema.ts does not declare. Declare it there, under collections.
grant_required
A one-time consent is missing — the first maintainers.send on a channel.
unprocessable
A write broke one of the collection's validate rules; the rule's message comes with it.
refused
The app's own server refused the request: an operation threw OperationError. The message is its words, and details carries its own code and status. See The app's server.
cancelled, not_available
A product's buy(): the person closed the purchase sheet, or the product has no price yet. See Products.
payload_too_large, rate_limited, quota_exceeded
Too big, too often, or out of credits or storage.
unsupported_in_dev
terminus dev cannot make this call on your machine. Try it on Terminus, in Test.

ErrorCode names every code. LIMITS carries the numbers (Limits lists them) and RECORD_ID_PATTERN the shape of a record id. The SDK checks what it can before a request leaves the page, so an oversized batch or a bad id fails at the call.

Retrying safely#

A call that changes something carries a key that names it — mutationId on a record, transactionId on a transaction, idempotencyKey on a notification, a job, a submission, or a service call. Within Terminus the same key is the same request, as with an HTTP Idempotency-Key, so a retry that keeps its key is safe whatever became of the first try:

await app.messages.put(message.id, message, { mutationId: message.clientId });
await notifications.create({ title: "Export ready" }, { idempotencyKey: `export-${exportId}` });
  • A repeat answers what the first try answered. Nothing happens twice and nobody is rung twice — even when the write would now be refused, because its writer has become a viewer since, say.
  • The same key on a different request is idempotency_conflict. While the first is still being worked on, the answer is still_pending: try again later, with the same key.
  • Without a key of yours, each call is a new request. A key is 1–200 printable ASCII characters with no spaces (LIMITS.idempotencyKeyChars).

Services

A call that goes on to someone else's server repeats in its own way:

  • A service another developer runs receives your key in its own Idempotency-Key header. The same key again answers idempotency_conflict rather than the first answer, and never charges twice — look before you call again.
  • Terminus's own services answer a repeated key with the first answer: a job, the same job.

Collections#

A collection is a named set of records. Declare it under collections in the schema — see Declare your collections — and use it through its handle, app.tasks. A collection the release does not declare fails with collection_undeclared.

import app from "../schema";

await app.tasks.put("t1", { title: "Ship the docs", status: "open" });
const task = await app.tasks.get("t1");
const { records: open } = await app.tasks.find({ status: "open" }, { sort: "-priority", limit: 20 });
await app.tasks.update("t1", (task) => ({ ...task, status: "done" }));
await app.tasks.delete("t1");

const cards = app.cards.in(spaceId);   // a space's collection, in one space
await cards.put("c1", { title: "Launch" });

What a declaration holds

ownership
"personal", the default: each person's own — and .in(spaceId) their own copies inside one space, such as the messages delivered to them there. "space": one set shared by everyone in a space, used .in(spaceId). "app": one set for the whole app, shared by everyone who uses it — everyone reads it, and only the app's server writes it, unless permissions say otherwise.
search
How its records are found. fields: the fields search(text) matches — the whole record without it. desk: put the records in the desktop's search, { title: "title", subtitle: "status", route: "/tasks/{id}" } titling each result and opening the app at that route, {id} being the record's; without it the records stay out of the desktop's search.
indexes, unique
Fields to index, and fields no two records may share.
relations
Links to records in other collections, with onDelete: "cascade" or "restrict".
permissions
Who may read, create, update and delete — down to single fields. A rule is owner, space-member, space-editor, space-admin, record-creator, allow or deny; "server", only the app's own server; or { owns: "pro" }, only people who own that product — or, in a space, a space that owns it. anyOf, allOf and not combine rules.
validate
Rules every write must pass, checked as it commits — { rule: { forbidChange: ["ownerId"] }, message: "the owner is fixed" }. A write that breaks one fails with unprocessable.
jsonSchema
A JSON Schema every record is checked against as it is written. In TypeScript the handle is typed from it: app.tasks.get answers the record's own shape.

schema.ts

collections: {
  scores: { ownership: "app" },                     // everyone reads; only the server writes
  themes: { permissions: { create: { owns: "pro" }, update: { owns: "pro" } } },   // only buyers of pro
  posts: {
    ownership: "space",
    permissions: { create: "space-member", update: "record-creator", delete: { anyOf: ["record-creator", "space-admin"] } },
  },
},

Methods

put(id, value)
Create or replace a record. { expectedVersion } makes it conditional — 0 means it must not exist yet — and { mutationId } makes a retry safe.
get(id)
A record's value. getRecord(id) adds its version and who wrote it.
update(id, fn)
Read, change, write: fn gets the current value and returns the next, or undefined to leave it. If someone wrote in between, it reads again and asks again.
putMany(records)
Many records, in order, 64 at a time — not as one unit. Calling it again with the same records is safe, and the answer says which were stored.
delete(id)
Remove a record.
find(where, options)
One page of matches, { records, nextCursor, hasMore }, sorted by one field ("-field" for descending), up to 200. Pass nextCursor back as after for the next page.
scan(where)
Every match, a page at a time, as an async iterator — without holding them all in memory. A function instead of a where filters in the page.
search(text)
One page of matches in the declared search fields, in web-search syntax: launch plan, "exact phrase", -draft, docs or notes.
live(options)
A view that stays current. See Live queries.
deliver(id, value, { spaceId })
One record into every member's own copy at once. See Apps for more than one person.
in(spaceId)
The same collection in one space.

Queries

await app.tasks.find({ status: "open", assignee: info.user.id });          // equal to
await app.tasks.find({ due: { gte: "2026-09-01", lt: "2026-10-01" } });   // a range
await app.tasks.find({ status: { in: ["open", "blocked"] }, archived: { exists: false } });
await app.tasks.find({ tags: ["urgent"] });                               // an array that holds "urgent"
  • A where names top-level fields. A plain value matches by equality — an array or object value by containment — and an object of operators is a condition: eq, ne, gt, gte, lt, lte, in (1–32 values) and exists, at most 8 conditions in all. A field name is one key: "a.b" is the key named a.b, not a path.
  • A page holds at most 200 records. Unsorted queries go in id order and page on: find answers nextCursor, and scan walks every page. A sorted query answers one page, up to its limit, and cannot page on with after — sort for the top of a list, scan for all of it.
  • find and live take the same syntax as a search option. There a blank one — empty, or only spaces — is no search: the query answers as if it had none, so a search box can pass its text straight in.
  • A query Terminus would refuse — a bad field name, in without 1–32 values, more than 8 conditions, a bad sort — throws WhereGrammarError before the request leaves the page.
  • A record id is 1–128 letters, digits, dots, dashes or underscores, starting with a letter or digit, and a value is at most 128 KiB of JSON.

Live queries#

live() opens a view over a collection that stays current: other people's writes arrive as they commit. Writes made through it render at once, before Terminus confirms them, and one that is refused rolls back.

const live = await app.tasks.live({ where: { status: "open" }, sort: "rank" });
const stop = live.subscribe((records) => render(records));

await live.put("t2", { title: "Review", status: "open", rank: 3 });   // renders now
await live.update("t2", (task) => ({ ...task, rank: 1 }));

stop();
await live.close();

A write to a record whose last write is still on its way waits behind it, and the latest value wins.

Open on what the device kept

persist: true keeps what the view held on this device and opens on it next time: drawn at once — offline too — then caught up once Terminus answers. A query without sort or limit reads only what changed since; a sorted or limited one draws what was kept, then reads its window afresh.

const notes = await app.notes.live({ persist: true });
const today = await app.events.live({ where: { day }, sort: "startsAt", persist: true });

await db.clearLocal({ spaceId });   // the person left the space: let its copies go
  • Each query's copy is kept for the person and this release of the app. A device keeps the 16 queries a person opened most recently in an app, none older than 30 days, and lets go of the rest by itself — so a query that names a day does not pile up.
  • db.clearLocal({ spaceId }) lets go of what was kept for one space, db.clearLocal({ collection: "notes" }) of one collection's, and db.clearLocal() of everything the app kept for the person. Open views among them stop keeping.
  • live.snapshot() and live({ resume }) do the same with a copy you keep yourself.

Transactions#

Up to 64 writes can commit as one: every version check, uniqueness rule, relation and permission passes, or none of the writes happen. Each write names its collection by handle, and a stable transactionId makes a retry safe.

import { db } from "@terminus-ai/app-sdk";

await db.transact([
  app.tasks.putOperation("t3", { title: "Launch" }, { expectedVersion: 0 }),
  { action: "put", collection: app.audit, id: "t3-created", value: { taskId: "t3" }, expectedVersion: 0 },
], { transactionId: "create-t3" });

A write in a space can ring people exactly when it commits — never on a retry of it, and never without it. notify is the row they see; it rings every member who is not looking at the space right now. mentions (up to 8 handles) rings each person named with Terminus's own "mentioned you" row:

await db.transact([app.channel.putOperation(message.id, message, { expectedVersion: 0 })], {
  spaceId,
  transactionId: message.id,
  notify: { title: "Alice in #launch", body: message.text, route: `/?space=${spaceId}` },
  mentions: ["bob"],
});

deliver() rings the same way — see Apps for more than one person.

Writes that must arrive#

db.outbox(name) keeps writes on this device until they reach Terminus — across a reload, a closed lid, a lost connection. An entry is an ordered list of steps, such as a photo's upload and then the message that points at it, and entries go one at a time in the order they were queued. Every try sends the same requests, so nothing arrives twice.

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: "deliver", collection: app.messages, id: message.id, value: message, spaceId, notify },
  ],
});

outbox.subscribe((entries) => renderPending(entries));   // pending, sending or failed
await outbox.retry(message.id);                          // a failed entry, from its failed step
await outbox.discard(message.id);                        // or drop it, bytes and all
  • Steps are upload, put, delete, deliver and transact; a step's collection is a handle, or a declared collection's name. Give each entry an id you never reuse.
  • A try that can succeed later is tried again with backoff, and at once when the browser comes back online; the entries behind it wait. One Terminus refuses is failed, with its lastError, and the queue moves on. A session that has ended stops the queue without failing anything: the next session sends it.
  • The outbox is the person's, in this app, and its windows share it, one sending at a time. What was queued before an app update is sent after it — once, in order, under the keys it was queued with — and your migrations bring it up to date as it arrives; only a refusal by the new version marks it failed. One the old version already saw refused stays failed, for you to retry or discard.
  • A queue that meets update_required waits, keeping every entry, until the app is updated.
  • Where the browser keeps nothing, it lasts as long as the page, and outbox.persistent is false.

Spaces and people#

A space is a shared place your app makes — a conversation, a channel, a shared page. The app creates spaces, puts people in them or invites them by handle, and chooses which one it shows; it is never opened inside one. kind is "group", the default, or "direct": the creator and exactly one other person, who is in it at once — anyone can message anyone.

import { people, spaces } from "@terminus-ai/app-sdk";

const [bob] = await people.search("bo");  // the person's contacts come first
const { space: direct, created } = await spaces.create({ kind: "direct", memberHandles: [bob.handle] });

const { space } = await spaces.create({
  name: "#launch",
  memberHandles: [bob.handle, "carol"],
  add: true,                       // put them in at once; leave it out to invite them
  parentId: team.id,               // optional: another space of this app
  meta: { topic: "Launch week" },  // optional: any JSON up to 4096 bytes
});

await spaces.update(space.id, { meta: { topic: "Shipped" } });
await spaces.members.add(space.id, dana.id, "viewer");  // or ask: spaces.members.invite(space.id, "dana")
await spaces.delete(space.id);     // the owner only: gone, data and all, for everyone
  • Roles are owner (the creator), admin (manages members), editor, and viewer, who reads but writes nothing. spaces.members.add and invite give editor unless told otherwise, and spaces.members.update moves someone between admin, editor and viewer; remove takes them out.
  • Every Space says what the current person may do there — capabilities.canSend, canInvite, canUpdate, canDelete and the rest — beside myRole and muted. Render those rather than working permissions out yourself. meta and parentId are readable by every member.
  • A direct space puts its person in at once, and opening one with someone who already shares one with you — under the same parentId, or none — answers that one (created: false). A group invites unless you add: an invitation is not a member — the invitee accepts or declines in Terminus's own notifications, and an app can invite, but never consent for anyone.
  • Someone put in a space knows it once they write in it, or keep whoever added them as a contact. space.known and space.addedBy say which spaces the person knows and who added them: file the unknown ones apart, and offer to keep the sender as a contact.
  • Direct spaces refuse group management. Blocking is between two people everywhere on Terminus: people.blocked.add(id), people.blocked.remove(id) and people.blocked.list() (most recent first, each with blockedAt). Neither puts the other in a space while it lasts, and a direct space between them carries nothing.
  • A direct space names who it is with as peer: the other person, and null for a group. A list of conversations labels itself from it, without reading any roster.

Live lists

const label = (space) => (space.kind === "direct" ? space.peer?.displayName ?? "Just you" : space.name);

const mine = await spaces.live();                    // the spaces the person is in
mine.subscribe((list) => renderSidebar(list.map(label)));

const roster = await spaces.members.live(space.id);   // members and pending invitations
roster.subscribe(({ members, invitations }) => renderRoster(members, invitations));
await roster.refresh();                              // read both again, now

const receipts = await spaces.receipts.live(space.id);  // how far each member has received and read
receipts.subscribe(({ headSequence, members }) => renderReceipts(headSequence, members));
await receipts.update({ readThrough: receipts.snapshot.headSequence });

A space the person makes turns up in spaces.live() in every window of theirs by itself, as does one they join or are put in. refresh() on any live list reads it all again now — a roster, its members and its invitations both — and resolves with what that read found.

Contacts

import { notifications, people } from "@terminus-ai/app-sdk";

const mine = await people.contacts.live();             // the person's contacts, in name order
mine.subscribe((list) => renderPeople(list));
await people.contacts.add(bob.id);                     // private: Bob is never told
await people.contacts.remove(bob.id);

await notifications.settings.update({ muteUnknownSenders: true }); // spaces the person doesn't know stop ringing them
  • Contacts are the person's, not your app's: one list, the same in every app, and changed from any of them. People search puts them first.
  • A space a contact put the person in is known to them, so it rings even while they mute unknown senders.
  • A person can put at most 100 people who don't keep them as a contact into spaces a day; past that, rate_limited.

A name and photo for your app

import { people } from "@terminus-ai/app-sdk";

const me = await people.me.live();                    // how the person appears in your app, kept current
me.subscribe((current) => renderMe(current?.user));
await people.me.update({ displayName: "Captain Kate" }); // null or "" shows their Terminus name
await people.me.setPhoto(blob);                       // a JPEG, PNG or WebP image, at most 512 KiB
await people.me.removePhoto();                        // their Terminus photo again
  • A person can give your app a name and a photo of its own. Everyone they meet in your app sees them — every person the SDK hands you (ready()'s user, lookups and searches, contacts, spaces, members) wears them — while their id, @handle and profile page never change, and other apps show their Terminus name and photo.
  • people.me.get() answers { displayName, hasPhoto, user, account }: what they set, themself as others see them, and their Terminus name and photo for a "use my Terminus name and photo" choice.
  • A name is at most 64 characters, and a person can change their name and photo 30 times an hour in one app. Lists in other people's windows follow by themselves.

Files and storage#

Two places hold bytes. files are the person's own files — documents, exports — which they can see and take with them. A bucket holds an app's blobs — pictures, attachments — and a bucket opened with a space is the whole space's.

import { files } from "@terminus-ai/app-sdk";

const receipt = await files.write("exports/tasks.csv", csv, { mediaType: "text/csv" });
const text = await files.readText("exports/tasks.csv");
const { files: listed, nextCursor } = await files.list({ prefix: "exports/", limit: 50 });

const shared = files.bucket("attachments", { spaceId });
await shared.write("photos/1.png", blob);            // a Blob keeps its own type
const photo = await shared.read("photos/1.png");     // a Blob
await shared.delete("photos/1.png");
  • A file is at most 12 MiB. It lands in the private zone, the app's own, unless you pass zone: "output" for deliverables the person keeps. For a safe edit, read files.info(path) first and write with expectedSha256: stat.sha256 — or "missing" for a new file — and a change in between fails with version_conflict. files.delete(path, { expectedSha256 }) removes only the version you read.
  • Files are the caller's alone, even with a spaceId: nobody else in the space reads them. What members share belongs in a space collection, a space bucket or a durable stream.
  • Nothing declares a bucket. Opened with { spaceId } it belongs to that space: every member reads it, and a file stays when whoever wrote it leaves. Without one it is the person's own. An object is at most 8 MiB and keeps the content type it was written with.
  • Lists come a page at a time: pass nextCursor back as after for the next.

A bucket's write and delete take expectedSha256 too, so people sharing a space cannot replace or remove each other's files unawares. "missing" writes only where nothing is yet; any other value must be the sha256 a receipt or a listing gave. A mismatch fails with version_conflict, and a delete cannot expect "missing" — it needs something to delete — so that is bad_request.

const receipt = await shared.write(path, photo, { expectedSha256: "missing" });   // only if nothing is there
await shared.delete(path, { expectedSha256: receipt.sha256 });                    // only if it is still that one

Showing files

files.url(path) shows a file in an <img>, a <video> or a download link, and a bucket's url(path) an object. The bytes are read once a session however many views show them: url is set at once when they are already here, and ready resolves when they arrive. Call release() when the view goes. thumbnail(path, { maxEdge }) shows a smaller copy of a picture, made in this browser — 640 pixels on its longest edge and WebP unless you say.

const cover = files.url("exports/cover.png");
img.src = cover.url ?? (await cover.ready);
cover.release();                                     // when the view goes away

const small = shared.thumbnail("photos/1.png", { maxEdge: 320 });
thumb.src = small.url ?? (await small.ready);

What your app keeps

import { db } from "@terminus-ai/app-sdk";

const usage = await db.usage();
// { bytes, fileCount, fileBytes, filesByType, recordCount, recordBytes, exportable, updatedAt }
const { image, video, audio, other } = usage.filesByType; // each { count, bytes }
if (usage.exportable) download(await db.export()); // a Blob: the records as one database file

bytes is the figure Settings › Storage shows for your app: the person's files under your app's own paths and your app's records for them. filesByType splits those files by the type each was written with — pictures, videos, audio and everything else — and the four add up to fileCount and fileBytes. db.export() is a copy of the records as one database file, built on request.

Rooms and presence#

A room is for what matters only now — typing, cursors, who is here — in one space. Nothing in it is stored.

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 });   // to whoever is listening right now
await room.close();                  // clears this page's presence

What you send is never sent back to you. A signal reaches the other people in the space, but none of the sender's own windows — not even another tab of theirs — and a presence change is not reported to the person who made it. So update your own screen as you send, and test with two people. Changes to records, files and streams are different: every window that can read them hears about them, the writer's own included.

  • Nothing a room carries is kept: a page that was offline never receives what it missed. Put anything that must last in records or a stream.
  • Clearing presence — setPresence(null), or closing the room — is a leave, and the others see the person go. So is a person's last window closing.
  • While a visible window of theirs has a presence state in a space, Terminus counts the person as looking at it, and a message there does not ring them. Set presence when a space is on screen and clear it when it is not.
  • room.signal is live-only. room.publish(payload, { mentions, notify }) is the kind that can ring people.
  • A person's presence in a space is at most 4 KiB, merged across the rooms their page has open there. An event carries at most 32 KiB; each person may send 240 events and 1,200 signals a minute in a space.

Durable streams#

streams.open is an ordered log of your app's own entries — a match's events, an activity feed. Every listener sees every entry exactly once and in order, its own appends included, so two people folding the same stream reach the same result.

import { streams } from "@terminus-ai/app-sdk";

const kills = await streams.open("kills", {
  spaceId,
  onEvent: (event) => (event.type === "reset" ? restore(event.snapshot) : apply(event.entry.operation)),
});
await kills.append({ killer: me, victim });
await kills.checkpoint(scoreboard());   // what everything so far adds up to

A reader who is behind a checkpoint gets reset with its snapshot, then only the entries after it.

Services#

A service is an operation someone else runs — a search, a page read from the web, a program, or another developer's API — or files the app's pages load. Declare each service the app uses under services in the schema, by address, and call it through its handle, from the window or from the app's server. Quick operations answer in the call; slow ones — document conversion — answer with a job you wait for. Pictures, music, speech, transcripts, vectors and video are models.

schema.ts

export default schema({
  services: {
    web: "@terminus/web",
    convert: "@acme/convert",
    three: "@terminus/three",
    calendar: { address: "@acme/calendar", reads: ["events"], writes: ["events"] },
  },
  collections: { events: {} },
});
import app from "../schema";

const { results } = await app.web.call("search", { query: "rust http clients", count: 5 });
const page = await app.web.call("fetch", { url });   // any public page
const event = await app.calendar.call("create-event", { title: "Launch" }, { idempotencyKey: "launch-1" });

const job = await app.convert.call("to-pdf", { path: "home/documents/plan.docx" }, { idempotencyKey: "plan-1" });
const done = await job.wait();   // succeeded, failed, cancelled — or reconciling: look before trying again

await app.three.style("controls.css");                      // a stylesheet of its files, on the page
const THREE = await app.three.import();                     // its module: index.js
call(operation, input, options)
The operation's JSON, exactly as the service sent it — or, for an operation that runs as a job, the job. A service declared once can be called on any of its operations.
request(operation, input, options)
The whole response — status, contentType, and the body as text in body or base64 in bodyBase64 — for an operation that returns a file.
options
idempotencyKey, a query object, and bytes — a Blob or a buffer — for an operation that takes a file instead of an input.
url(path), style(path), import(path)
For a service that publishes files for pages: a file's address (index.js unless you say), a stylesheet added to the page (index.css unless you say; resolves once it loads), and a module imported. A page can also import it by address — import * as THREE from "@terminus/three" — at the version the app was published against. See Files for pages.
a job
wait() resolves once it succeeded, failed, was cancelled, or is reconciling — an outcome nobody is sure of yet, never to be sent again blindly. get(), cancel(), file(id) for one of its result files, and save(id, path) to keep one in the person's files; results are temporary. app.convert.job(id) finds a job again after a reload.
  • reads and writes name the app's collections the service may read and write, as the person calling, for each call — a calendar service keeping the app's events, say. It reaches nothing else of the app's.
  • The person using the app pays, from their credits: quota_exceeded when they run short. A call from an operation a guest runs, or from the app's own schedule, is paid by the app's owner, within the app's daily budget.
  • An operation may limit the calls one person makes of it a day: past that, it answers rate_limited until midnight UTC.
  • To read any public page a person names, call @terminus/web's fetch: the app's server reaches only the hosts its operations name.
  • Sent again with the same idempotencyKey and input, a job answers the same job, while any other call answers idempotency_conflict; neither runs or charges twice (Retrying safely).

Connector services

A connector service works in each person's own account — Gmail, GitHub, Notion — and is declared and called like any other, with no prompt: Terminus sends the person's own sign-in or key to the service's API, and the app never sees it. A person who has not connected it is answered not_connected (403) and nothing is sent; its details name the service (service_id, address, name), how it is connected (connect: sign_in or key) and why (reason: not_connected, or reconnect when the provider withdrew the grant). The SDK says so once a service per page, as a terminus:not-connected event on window. In a window on the desk, Terminus shows the person how to connect it, and once they have, the page hears terminus:connected: try the call again. On a page of its own, the app shows a bar that opens Terminus to connect it.

// schema.ts: services: { gmail: "@acme/gmail" }
const inbox = await app.gmail.call("list-messages", { q: "from:alice" }).catch((error) => {
  if (error.code === "not_connected") return null;   // Terminus asks the person to connect it
  throw error;
});
addEventListener("terminus:connected", (event) => {
  if (event.detail.address === app.gmail.address) reload();
});

More, and what there is to call, on the Services page — and how to make a connector of your own, under Connectors.

Models#

A model talks, draws pictures, writes music, reads text aloud, writes down what is said, turns text into vectors or makes videos, at an address like a service's. Declare each model the app calls under models in the schema, by address, and call it through its handle, from the window or from the app's server. A call runs the model's current release and bills the person at its prices; a failed call costs nothing. Publishing one, and its prices, on the Models page.

schema.ts

export default schema({
  models: {
    writer: "@terminus/claude-sonnet-4-6",
    art: "@terminus/gpt-image-2",
    beats: "@acme/beats",
    voice: "@terminus/tts-1",
    ears: "@terminus/whisper-1",
    vectors: "@terminus/text-embedding-3-small",
    clip: "@acme/film",
  },
});
import app from "../schema";

const answer = await app.writer.chat({
  messages: [{ role: "system", content: "Answer in French." }, { role: "user", content: "Hello" }],
  max_tokens: 200,
});
const text = answer.choices[0].message.content;

const drawing = await app.art.generate({ prompt: "A lighthouse at dusk", count: 2 }, { idempotencyKey: "cover-1" });
const drawn = await drawing.wait();
const song = await app.beats.compose({ prompt: "lo-fi beats", seconds: 30, instrumental: true });

const spoken = await app.voice.speak({ text: "Welcome back", voice: "nova", speed: 1.1 });
const heard = await (await app.ears.transcribe({ audio: recording, language: "en" })).wait();
const words = heard.result.text;
const { data } = await app.vectors.embed(["first note", "second note"], { dimensions: 512 });
const film = await app.clip.generate({ prompt: "Waves at dawn", seconds: 8, size: "1280x720" });
chat(request, options)
A chat model's completion in OpenAI's Chat Completions format, exactly as the model wrote it — choices[0].message, its tool_calls, and the usage the call was billed by. It takes messages, tools and tool_choice, max_tokens (never more than the model writes) and, for a model that reasons, reasoning_effort; a model that reads pictures takes them as image_url parts of a message.
generate(input, options)
An image model's job: prompt, count (1 to 10, each picture billed), size ("1024x1024", or "auto"), quality, transparent, and the pictures to draw from — source, the one to edit, and references — each a Blob, bytes or base64. Or a video model's: prompt, seconds (1 to 60, 8 unless said, each billed), size ("1280x720", or "auto") and a source picture to start from; a video takes up to 30 minutes.
compose(input, options)
A music model's job: prompt, seconds and instrumental.
speak(input, options)
A speech model's job: text (at most 10,000 characters, each billed), voice (one of the model's, its first unless said), speed (0.25 to 4), instructions — a tone, an accent, a pace — and format (mp3 unless said; wav, opus, aac or flac). Its file is the audio.
transcribe(input, options)
A transcription model's job: audio — MP3, WAV, FLAC, Ogg, M4A or WebM, at most 16 MiB, a Blob, bytes or base64, billed by its length — language ("en", "fr") and prompt, words to spell right. Its result is { text, seconds, language }.
embed(input, options)
An embedding model's vectors, in the call, in OpenAI's Embeddings format — data[i].embedding for each text, in order, and the usage the call was billed by. It takes a text or a list of up to 512, none empty, and dimensions: fewer than the model's own, where it shortens its vectors.
options
idempotencyKey: sent again, a job answers the same job and never charges twice; a chat call answers idempotency_conflict, since its answer is not kept.

A picture, a song, speech, a transcript or a video is a job like a service's — wait(), file(id), save(id, path), and app.art.job(id) to find it again after a reload. In the app's server, ctx.models.call(address, input) calls a model its operation lists in models; while it runs for nobody, the app's owner pays within the app's daily budget, and a job comes back finished, its files in the answer and a transcript's words in its result. An embedding model answers its vectors, as a chat model answers its completion.

Products#

What the app sells — a Pro plan, a pack of themes — is a product. Declare it under products in the schema as { kind: "subscription" } or { kind: "one_time" }, and price it on the web. Terminus sells it with its own purchase sheet.

schema.ts

export default schema({
  products: { pro: { kind: "subscription" } },
  collections: {
    themes: { permissions: { create: { owns: "pro" }, update: { owns: "pro" } } },   // only buyers write
  },
});

src/main.js

import app from "../schema";

proFeatures.hidden = !(await app.pro.owned());

upgrade.onclick = async () => {   // from a click: the sheet opens in a window of its own
  try {
    await app.pro.buy();
    proFeatures.hidden = false;
  } catch (error) {
    if (error.code !== "cancelled") throw error;   // they closed the sheet
  }
};
owned(options)
Whether the person owns it — or, with { spaceId }, whether that space does. It shapes the interface.
info(options)
{ id, kind, price, periodDays, owned, expiresAt, renews, spaceId }: its price in credits (null until you set one), a subscription's period in days, and until when what is owned lasts.
buy(options)
Opens Terminus's own purchase sheet, which the app never draws, and resolves once the product is owned. Call it from a click. It throws cancelled when the person closes the sheet, and not_available while the product has no price. buy({ spaceId }) buys it for a space, which takes one of its admins.
  • A product has no price until you set one — on the app's page in Creations, under Products, once a release that declares it is published: a price in credits and, for a subscription, its period. Prices change there without a release.
  • What only buyers get is kept by rules, not by the interface: { owns: "pro" } in a collection's permissions is checked on every write, and owned() asked in the app's server cannot be changed by the page.
  • Terminus charges the buyer's credits and pays you your share. A subscription renews each period until the person cancels it in System Settings, and stays owned until the period they paid for ends.

The app's server#

Code the browser cannot run lives in the app's server: server/index.ts, whose default export is defineService({ … }) from @terminus-ai/app-sdk/service, one key per operation. terminus push builds it with the app, and Terminus runs it — a fresh copy for each call. The app's server walks through it; this is the reference.

server/index.ts

import { defineService, OperationError } from "@terminus-ai/app-sdk/service";
import app from "../schema";

export default defineService({
  submit: {
    description: "Keep a player's best score",
    input: { type: "object", properties: { score: { type: "integer", minimum: 0 } }, required: ["score"] },
    async run({ score }, ctx) {
      if (ctx.caller.kind !== "person") throw new OperationError("forbidden", "Sign in to keep a score");
      await app.scores.update(ctx.caller.userId, (current) => (current && current.best >= score ? undefined : { best: score }));
      return { saved: true };
    },
  },
});

src/main.js

import { server } from "@terminus-ai/app-sdk";

const { saved } = await server.call("submit", { score: 42 });

An operation

run(input, ctx)
The operation itself. What it returns is the answer, as JSON.
description
What it does, in a sentence.
input
A JSON Schema the input is checked against before run: an input it refuses is bad_request, and nothing runs.
timeoutSeconds
How long it may run: 10 seconds unless you say, up to 120. Past 10, only background work runs it — a job, a trigger, or its own schedule.
memoryMb
The memory it may use: 16 to 512 MB, 128 unless you say.
network
The hosts it fetches from, by name: api.example.com, or *.example.com for any name under it. fetch works as in a page, over HTTPS. An app's server never reaches the whole web.
secrets
The secrets it reads, by name — WEATHER_KEY — with ctx.secrets.get(name). Their values are set on the web, never in your files. See Secrets.
schedule
The app's own schedule for it: { every, at, onDay, timezone } — every being "15m", "hour", "day", a weekday or "month" — or { cron, timezone }, UTC unless timezone names a zone. It runs as the app. See Work in the background.
guests
true lets someone who is not signed in call it from the window, on an app open to guests. An operation with a schedule cannot also be open to guests.

Inside run

ctx.caller
Who the call is for: { kind, userId }, kind being person, guest, or app for the operation's own schedule. Key what a person owns on userId, never on the input.
ctx.trigger
What started it: call (a window), job, trigger (a person's schedule, webhook or watch), schedule (the app's own) or agent (a command an agent called).
ctx.secrets.get(name)
A secret the operation names, as it was last set.
ctx.cache
get(key), set(key, value, ttlSeconds) and delete(key): JSON values any later call of the same release reads, kept an hour unless you say, a week at most.
ctx.invocationId
The call's own id, as the app's log shows it.
ctx.spaceId
The space an agent's command works in; null otherwise.
  • The app's handles and the SDK work inside run as they do in the window, as whoever the call is for: app.scores.put(…) writes as that person, and a write from here passes the "server" rule. notifications.create tells the person the call runs for.
  • Throw new OperationError(code, message) to refuse in your own words: the window's server.call rejects with refused, your message, and your code and status in details. Any other error fails the call — upstream_failed in the window — and its message is kept in the app's log, with what console.log wrote.
  • Code at the top of the module runs once, when the server is built, not on each call.
  • An app's server has at most 32 operations.

Calling it from the window

server.call(op, input) runs one operation and answers what run returned. Its input is JSON, at most 256 KiB, and it waits at most 10 seconds. A refusal carries its code: not_found for an operation the app does not have, bad_request for an input the operation's schema refuses or an operation only background work runs, deadline_exceeded past ten seconds, refused when the operation refused with OperationError (its own code in details.code), upstream_failed when the operation failed, and server_unavailable when the app's server cannot run right now. A signed-out visitor, on an app open to guests, may call the operations open to guests this way; every other call of theirs needs them to sign in.

Commands for agents#

What agents may use is declared in the schema: a collection's agents word gives them its records, and the agents section lists the commands they may call. Tools for agents is the guide; this is the reference.

schema.ts

import { schema, t } from "@terminus-ai/app-sdk";

export default schema({
  collections: {
    messages: { ownership: "space", search: { fields: ["body"] }, agents: "read" },
  },
  agents: {
    sendMessage: {
      description: "Send a message in the open conversation.",
      effect: "send",
      input: { body: t.text() },
    },
  },
});
agents, on a collection
"read" or "write". Read gives agents list, get and — when the collection declares search fields — search tools over its records; write adds create, update and delete. Without it, agents never see the collection.
agents, the section
The commands, by key: 1–64 letters, digits, - or _, starting with a lowercase letter, and at most 64 of them. Agents call each by its key in snake case — sendMessage is send_message — so no two may come out the same, or the same as a data tool.
description
What the command does, for agents to choose by: 1–1,024 characters.
effect
read, write or send: what it may do. A command named after a server operation runs there, held to its effect; any other runs in the app's window in the Terminus desktop.
input
Optional: fields made with t, each required unless .optional(), or a whole JSON Schema with type: "object" — at most 16 KiB. Without one, a window command takes nothing, and a server command takes its operation's input.

commands(map)

A window hands over its window commands once, at startup: the functions it already uses, under the keys the schema declares them by.

import { commands } from "@terminus-ai/app-sdk";

const stop = commands({ sendMessage, createChannel });
// stop() takes them back
  • Each key must be declared under agents; anything else is a TypeError. A key that names a server operation is allowed, and still runs on the server.
  • In the Terminus desktop, each command is offered to the agents the person allows, with its declared description and input. A call's input is checked against the declaration first — a bad one is refused with bad_request, and the function never runs — then the function runs with the input object, and what it returns is the answer.
  • Throw to refuse: the agent reads the error's message, with its code (not_found, say) when it has one, else refused.
  • Anywhere else — a browser, a phone — commands() registers nothing, throws nothing, and returns a function that does nothing.

t

Input fields, the same in every app: t.id(), t.name(), t.text(max) (never empty), t.string(max), t.bool(), t.int(min, max), t.number(min, max), t.choice(...values), t.list(item, max), t.object({...}), t.date() (YYYY-MM-DD), t.time() (HH:mm), t.version(), t.query(), and ...t.page() for after and limit. Each is required unless .optional().

Jobs and triggers#

Work that runs while nobody has the app open is one of its server operations, run by a job or by a schedule, webhook or watch the app's code makes for the person — or by the operation's own schedule, for the whole app. See Work in the background. jobs.wait resolves the moment a job finishes.

import { jobs, triggers } from "@terminus-ai/app-sdk";

const job = await jobs.run("remind", { title: "Stand-up" }, { idempotencyKey: "standup-0923" });
const finished = await jobs.wait(job.id, { timeoutMs: 60_000 });
if (finished.status === "failed") report(finished.error);

await triggers.schedule("remind", "0 9 * * *", { name: "mornings", timezone: "user", input: { title: "Stand-up" } });
await triggers.setEnabled("mornings", false);
const all = await triggers.list();

const { url } = await triggers.webhook("on_payment");   // shown once
await triggers.rotate("on_payment");                     // a new URL; the old one stops

await triggers.watch("price_moved", pageUrl, { pattern: "\\$[0-9.]+", condition: { changedByPct: 2 } });
await triggers.delete("price_moved");
  • jobs.run runs the operation once, now, with its whole timeoutSeconds; a job's result is what the operation returned. Its input is a JSON object of at most 256 KiB, and a repeated idempotencyKey answers the first job.
  • A schedule takes a five-field cron — each field *, */N or one number — and a timezone: an IANA name, or "user" for the person's own. A webhook's operation gets { webhook, delivery_id, delivery_key, received_at, payload }, and a watch's { event, input } when what its pattern matches changes.
  • A person's installation has at most 64 schedules, 16 webhooks and 8 watches. Each runs its operation as that person.

Notifications#

import { notifications } from "@terminus-ai/app-sdk";

await notifications.create({ title: "Export ready", body: "3 files", route: "/?view=exports" });
const { notifications: inbox, nextCursor } = await notifications.list({ limit: 50 });
await notifications.markRead(inbox[0].id);
  • notifications.create notifies the signed-in person themself, never anyone else; to reach other people, ring them with a write (Transactions). Calling it is all the permission it needs: the schema and terminus.json say nothing about notifications. In the app's server it tells the person the call runs for.
  • While the person has paused this app's notifications nothing is sent, and the answer says paused. notifications.settings.update({ pausedUntil }) pauses them for up to 31 days, and notifications.mute(spaceId) silences one space; a mention still rings. notifications.settings.get() answers what the person chose.
  • The person also decides, app by app, whether notifications reach their desk (System Settings › Notifications). With your app switched off there, notifications.create still answers created and the notification stays in your app's own notifications.list; the desk just does not show it.
  • An idempotencyKey makes a retry answer with the first notification instead of sending a second.

A notification's route is a path in your app: where opening it lands. An app that starts there reads the route from location, like any address. An app that is already open is not started again — the route arrives as a terminus:route event instead, which routes.listen wraps. Take it and the app moves there itself, without reloading; an app that does not take it is reloaded at the route.

import { routes } from "@terminus-ai/app-sdk";

routes.listen((route) => {
  // route is "/?view=exports"
  showView(new URL(route, location.href).searchParams.get("view"));
});

Without the SDK, listen for terminus:route on window, read event.detail.route, and call event.preventDefault() to keep the page.

Logs#

An app has one log, which its maintainers read with terminus logs <app> (--level error for the errors alone).

import { log } from "@terminus-ai/app-sdk";

await log.info("exported", { count: 3 });
await log.error("sync failed", { spaceId, code: error.code });
  • It holds the window's log.debug, log.info, log.warn and log.error lines — a message of up to 2,000 characters and a detail of up to 64 KiB, 60 lines a minute — and every error the window did not catch, which Terminus catches for you.
  • It also holds each run of a server operation — from a window, a job, a trigger, a schedule or an agent's command — with how it ended and what it wrote with console.log, and each service call the app makes.
  • Entries are kept for 7 days, and errors for 30. A person appears in them under a stand-in name that means something in your app's log alone, never as their account.

Also in the SDK#

maintainers.send(channel, payload)
Send something the person wrote — feedback, a report — to the app's maintainers, on a channel the schema declares under channels: at most 64 KiB, and 100 a day per person and channel. The person agrees once per channel, and until then it fails with grant_required. It is delivered after a two-minute undo window, and maintainers.retract(channel, id) inside it takes it back without a trace.
people.lookup(handle), people.search(text)
One person's public profile by handle, or up to 8 matches for a recipient picker (a query of at least two characters), the person's contacts first.
session.signOut()
Sign the person out of this app on this device. See Install and start.
document.title, navigator.setAppBadge(n), navigator.share(data)
The window's title, the count on the Dock icon and the desktop's share sheet are the web's own APIs, not the SDK's — and so are Open With and share targets, which the app's web manifest names. See Fit into the desktop.

Limits#

The numbers an app meets, all in LIMITS. The SDK refuses what it can before a request leaves the page.

Record value
128 KiB of JSON. A record id is 1–128 characters.
Writes in one transaction
64 — and putMany commits 64 at a time.
Items in one page
200: records, files, objects, notifications.
Mentions
8 people in one write or event.
People in a space
50, which is also how many one delivery reaches.
Contacts
1,000 per person; 100 new people a day put in spaces who don't keep the person as a contact.
A name and photo for your app
A name of 1–64 characters, a photo of 512 KiB; 30 changes an hour.
Space meta, name
4096 bytes of JSON; a name of 1–120 characters.
Presence
4 KiB per person per space.
Events
32 KiB each; 240 a minute per person and space, or 1,200 signals.
Bucket object, file
8 MiB, and 12 MiB.
Notifications
A title of 1–160 characters, a body of 2,000, a route of 500.
Idempotency key
1–200 characters.
Declarations
Per release: 32 collections, 32 services, 32 products, 8 channels and 64 commands for agents.
Server operations
10 seconds from a window, up to 120 in the background; 16–512 MB; 32 per app.
Commands for agents
A description of 1–1,024 characters; an input of 16 KiB.
Job input
A JSON object of 256 KiB.
Logs
60 lines a minute from the window.

For your coding agent

https://www.terminus.build/docs/SKILL.md