Apps
Last updated
An app is a website that opens in a window on the Terminus desktop: a browser bundle, a small manifest, and a schema that says what it is built on. Terminus supplies what a website would normally need a server for — the user's data, the people they share it with, the services it calls, payments — and runs the app's own server code when it has some. There is nothing for you to host.
On this page⌄
- How an app works
- Create an app
- What's in the project
- Design for a small window
- Run it locally
- Use the SDK
- The schema
- Permissions come from your code
- Where the data lives
- Declare your collections
- Your app's data as SQLite files
- Migrations
- Apps for more than one person
- The app's server
- Work in the background
- Secrets and the daily budget
- Services
- Sell something
- Fit into the desktop
- Tools for agents
- Logs
- Test on Terminus
- Publish
- Common mistakes
How an app works#
An app reaches everything outside its own code — the person using it, their data, the people they share with, services and payments — through the app SDK. What it is built on is declared once, in its schema. It never handles anyone's sign-in or credentials: Terminus acts for the app, within what the app declares.
What an app stores belongs to the person who stored it. It lives in their data space, not in a database the app owns — so they can inspect it, export it, and keep it when they switch to another app. Only what the app declares as its own — a leaderboard, a catalogue — is one set that everyone using the app shares.
When an app needs code the browser cannot run — a key it must keep, a score nobody may fake, work on a schedule — it has a server of its own, which Terminus runs.
Create an app#
Start on the web: open Create on the desktop, choose App, and name it. That gives it an address, @you/my-app, and an empty draft. The CLI never makes one. Then clone it — an empty draft arrives started from a template:
terminus clone @you/my-app --template react # or vanilla (the default), svelte
cd my-app && npm installA draft that already has files — made in the editor, or pushed from another computer — clones as it is, without --template. clone wants a folder that does not exist yet or is empty. For code you already have, connect its folder instead; it writes the address into terminus.json and uploads nothing:
terminus remote add @you/my-appStarting before the creation exists, terminus init app my-app scaffolds the same project; once the app has been made on the web, terminus remote add links it.
What's in the project#
- terminus.json
- The manifest: what the package is and which address it belongs to.
- index.html, src/
- Your app. Anything that builds to static files works; the templates use Vite.
- vite.config.js
- Keeps
base: "./"and the proxy that letsnpm run devreachterminus devon port 8868. Keep both. - AGENTS.md
- Notes for coding agents: where this app keeps its data, and what not to add.
- dist/
- The build. It is what runs, locally and once published. A web manifest in it says which files the app opens and whether it takes shares: see Fit into the desktop.
- schema.ts
- What the app is built on, declared once: its collections, the services, products and channels it uses, and what agents may use — or
schema.js,schema.mjs;schemainterminus.jsonnames another file. See The schema. - migrations/
- Optional: how a release reshapes the data earlier ones wrote;
migrationsinterminus.jsonnames another folder. See Migrations. - server/
- Optional: the app's server,
server/index.ts, for code the browser cannot run. Terminus runs it. See The app's server. - .env.local
- Secrets for
terminus dev. Never uploaded. See Secrets.
terminus.json
{
"kind": "app",
"id": "@you/my-app",
"version": "0.0.1"
}That is the whole manifest: what the package is, its address, and the version its next release goes out as. When one of the app's folders is not where it usually is, build, server or migrations names it — "build": "site" — and schema names the file that declares what the app is built on when it is not schema.ts, .js or .mjs at the root; any other key is refused. The build is the project's own: with a build script in package.json, Terminus runs npm run build before every push and publishes dist/; without one, dist/ holds the app's finished files as they are. Name, description, icon and listing are set on the app's page and change without a release; info.iconUrl always points at the current icon, which is also your pages' tab icon unless index.html names one of its own. What the app may reach — a service, a host on the web — is declared in its schema and its server's code, never here: see Permissions come from your code.
Design for a small window#
An app opens in a window on the desktop: 640×460 by default, and people can shrink it to 400×300. The window's content box is the app's whole viewport, so a @media query describes the window, not the screen — a breakpoint written for phones fires in an ordinary window.
- Design the narrow case first, then let the layout grow. Judge it at 640×430 and at 400×270, the content of a default window and of the smallest one.
- Keep every pane of a master–detail app on screen at every width: narrow the list, fold it to a rail of icons below about 600px, and open the full list as a drawer over the detail. Never hand one pane the whole window.
- Short windows matter too. Fixed bands — toolbar, search, header, composer — eat a 300px-tall window; trim those, not the scrolling content.
Run it locally#
terminus dev runs your build with the same SDK calls, answers and error codes as Terminus, against local test data in .terminus/dev/ — which survives restarts, is never uploaded, and touches nobody's real data. Build first: it serves dist/, and without a build it stops and says so.
npm run build
terminus dev # http://localhost:8868/, as AlanIt prints the address of each person it runs as:
terminus dev — @you/my-app
Data space: /…/my-app/.terminus/dev
@alan: http://localhost:8868/To see a change, build again and reload. For hot reload, keep terminus dev running and start Vite beside it; the template's proxy passes the SDK's calls through to terminus dev:
npm run dev- --members 3
- Run as several people at once — Alan, Bob, Carol, on down to Zoe — each on a port of their own from 8868 up, with their own data. Use it for anything people do together.
- --members alice,bob
- People with the handles you name.
- --profiles people.json
- Names, bios and avatars from a file. Without
--members, its keys are the people. - --guest
- Add a port after the people's for someone who is not signed in — how your app looks to a guest when it is open to guests. Their own things stay in the browser;
session.signIn()there lets you pick one of the people, on the same port, and what the guest kept moves into that person's data. - --fresh
- Clear the local data and start over.
- --port 5000
- Start at another port; with
--members, the others follow it. Point the Vite proxy's target at it too. A port that is taken is reported with what holds it and a free range to use. - --remote
- Keep your local build, but send every SDK call to your real account on Terminus — real data, real quotas. Needs a published release you have installed;
--members,--profilesand--freshdo not apply.
people.json
{
"alice": { "name": "Alice Chen", "bio": "Designs calm tools.", "avatar": "./fixtures/alice.png" },
"bob": { "name": "Bob Li" }
}The app's server, locally
When the app has a server, terminus dev builds it as a push does and runs it here, a fresh copy for each call: as the person whose tab called, as a guest on the --guest port, or as the app for its own schedules. A schedule does not wait for its time — run it from the dev page, or with POST http://localhost:8868/__terminus_dev/schedules/<operation>/run. Secrets come from .env.local, and app-wide collections and the server and { owns } rules work in the local data as they do on Terminus.
Services and products
Declared services reach the real ones, as you — signed in with terminus login — and billed to you, at most 200 service calls a day, unless a hosted service of yours runs beside the app (below). Products answer with test prices, which you set on the dev page (1 credit unless you change it), and buy() opens a test purchase sheet — Buy or Cancel — that marks the product owned for that person, or that space. Nothing is charged.
A hosted service you are writing can run beside the app instead: its folder answers the app's calls as it will on Terminus, with nothing billed. It restarts when its folder changes, and an operation that declares a web host reaches it under Terminus's rules. This needs a CLI newer than 0.0.4.
terminus dev --service ../weather
terminus dev --service @you/weather=../weather --service ../mapsThe folder's terminus.json id says which service it is; @you/weather=../weather names it when there is none. Repeat --service for each service the app calls.
To see the app handle a failing service, POST http://localhost:8868/__terminus_dev/services/faults with {"code": "service_unavailable", "times": 2} makes the next calls answer that code — rate_limited and upstream_failed work too — and GET http://localhost:8868/__terminus_dev/services/calls lists what each call answered.
What you see
Each person's tab shows your app the way the desktop would: the window's title and its Dock badge, a stand-in for the share sheet, and Open With for the file types your manifest takes. A DEV card in its top-right corner appears for every invitation that person receives — Accept or Decline, which is how they consent to join — and for each new notification, as the desktop's Notification Center would show it. The app's log prints in the terminal running terminus dev, entry for entry as Terminus keeps it: the window's log.info and the other log calls, what went uncaught, and each run of a server operation with what it wrote.
For scripted tests, a person's invitations are also at GET http://localhost:8869/__terminus_dev/requests (their own port), and POST http://localhost:8869/__terminus_dev/requests/{id}/accept — or /decline — answers one as them.
terminus data export --member bob bob.sqlite # Bob's local data, as the file people download
terminus data export --space <space id> launch.sqlite # one space's
terminus data import --member bob data.sqlite # start Bob from a file — one downloaded from the desktop too
terminus data inspect --member bob # what Bob's local data holdsimport replaces what was there only with --force, keeping a backup, and takes the file at its own data version: pending migrations run on it next. See Migrations.
Use the SDK#
Everything outside your bundle goes through @terminus-ai/app-sdk, which the templates already depend on. Wait for ready() — it says who is using the app — and reach what the app is built on through its schema's handles:
src/main.js
import { ready } from "@terminus-ai/app-sdk";
import app from "../schema"; // schema.ts declares "notes"
const info = await ready();
if (!info.guest) console.log(info.user.handle);
await app.notes.put("welcome", { title: "Welcome", body: "Hello" });
const live = await app.notes.live({ sort: "-updatedAt" });
live.subscribe((records) => render(records));App SDK covers all of it: the schema and its handles, errors and retries, collections and live queries, spaces and roles, files and buckets, rooms and durable streams, services, products, the app's server, jobs, notifications and logs.
The schema#
An app declares what it is built on once, in one file: schema.ts at the root of its project, or schema.js or schema.mjs, or the file terminus.json names with schema. Its default export is schema({ … }), in up to six sections — the collections it keeps records in; the services, models, products and channels it uses; and the commands agents may call, under agents (see Tools for agents):
schema.ts
import { schema } from "@terminus-ai/app-sdk";
export default schema({
collections: {
tasks: { ownership: "personal", indexes: ["status"], search: { fields: ["title"] } },
board: { ownership: "space" },
scores: { ownership: "app" },
},
services: {
web: "@terminus/web",
calendar: { address: "@acme/calendar", reads: ["tasks"], writes: [] },
},
products: { pro: { kind: "subscription" } },
channels: { feedback: {} },
});Every collection, service and product it declares is a handle, reached through the file's default export — in the window and in the app's server alike:
src/main.js
import app from "../schema";
await app.tasks.put("t1", { title: "Ship", status: "open" });
const page = await app.web.call("fetch", { url });
if (!(await app.pro.owned())) await app.pro.buy();terminus pushruns 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 release keeps records in exactly those collections, calls exactly those services, sells exactly those products, and sends on exactly those channels;terminus devholds your app to the same.- A declaration is fixed for the release: change it in the next one. A release declares up to 32 collections, 32 services, 32 products, 8 channels and 64 commands for agents.
- A channel makes no handle —
maintainers.send("feedback", …)names it — and neither does a command for agents. The schema and its handles has every rule.
Permissions come from your code#
An app never asks for permissions in its manifest. What it may reach is what its code declares, and nothing else: its schema names the services, products and channels it uses, and each operation of its server names the hosts it fetches from and the secrets it reads. Terminus refuses anything a release does not declare — and terminus.json has no field that could add to it.
Notifications need nothing: notifications.create is always allowed, and each person decides, app by app, whether an app's notifications reach them.
Where the data lives#
What an app stores belongs to its users — each person's own, or a space's that its members share — and each kind of data has its place:
- app.<collection>
- Structured, queryable state. These records are the canonical copy, and they belong to the user — or to a space, or, for an app-wide collection, to everyone using the app.
- app.<collection>.live(…)
- What the interface renders from: a view over the records. It can keep a copy on the device to open fast, but the records are the truth — never browser storage.
- files.bucket(…)
- Large blobs — images, audio, attachments. Keep them out of record JSON. Opened with a space, the whole space shares it.
- files
- Files the user can see and take with them: documents, exports. Theirs alone, even in a space.
- streams.open(…)
- An ordered log a space shares — a match's moves, an activity feed. Everyone folds the same entries in the same order.
- Keep record ids stable, and change the shape of records with a migration. The data outlives the interface that wrote it, and people take it with them as files.
- No second database, and no backend of your own. Other apps and agents reach this data through grants the user controls.
Declare your collections#
Every collection the app keeps records in is declared under collections in its schema. What a collection is lives there — who owns its records, what is indexed and searched, its relations, permissions and rules — and where it is used takes only which space:
schema.ts
import { schema } from "@terminus-ai/app-sdk";
export default schema({
collections: {
tasks: { ownership: "personal", indexes: ["status"], search: { fields: ["title"] } },
board: { ownership: "space" },
scores: { ownership: "app", indexes: ["best"] },
},
});src/main.js
import app from "../schema";
await app.tasks.put("t1", { title: "Ship", status: "open" }); // the person's own
const board = app.board.in(spaceId); // one space's, shared by its members
const { records: top } = await app.scores.find({}, { sort: "-best", limit: 10 }); // the app's, everyone's- "personal"
- Each person's own: their records, in their data. The default.
- "space"
- One set for each space, shared by its members, used with
.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 — a leaderboard nobody can fake — unless its
permissionssay otherwise.
Two rules keep what only some may write:
- "server"
- Only the app's own server: a write from the window is refused, and the server's code decides what is let through. See The app's server.
- { owns: "pro" }
- Only people who own that product — or, in a space, a space that owns it. See Sell something.
schema.ts
collections: {
themes: { permissions: { create: { owns: "pro" }, update: { owns: "pro" } } },
prices: { ownership: "app", permissions: { read: "allow", create: "server", update: "server", delete: "server" } },
},- A collection the release does not declare fails with
collection_undeclared, andterminus devholds your app to the same list. - A definition is fixed for the release: change it in the next one. What a definition can hold is under Collections.
- Each collection's name is its table in the files people download.
Your app's data as SQLite files#
Everything an app keeps for people comes as real SQLite databases: one file per person, and one per space. People find them in the app's Data folder on the desktop and download them; your migrations run on them; terminus data export writes the same files from terminus dev.
- data.sqlite
- Everything one person keeps in the app: their own collections, and the copies they keep inside spaces — a delivered message, say — each marked with its space.
- <space name>.sqlite
- What one space shares: the collections declared with
ownership: "space". In the Data folder's Spaces folder, one file per space the person is in. A member's download holds what that member may read in the app.
A collection is a table named after it, and a record is a row:
-- in data.sqlite; a space's file has no space_id, and its key is (id)
CREATE TABLE tasks (
id TEXT NOT NULL, -- the record's id
data TEXT NOT NULL, -- its value: a JSON object
space_id TEXT NOT NULL DEFAULT '', -- a copy's space; '' outside spaces
created_at TEXT NOT NULL, -- ISO 8601, UTC
created_by TEXT NOT NULL, -- a person's id: see _terminus_people
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL,
PRIMARY KEY (space_id, id)
);- app.tasks
- The table
tasks— indata.sqlite, or in the space's file forownership: "space". - a record's id and value
idanddata.- app.tasks.in(spaceId)
- For a personal collection, its rows in
data.sqlitewhosespace_idis that space. - createdAt, createdByUserId, updatedAt, updatedByUserId
created_at,created_by,updated_at,updated_by.- find({ status: "open" })
SELECT * FROM tasks WHERE data->>'status' = 'open'.
Three read-only tables say what the file is. _terminus_meta (key, value) names the app, whether the file is a person's or a space's and whose, and data_version — the last migration the file has passed, empty before any. _terminus_people (id, handle, name) is everyone the file mentions, as your app shows them. _terminus_deliveries, in data.sqlite, holds the order and recipients of delivered records.
- A table exists for each collection that has records in the file. A table is named exactly as its collection, so a name with a
-is quoted in SQL:"my-notes". datais always a JSON object of at most 128 KiB, exactly as your app stored it.- A download is the data as it stands when it is downloaded. Uploading an edited file back is not possible.
Migrations#
When a release changes the shape of its data — a field renamed, a collection split in two — it carries a migration: a .sql file that runs once on every file of its kind, in version order, before that release writes to it. The files are the whole declaration — nothing lists them — and terminus push sends them with the rest.
migrations/
0.0.2.sql runs on each person's data.sqlite
0.1.0.sql
spaces/
0.1.0.sql runs on each space's fileThe folder is migrations/ at the root unless terminus.json names another, as it can name the app's schema. Each is a path inside the app's folder; the layout inside the migrations folder stays the same, and a release carries its files as migrations/… wherever they were kept:
terminus.json
{
"kind": "app",
"id": "@you/my-app",
"version": "0.0.3",
"migrations": "db/migrations",
"schema": "src/schema.ts"
}- A migration is named after the release that brings it,
MAJOR.MINOR.PATCH.sql, and runs on data written by any earlier release. Someone going from 0.0.1 to 0.1.0 runs 0.0.2, then 0.1.0. - A new migration's version is above the last published release and no higher than the release that ships it; publishing checks both. A published migration is never renamed or deleted.
- Each run is one transaction: it lands whole, or not at all.
migrations/0.0.2.sql
-- one name per contact; empty drafts go
UPDATE contacts
SET data = json_set(data, '$.name',
trim(coalesce(data->>'first', '') || ' ' || coalesce(data->>'last', '')))
WHERE data->>'name' IS NULL;
DELETE FROM drafts WHERE json_array_length(data, '$.blocks') = 0;migrations/0.1.0.sql
-- a task's checklist becomes its own collection
INSERT INTO subtasks (id, data, space_id, created_at, created_by)
SELECT t.id || '-' || item.key,
json_object('taskId', t.id, 'text', item.value->>'text',
'done', item.value->'done'),
t.space_id, t.created_at, t.created_by
FROM tasks AS t, json_each(t.data, '$.checklist') AS item;
UPDATE tasks SET data = json_remove(data, '$.checklist')
WHERE data->'checklist' IS NOT NULL;What a migration may do
- INSERT, UPDATE, DELETE
- Create, change or remove records in a collection's table — upserts too.
- DROP TABLE <collection>
- Remove every record of that collection in the file.
- ALTER TABLE <a> RENAME TO <b>
- Move every record of
ainto a new collectionb. Refused whenbalready has records in the file: merge withINSERT … SELECTinstead. - SELECT, WITH, CREATE INDEX, TEMP tables, views, triggers
- Scratch work. None of it is kept.
Anything else is refused before the first statement runs: ATTACH, PRAGMA, VACUUM, extensions, transaction statements, writes to the _terminus_ tables, and changes to created_* or updated_* on rows that exist. A new row may set created_at and created_by — copied from the row it came from, say; otherwise they are the time of the run and the file's owner. What a run writes keeps to the rules records always keep: an id of 1–128 letters, digits, dots, dashes or underscores, starting with a letter or digit, and a value that is a JSON object of at most 128 KiB.
A migration may name any collection. One with no records in a file reads there as an empty table, and writing to it creates that collection's records. Migrations run on SQLite 3.46, whatever version your computer has.
The any-subset rule
A migration must give the right result when it runs on any subset of a file's rows, because data can arrive late — kept after a reinstall, put back from the Trash, or queued offline by an older version of the app — and the pending migrations then run on just those rows. Row-by-row UPDATE and DELETE pass, and so does deriving new rows from each row. Totals, ranks and removing duplicates across rows do not. Avoid random() and 'now': one migration runs on different files months apart.
When migrations run
- A person opens a newer release
- The pending migrations run on their
data.sqlitebefore the update completes; the desktop says Updating meanwhile. - The first member on a newer release opens a space
- The pending
migrations/spaces/files run on the space's file, once. Writes to the space wait for it; reads carry on. - Data arrives from an older version
- Kept data after a reinstall, data put back from the Trash, writes queued offline: the pending migrations run on just those rows as they arrive.
- A new install
- Nothing runs: the data starts out at the release's latest migration.
Every row remembers the last migration it passed, so a migration runs exactly once on each row, whenever and wherever that row turns up. terminus dev runs pending migrations on every person's and every space's local data when it starts, and a draft's Test window runs them on its test data.
When a migration fails
A failing statement undoes the whole run: nothing is written, and the file keeps its version. A person stays on the release they had, and the app still opens — the desktop says the update didn't finish. In a space, members on the newer release can read but not write until a release corrects the migration, while members on older releases carry on.
What you see of a failure is the migration's version, the statement, SQLite's message, and how many files passed, failed or are still waiting — never anyone's data. Correct the file in your next release: the correction reaches the data still waiting on it, and a failed file tries again when that release is published.
Older versions and update_required
A release that adds no migration mixes freely with older ones. Once a space's file has passed a migration, members still on an older release can read it but not write to it: their writes answer update_required, and the desktop offers them the update. What their app had queued is kept, sent after the update, and migrated as it arrives. A newer release can still deliver a newer-shaped record into the data of someone who hasn't updated yet, so an app skips what it cannot read.
Rehearse it
Try a migration on a real file before you publish: download a data.sqlite from the desktop, or export one from terminus dev, and check it:
terminus data export --member alan data.sqlite # from terminus dev's local data
terminus data migrate --check data.sqlite--check runs your pending migrations on a copy — never the file itself — with the same rules and the same SQLite as Terminus, prints the rows added, changed and removed per table, and writes .out/migrations/report.json. It also runs each migration on random halves of the file and compares them with the whole, so a migration that breaks the any-subset rule shows up before it is published, and it warns about a collection your code never opens, since a typo would match nothing. sqlite3 copy.sqlite < migrations/0.0.2.sql is fine for trying ideas; --check is the rehearsal.
Apps for more than one person#
What several people use together — a conversation, a board — lives in a space your app makes. spaces.create makes one and invites people by handle; spaces.live() lists the ones the person is in — one they make shows up in all their windows by itself — and the app chooses which to show, from its own route, say. A collection declared with ownership: "space" and used .in(spaceId) is shared by everyone in it, and deliver() puts one record into every member's own data at once — the way a chat stores a message:
import { spaces } from "@terminus-ai/app-sdk";
import app from "../schema";
const { space } = await spaces.create({ name: "Launch", memberHandles: ["bob", "carol"] });
await app.messages.deliver(message.id, message, {
spaceId: space.id,
mutationId: message.clientId,
notify: { title: info.user.displayName, body: message.text, route: `/?space=${space.id}` },
});The people named are invited, and join when they accept in Terminus — membership is always their own answer. Each space gives its members a role — owner, admin, editor or viewer — and says what the current person may do there, and a direct space names the other person as peer, so a list of conversations needs no roster reads; Spaces and people has the rest.
Test it as several people. terminus dev --members 3 starts three local people — Alan, Bob and Carol — on consecutive ports, so each browser tab is someone else:
terminus dev --members 3 # ports 8868, 8869, 8870
terminus dev --members alice,bob # people you name
terminus dev --profiles people.json # names, bios and avatars from a fileWatch from someone else's tab. What a page sends to a room — typing, a cursor — reaches the other people in the space but never comes back to its sender, not even to another tab of theirs, and a presence change is not reported to the person who made it. Changes to records, files and streams reach every window that can read them, the writer's own included — and a change to a person's own collection reaches only that person, even when it is used in a space.
The app's server#
Code the browser cannot run — a key it must not show, a score nobody may fake, a partner's API — goes in the app's server: server/index.ts, whose default export is defineService from @terminus-ai/app-sdk/service. Each key is an operation. terminus push builds it with the rest of the app, and Terminus runs it — a fresh copy for each call.
schema.ts
import { schema } from "@terminus-ai/app-sdk";
export default schema({
collections: { scores: { ownership: "app", indexes: ["best"] } }, // everyone reads; only the server writes
});server/index.ts
import { defineService } 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) {
const player = ctx.caller.userId; // who called: key on it, never on the input
await app.scores.update(player, (current) => (current && current.best >= score ? undefined : { best: score }));
return { saved: true };
},
},
top: {
guests: true, // someone who is not signed in may call it too
async run() {
const { records } = await app.scores.find({}, { sort: "-best", limit: 20 });
return records.map((record) => record.value);
},
},
});src/main.js
import { server } from "@terminus-ai/app-sdk";
await server.call("submit", { score: 42 });
const leaders = await server.call("top");Each operation may also say:
- timeoutSeconds
- How long it may run: 10 seconds unless you say, up to 120. A window's call waits at most 10, so an operation that may take longer runs only in the background — 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 may fetch from, by name —
api.example.com,*.example.com— and nothing else: an app's server never gets the whole web. To read any page someone names, call a fetching service (Services). - secrets
- The secrets it reads with
ctx.secrets.get(name). See Secrets. - schedule
- The app's own schedule for it:
{ every: "hour" },{ every: "monday", at: "09:00", timezone: "Europe/Paris" }or{ cron: "0 9 * * 1" }. See Work in the background. - guests
true: someone who is not signed in may call it from the window of an app open to guests.
- Inside
run, the app's handles and the SDK work as they do in the window, as whoever the call is for, and a write from here passes the"server"rule. Refuse withthrow new OperationError(code, message): the window'sserver.callrejects withrefused, your words, and your code indetails. The app's server in the SDK reference has every option and answer. - What
console.logwrites lands in the app's log, with the run. - Nothing about the server goes in
terminus.json.
Who a call runs as
- The person
- A call from their window, a job or trigger of theirs, or an agent's command they allowed. The handles read and write as them, and the services it calls are billed to them.
- A guest
- Someone who is not signed in, calling an operation marked
guests: trueon an app open to guests. It reads the app-wide collections that allow reading, and writes app-wide collections through theserverrule. The services it calls are paid by you, the app's owner, within the app's daily budget. - The app
- The operation's own
schedule. Nobody is behind it: it reaches app-wide collections only, and you pay for the services it calls, within the daily budget.
Work in the background#
Work that must happen while nobody has the app open is one of its server operations. The app's code runs it for a person with a job, or makes a trigger that runs it later — each named after the operation it runs:
src/main.js
import { jobs, triggers } from "@terminus-ai/app-sdk";
// Once, now — its result is what the operation returned.
const job = await jobs.run("digest", { day: "today" });
const { result } = await jobs.wait(job.id);
// Every morning, in the person's own time zone.
await triggers.schedule("digest", "0 9 * * *", { timezone: "user" });
// A URL another service posts to; each delivery runs the operation.
const { url } = await triggers.webhook("on_payment");
// A public page, looked at every hour; a change runs the operation.
await triggers.watch("price_moved", "https://example.com/aapl", { pattern: "\\$[0-9.]+" });- A job, and a person's schedules, webhooks and watches, run the operation as that person, with its whole
timeoutSeconds. Each belongs to the person, one set per installation, and follows their installation to every release. A name defaults to the operation's; setting a name again remakes it.triggers.list()shows them all, each with itskind;triggers.delete(name)removes one, andtriggers.setEnabled(name, false)pauses a schedule. - A schedule takes a five-field cron — each field
*,*/Nor one number — and atimezone: an IANA name, or"user"for the person's own; UTC otherwise. Itsinputis the operation's input. - A webhook's operation gets
{ webhook, delivery_id, delivery_key, received_at, payload },payloadbeing the JSON body, or its text. The URL is shown once;triggers.rotatemakes a new one and the old one stops working. - A watch reads what its
patternmatches on the page (or the page's text) every hour —intervalMinutes, 15 at least — and its operation gets{ event, input }when that changes.condition: { changedByPct: 2 },{ below: 220 }or{ above: 250 }holds it back until a number moves that far. - Whoever installs the app can switch a schedule off, and see recent runs, from the app's Info → Automations.
The app's own schedule
Work that is the app's, not any one person's — refreshing a catalogue, tidying app-wide data — is an operation with a schedule of its own. It runs as the app, for everyone: it reaches app-wide collections only, and what it spends is paid by you, within the app's daily budget.
server/index.ts
import { defineService } from "@terminus-ai/app-sdk/service";
import app from "../schema";
export default defineService({
refresh: {
schedule: { every: "hour" },
timeoutSeconds: 60,
network: ["api.rates.example"],
async run() {
const answer = await fetch("https://api.rates.example/latest");
await app.rates.put("latest", await answer.json()); // an app-wide collection
},
},
});every is "15m", "hour", "day", a weekday or "month" (with onDay), with an at time for a day; or give a five-field cron. A schedule runs in UTC unless its timezone names a zone. terminus dev runs one when you ask it to (Run it locally).
Secrets and the daily budget#
A secret is a key the app's server reads — a partner's API key, say. Each operation names the ones it reads in secrets, and reads them with ctx.secrets.get:
server/index.ts
export default defineService({
forecast: {
network: ["api.weather.example"],
secrets: ["WEATHER_KEY"],
async run({ city }, ctx) {
const answer = await fetch(`https://api.weather.example/tomorrow?city=${encodeURIComponent(city)}`, {
headers: { authorization: `Bearer ${ctx.secrets.get("WEATHER_KEY")}` },
});
return answer.json();
},
},
});- Set a secret on the web, under the key on the app's page in Creations, or from the app's folder with
terminus secrets set WEATHER_KEY, which reads the value at a hidden prompt or from stdin. A value is never shown again, and a new one holds from the next call. - A secret belongs to the app, not to a release: it never enters your files, a package or a release, and a fork of the app never gets it.
- For
terminus dev, put them in.env.local, which is never uploaded.
The daily budget
What the app spends on its own — its schedules, and the calls guests make — is paid by you, its owner, up to its daily budget: $1 a day until you change it in the app's settings in Creations. Past it, those calls are refused until the next day, UTC. Everything a signed-in person does is billed to them, never to you.
Services#
A service the app uses is declared in its schema and called through its handle — from the window, or from the app's server:
schema.ts
export default schema({
services: {
web: "@terminus/web",
gmail: "@acme/gmail",
calendar: { address: "@acme/calendar", reads: ["events"], writes: ["events"] },
},
collections: { events: {} },
});src/main.js
import app from "../schema";
const page = await app.web.call("fetch", { url }); // a public page
await app.calendar.call("create-event", { title: "Launch" });
const inbox = await app.gmail.call("list-messages", { q: "from:alice" }); // the person's own Gmail- A declared service can be called on any of its operations.
readsandwriteslend it the app's collections they name, as the person calling, for each call — and nothing else of the app's. - A connector — a service that works in each person's own account, Gmail here — calls its API with the person's own sign-in or key, which never reaches the app. Someone who has not connected it yet is answered
not_connected, and Terminus asks them to connect it. - The person using the app pays for the services it calls. A guest's call, and the app's own schedule, are paid by you, within the daily budget.
terminus devcalls the real services, as you and billed to you (Run it locally).
Services in the SDK reference has every option, and the Services page lists what there is to call.
Sell something#
What the app sells — a Pro plan, a pack of themes — is a product, declared in its schema as a subscription or a one_time purchase. Terminus sells it: the app asks, and Terminus's own purchase sheet does the rest.
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 () => {
try {
await app.pro.buy(); // Terminus's purchase sheet
proFeatures.hidden = false;
} catch (error) {
if (error.code !== "cancelled") throw error;
}
};owned()shapes the interface.buy()opens Terminus's own purchase sheet — the app never draws it — so call it from a click. It resolves once the product is owned, and throwscancelledwhen the person closes the sheet.- Prices live on the web. Once a release that declares a product is published, the product waits on the app's page in Creations, under Products, for its price in credits and — for a subscription — its period. Until it has one,
buy()answersnot_available. A price changes without a release. - What only buyers get is kept by rules:
{ owns: "pro" }in a collection's permissions, or a check in the app's server — never by hiding a button. - Terminus charges the buyer's credits and pays you your share. People see and cancel their subscriptions in System Settings; a cancelled one stays theirs until the period they paid for ends.
buy({ spaceId })buys for a whole space, which takes one of its admins, andowned({ spaceId })asks about that space.
Fit into the desktop#
An app fits into the desktop through the web's own APIs — there is nothing of Terminus's to import:
document.title = `${unread} unread`; // the window's title
navigator.setAppBadge(unread); // the count on the app's Dock icon
navigator.clearAppBadge(); // back to its unread notifications
await navigator.share({ title: "Q3 plan", text: summary, url: link }); // the desktop's share sheetdocument.titlebecomes the window's title.navigator.setAppBadge(n)puts a count on the app's Dock icon, up to 99,999 (0shows none).navigator.clearAppBadge()takes the app's own count away, and the Dock shows its unread notifications again, as it does for an app that never set one.navigator.share({ title, text, url, files })opens the desktop's share sheet: Copy, Save to Files, and the apps that take shares.
A web manifest in the build — manifest.webmanifest, or the file <link rel="manifest"> names in index.html — says which files the app opens and whether it takes shares. Terminus reads its file_handlers and its share_target, and nothing else:
manifest.webmanifest
{
"file_handlers": [
{ "action": "/open", "accept": { "text/markdown": [".md"], "text/plain": [".txt"] } }
],
"share_target": {
"action": "/share",
"params": { "title": "title", "text": "text", "url": "url" }
}
}- With
file_handlers, Files lists the app under Open With for those types: up to 8 handlers, eachactiona path in the app. - With
share_target, the app appears in the share sheet. Itsparamsname what it takes:title,text,url, andfileswith the types they may be.
An app opened with files — from Open With, say — receives them through window.launchQueue, each as a handle whose getFile() reads it:
window.launchQueue?.setConsumer(async ({ files }) => {
for (const handle of files) openDocument(await handle.getFile());
});Tools for agents#
Agents use your app through its tools — Norbert, and coding agents such as Claude Code, Codex or Cursor through terminus mcp. You write nothing for them: the tools come from what the app already has — its collections, the functions its window uses, its server — and schema.ts says which of it agents may use:
schema.ts
import { schema, t } from "@terminus-ai/app-sdk";
export default schema({
collections: {
notes: { ownership: "personal", search: { fields: ["title", "body"] }, agents: "write" },
tags: { ownership: "personal", agents: "read" },
},
agents: {
shareNote: {
description: "Share the open note with someone; they are notified.",
effect: "send",
input: { handle: t.name() },
},
digest: {
description: "Summarise the notes written this week.",
effect: "read",
},
},
});- Data tools
agents: "read"on a collection gives agentslist_notes,get_noteand, when it declares search fields,search_notes;agents: "write"addscreate_note,update_noteanddelete_note. Terminus answers them itself, as the person and under the collection's own rules — no code of yours runs. Use"write"where saving a record is the whole job; anything with more to it is a command.- Server commands
- An entry under
agentsnamed after one of your server operations. Terminus runs the operation for the agent, as the person, wherever the agent is, and holds it to its effect. - Window commands
- Any other entry: a function your window already uses, handed to
commands()once at startup. It runs in the app's window in the Terminus desktop, so only agents working there can use it.
A window command is code you already have, written for people — a named function that takes one input object, which a button calls too:
src/main.js
import { commands } from "@terminus-ai/app-sdk";
// What the Share button calls.
export async function shareNote({ handle }) {
…
}
commands({ shareNote }); // once, at startup- A command is a description agents choose by, its effect, and its input: fields made with
t—t.id(),t.text(max),t.choice(…),...t.page()— each required unless.optional(), or a JSON Schema of your own. Agents call it by its name in snake case:shareNoteisshare_note. - Its effect says what it may do:
readchanges nothing,writechanges data and reaches nobody, and onlysenddelivers to people, rings them or notifies them. Terminus holds a server command to its effect. A window command's effect is your word, so a command that must never reach people belongs on the server. commands()checks the input before your function runs, and answers what it returns. Throw to refuse: the agent reads your message, and the error'scodewhen it has one. Outside the Terminus desktop it registers nothing and throws nothing.- When the app is not open, the desktop opens it out of sight for the call. A command that must work without the desktop — Norbert on the web, agents that run on their own — is a server command.
- Every app window in the desktop also gives agents
whats_open— the page, its title, the record it shows, the text the person selected — andopen, for a page or a record. A record opens at its collection'ssearch.desk.route, which your app takes throughroutes.listen. - People choose per app, in System Settings → App access, what Norbert and coding agents may do there: Off, Read only, or Read and write, and Send as its own yes — in their own data and the shared spaces they pick. An agent only sees the tools that reaches. The Terminus row above the apps covers the desktop's own tools: listing its windows and taking pictures of them at Read only, and opening, focusing and closing them at Read and write.
- Nothing about tools goes in
terminus.json. A release declares at most 64 commands, each with a description of 1–1,024 characters and an input of at most 16 KiB, andterminus validaterefuses what Terminus would.
While terminus dev runs, open its App access page (the address it prints), let coding agents in, and use the tools as an agent would. Started with --desktop while the Terminus desktop is open, it opens your app's window there too, and its window commands join the tools:
terminus dev --desktop # run it, with its window in the Terminus desktop
terminus apps notes --dev # the tools, and what is allowed
terminus apps notes search_notes --query launch --dev # call one
claude mcp add notes-dev -- terminus mcp --dev # or give them to Claude CodeLogs#
An app has one log, and its maintainers read it:
terminus logs @you/my-app --level error- The window writes to it with
log.infoand the other log calls, and whatever the window did not catch — an exception, a rejected promise — is caught for you. - Each run of a server operation is in it — 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 so is each service call the app makes. - Entries are kept for 7 days, and errors for 30. A person appears under a stand-in name that means something in your app's log alone, never as their account.
Test on Terminus#
terminus dev is the fast loop; Test runs the real thing. Push, then open the app's page in Creations — terminus push prints the link — and press Test. It asks how many people: Just me, Two people, or any number up to eight. Each gets a window, and each window is a different test person — Alan, Bob, Carol and on down the alphabet — running your pushed draft on Terminus, with every SDK call answered as it will be once the app is published. Everything they write is shared the way it will be for your users.
- The build
- A window runs the build your last push sent. Push again and open windows move to the new build by themselves. Stale build means your source has moved past that build: push. Behind the draft means the window has not caught up yet: press Run again.
- The people
- Handles name test people only:
bobis the test person Bob, never a real account, and he is there once his window has opened — an invitation to him before that answersnot_found. - Invitations
- An invitation or notification for a test person waits in your own Notification Center, where you answer it for them. What they send to the app's maintainers (
maintainers.send) lands in the app's Inbox, under Test submissions, without asking them to consent. - What it costs
- You pay for what the windows spend, up to 5 credits and 200 background runs a day (UTC) — jobs, and what schedules, webhooks and watches start. Past the credits, calls answer
quota_exceeded; past the runs,jobs.runanswersrate_limited. The Test panel shows today's use. - What is different
- A connector service runs in your own account, whichever test person calls it. A Test window does not ask you to connect it: until you have, its calls answer
not_connected. A service job that takes one of the person's files is refused withforbidden. - Sessions
- A window's sign-in lasts 12 hours. After that its calls answer
session_ended: press Run again.
What test people write is kept between runs until you forget it: Forget test data, under Test, deletes them and everything they wrote, and every open window starts over.
Publish#
terminus validate # what Terminus will check when you push
terminus status # this folder vs. the draft online vs. the live release
terminus push # build, then update the draft onlineterminus push builds the app — its window, and its server when it has one — replaces the draft online with this folder, and prints the app's page. Then publish it on the web: the app's page in Creations shows what the draft holds, runs it (Test), and publishes it at the version you pick. The CLI never publishes.
Versions are MAJOR.MINOR.PATCH, starting at 0.0.1, and each release must be above the last; the one you pick is written back into terminus.json. The version there is what the next release is called, so when a push warns that it is already released, set it to the version the warning names.
What a release needs from the web lives on the same page, never in your files: the secrets its server reads, its daily budget, and — once a release declaring them is published — the prices of its products.
Common mistakes#
- Nothing to serve
terminus devservesdist/. Runnpm run buildfirst.- Your own typing never shows up
- What a page sends to a room never comes back to its sender. Draw your own change as you send it, and watch for it from a second person's tab.
- A collection answers collection_undeclared
- It is not declared under
collectionsinschema.ts. Declare it there, and reach it through its handle. - A write from the window is forbidden
- The collection's rules keep it for the app's server (
"server", the default of an app-wide collection) or for buyers ({ owns: … }). Write through a server operation, or sell the product. - A server call answers bad_request
- Its input broke the operation's schema — or the operation may run longer than 10 seconds, or has a schedule, so only jobs and schedules run it.
- The server cannot reach a site
- Name the host in the operation's
network. To read any page someone names, call a fetching service instead. - A guest error
- A visitor who is not signed in has no account behind them. Check
info.guestbefore touching spaces, services or products, and mark the server operations they may callguests: true. - buy() throws cancelled at once
- The purchase sheet could not open: call
buy()from a click.not_availablemeans the product has no price yet — set one on the app's page in Creations. - A layout that only works full screen
- A window opens at 640×460 and shrinks to 400×300. Design the narrow case first.
- A page that never appears
- The app opens at
index.htmlin its build.terminus statusshows what the draft actually holds. - Publish refuses the version
- It is not above the live release. Raise it.
- A Test window shows an older page
- It runs the build you pushed, and says Stale build when your source has moved past it. Build,
terminus push, and every open window catches up by itself. - An invite in a Test window finds nobody
- A test person is there once their window has opened. Test with two people before Alan invites
bob. - A write answers update_required
- A newer release's migration has moved that data on — someone in the space updated first. This version can read it but not write to it: the desktop offers the person the update, and queued writes wait for it. Don't retry.
- A migration that works on the whole file, but not on some rows
- It breaks the any-subset rule: totals, ranks, removing duplicates,
random(),'now'.terminus data migrate --checkruns it on random halves to show you.
For your coding agent
https://www.terminus.build/