npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@terminus-ai/app-sdk

v0.0.5

Published

Browser SDK for user-owned data, collaboration, connectors, and managed app resources on Terminus.

Readme

@terminus-ai/app-sdk

The browser SDK for Terminus apps: the person's own files and records, shared spaces with their members and read cursors, rooms and presence, durable streams, notifications, automations, the public web, connectors, services and the app's own server code — over one same-origin door, with the person's session never in reach of app code.

import { db, files, ready, spaces } from "@terminus-ai/app-sdk";

const app = await ready();
if (app.guest) showSignIn(); // a guest: their own things stay on this device
else greet(app.user.displayName);

const notes = await db.collection("notes").live({ sort: "-updatedAt" });
notes.subscribe((records) => render(records));
await notes.put("welcome", { title: "Hello", updatedAt: Date.now() });

const { files: recent } = await files.list({ prefix: "exports/", limit: 20 });
const team = await spaces.create({ name: "Launch", memberHandles: ["bob", "carol"] });

Importing the root module has no side effects: the same-origin bridge is created on first use. Script-tag apps import the auto entry, which installs window.terminus and window.TerminusSDK eagerly:

<script type="module">
  import "@terminus-ai/app-sdk/auto";
  const app = await window.TerminusSDK.ready();
</script>

Runtime protocol major version 1 is negotiated by ready(): a host that speaks another major is refused before any data is touched. Its wire — every door, field, header, error code and limit — is conformance/doors.json; the SDK speaks that snake_case wire and answers apps in camelCase. What an app or an upstream wrote is never renamed: record values, space meta, event payloads, presence states, notification data, job input and result, connector, service and egress bodies and headers, and server results come back exactly as written.

The session

ready() answers who is here. A signed-in person:

const app = await ready();
// { guest: false, user: { id, handle, displayName, avatarUrl, publicProfileUrl },
//   appId, slug, name, iconUrl, installationId, releaseId, releaseVersion,
//   grants, resourceGrants, platformApiVersion, sdkVersion }

Guests

An app open to guests (its maker's setting, beside visibility and price) also opens for someone who is not signed in: guest: true, user: null, no installation. A guest's own things work, and stay on this device — records in collections without a space (put, get, find, search, live, db.transact), files, and storage buckets without a space — through the same calls, with the same versions, compare-and-set and change feed; a guest keeps up to 64 MiB of an app. Everything that needs an account — spaces, rooms, streams, people, notifications, the web, connectors, services, server code — refuses with a TerminusError whose code is guest; the app keeps running and can say so. Shared capability modules (horizontal.loadModule, loadStyle) load for a guest too, for the capabilities the app uses.

When a feature needs an account, ask for it where it is needed:

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

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

session.signIn({ returnTo }) takes the page through the platform's own sign-in (or sign-up) and back to returnTo — a path on the app's own origin; the current page when unset — signed in. The origin never changes, so whatever the app kept in the browser is still there, and the guest's own records, files and objects move into their account as the signed-in page opens (moveGuestData, create-only: anything the account already has by the same name stays as it is, and the guest's copy stays on the device). Only a sign-in the app started moves anything by itself: on a shared browser, session.guestData() says what a guest left here, and the app decides — session.moveGuestData() or session.discardGuestData(). Inside a frame, sign-in opens in a tab of its own.

session.signOut() signs the person out of this app on this device. The platform ends the session; from then on every door of the page refuses with session_ended (the SDK's own answer, sent nowhere), and the app's other windows — their session cookie went with it — with unauthorized. A session the platform itself ended (the person signed out of Terminus, or it expired) answers session_ended too; session.signIn() signs them back in and returns them where they were. An app open to guests opens as a guest after signing out.

Errors and limits

Every refusal is a TerminusError: the HTTP status (0 when the platform was never reached), the platform's code, its details, and whether the same call can succeed later. Branch on the code, never on the message:

import { TerminusError, isRetryable } from "@terminus-ai/app-sdk";

try {
  await tasks.put(id, value, { expectedVersion: 3 });
} catch (error) {
  if (error instanceof TerminusError && error.code === "version_conflict") return reload();
  if (isRetryable(error)) return retryLater();
  throw error;
}

isRetryable(error) is true for network (the platform could not be reached), 5xx answers other than server_disabled and a door the host does not have (not_implemented, unsupported_in_dev), 408/425/429 and still_pending (the same idempotency key is still in flight). An ended session, a guest and every other 4xx are final. ErrorCode names the whole catalogue.

LIMITS carries the protocol's numbers — LIMITS.transactionOperations (64), LIMITS.pageSize (200), LIMITS.eventBatch (16), LIMITS.mentions (8), LIMITS.bucketObjectBytes (8 MiB), LIMITS.spaceMetaBytes (4096) and the rest of doors.json's limits — and RECORD_ID_PATTERN the record id grammar. The SDK checks the ones it can before a request leaves the page, so an oversized batch or a bad id fails at the call with bad_request or payload_too_large.

Files

The person's own file plane: files this app may read and write. private (the default zone) holds the app's own files; output holds deliverables the person keeps. With a spaceId, a file is the caller's own in that space (private zone only) — state other members share belongs in a space collection, a space bucket or a durable stream instead.

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

const receipt = await files.write("drafts/plan.md", text, { mediaType: "text/markdown" });
// { path, sizeBytes, sha256, mediaType, version, updatedAt }
const body = await files.read("drafts/plan.md");
const image = await files.readBlob("exports/cover.png", { zone: "output" });

let page = await files.list({ prefix: "drafts/", limit: 100 });
while (page.nextCursor) page = await files.list({ prefix: "drafts/", after: page.nextCursor });

await files.remove("drafts/old.md");

files.stat(path) answers a file's metadata — sha256, version, mediaType, schemaId, schemaVersion, observationId, provenance. For a compare-and-set edit, stat first, then read, then write with expectedSha256: stat.sha256 (or "missing" for a file that must not exist yet): a change in between fails the write with version_conflict instead of being overwritten. A file is at most 12 MiB.

Storage buckets

Nothing declares a bucket. Opened with a spaceId it belongs to that space: every member reads it, and an object stays when whoever wrote it leaves. Without one it is the member's own. An object is at most 8 MiB and keeps the content type it was written with.

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

const shared = storage.bucket("attachments", { spaceId });
await shared.write("photos/1.png", blob); // a Blob's own type is stored
const photo = await shared.read("photos/1.png"); // a Blob
const { files: listed, nextCursor } = await shared.list({ prefix: "photos/", limit: 50 });
await shared.delete("photos/1.png");

Like a file write, a bucket write can be create-only (expectedSha256: "missing") or a compare-and-set against the sha256 an earlier receipt or listing gave, and so can a delete — so members of a space do not overwrite, or delete, each other's files. A mismatch answers version_conflict; a delete cannot expect "missing" (it needs something to delete: bad_request).

const receipt = await shared.write(`photos/${id}.png`, blob, { expectedSha256: "missing" });
await shared.write(`photos/${id}.png`, edited, { expectedSha256: receipt.sha256 });
await shared.delete(`photos/${id}.png`, { expectedSha256: receipt.sha256 }); // version_conflict: it changed

Pictures on screen

The platform serves every read afresh and resizes nothing. media keeps what the page has read: each file is read once however many views show it, drawn on the first frame when its bytes are already here, never revoked under a view that holds it, and let go of — oldest first — once nothing shows it and the page holds more than its idle budget (96 MiB; media.setIdleBudget). The key is any string that names the bytes.

import { media, storage } from "@terminus-ai/app-sdk";

const shared = storage.bucket("attachments", { spaceId });
const key = `${spaceId}/${attachment.path}`;

// In a view: `url` at once when the bytes are here, else `ready`.
const view = media.objectUrl(key, () => shared.read(attachment.path));
img.src = view.url ?? (await view.ready);
// …and when the view goes away:
view.release();

media.peek(key);             // the URL if the bytes are here, synchronously (holds nothing)
media.hold(key, file);       // bytes already in hand — the sender's own photo — where views look
await media.bytes(key, () => shared.read(attachment.path)); // the Blob, to save or send on
media.revoke(key);           // the bytes changed: read them again next time

media.thumbnail(blob, { maxEdge, type, quality }) makes the small copy in the browser that already holds the picture: decoded with its own orientation, scaled to its longest edge (never enlarged), and encoded as type (WebP by default; a browser that cannot encode it gives a photograph back as JPEG). Where the browser can, it reads the picture's size from its header and decodes it at the thumbnail's size — a 48-megapixel photo never sits in memory whole (about 190 MB); elsewhere (a worker, a browser that ignores resize options) it decodes the whole picture and scales it. It answers { blob, width, height, sourceWidth, sourceHeight }.

const { blob: thumbnail, width, height } = await media.thumbnail(file, { maxEdge: 640 });

Records

Collections are registered lazily where app code opens them, once per page (db.collection("tasks") inline at every call site costs one definition round-trip). A collection is the member's own unless its definition says ownership: "space"; definitions can add search fields, indexes, uniqueness, relations, a bounded permission policy and validate-on-write rules the platform checks inside every write's own transaction:

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

const tasks = db.collection("tasks", {
  spaceId, // a space-owned collection is read and written in one space
  ownership: "space",
  searchFields: ["title"],
  indexes: ["status"],
  unique: ["slug"],
  relations: { project: { collection: "projects", field: "projectId", required: true, onDelete: "cascade" } },
  permissions: { read: "space-member", create: "space-editor", update: { anyOf: ["record-creator", "space-admin"] } },
  validate: [
    { rule: { eq: [{ field: "ownerId" }, { actor: "user_id" }] }, message: "a task is its writer's" },
    { rule: { forbidChange: ["ownerId"] }, message: "the owner is fixed" },
  ],
});

const record = await tasks.getRecord("task-1"); // { id, value, version, createdAt, updatedAt, createdByUserId, … }
await tasks.put("task-1", { title: "Ship", status: "open" }, { expectedVersion: record?.version ?? 0 });
const open = await tasks.find({ status: "open" }, { sort: "-priority", limit: 20 });
for await (const task of tasks.scan({ status: "done" })) archive(task);

Every write carries an idempotency key — pass mutationId to make a retry safe, or let the SDK pick one — and expectedVersion makes it a compare-and-set (0: the record must not exist). Record ids match RECORD_ID_PATTERN. find() reads one bounded page, findPage() answers its cursors, scan() walks every page without holding them, and search() runs a websearch-syntax query over the declared search fields. The query grammar (where, sort, search) is evaluated locally by the @terminus-ai/app-sdk/where module too, which throws WhereGrammarError for exactly the inputs the platform rejects.

Several writes can commit as one unit — every compare-and-set, constraint, relation and permission check succeeds or nothing does:

await db.transact([
  tasks.putOperation("task-1", { title: "Ship", projectId: "p1" }, { expectedVersion: 0 }),
  { action: "put", collection: "audit", id: "task-1-created", value: { taskId: "task-1" }, expectedVersion: 0 },
], { spaceId, transactionId: "create-task-1" });

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

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

A personal record can be delivered to every current member's own collection with one idempotent call; the platform freezes the recipients and a space-wide delivery sequence with it:

const inbox = db.collection("messages"); // each member's own
const delivered = await inbox.deliver(message.id, message, {
  spaceId,
  mutationId: message.clientId,
  notify: { title: "Alice", body: message.text },
});
// { id, value, sequence, recipients, mutationId, syncCursor, replayed }

Live queries

live() is a reactive projection over a query: it reads a snapshot, then follows the collection's durable change feed — applying a change in place when its frame says it follows the local cursor, reading the feed otherwise, and draining from its cursor after the stream reconnects. Writes through it are optimistic and leave the overlay only when their own mutation returns through the feed. Identical live queries share one reactor. Each change is judged against the query's where and search with the SDK's own evaluator (the where module), so a record that stops matching leaves and one that starts matching enters; a change whose text the evaluator cannot read exactly as the platform does (a URL, a path, markup, a signed number) makes the query read its window afresh instead.

const live = await db.collection("tasks", { spaceId }).live({ where: { status: "open" }, sort: "rank", limit: 50 });
const stop = live.subscribe((records, event) => render(records));
await live.put("task-9", { title: "Draft", status: "open", rank: 3 });
await live.delete("task-4");
await live.close();

To open fast next time, keep what a projection held — live.snapshot() is the committed records and the cursor they are current through — and hand it back with resume. The projection draws those records at once, before the collection's definition is registered or anything is read, and catches up behind them: an unbounded query reads only what changed since (a cursor the platform no longer holds falls back to a fresh snapshot); a bounded one (sorted or limited) reads its window afresh and replaces what it drew.

const kept = await loadFromIndexedDb(`${app.user.id}:${app.installationId}:${app.releaseId}:tasks`);
const tasks = await db.collection("tasks").live({ resume: kept });
tasks.subscribe(() => saveToIndexedDb(key, tasks.snapshot()));

Or let the projection keep it: with persist, its snapshot is kept on this device — per person, installation, release and query (where, sort and limit included) — and drawn from next time, offline too. It is written when changes pause, at least every half minute while they keep coming, and at once when the page hides or goes away or the projection closes. A device keeps the 16 most recently opened snapshots of a person and installation, none older than 30 days, so a query that names a day does not pile up.

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

db.forgetKept() lets go of what was kept — for a space the person left, say. It resolves how many snapshots it forgot, and open projections among them stop keeping:

await db.forgetKept({ spaceId });             // what was kept for one space (null: their own scope)
await db.forgetKept({ collection: "tasks" }); // for one collection
await db.forgetKept();                        // everything this device keeps for them in this app

A write to a record that already has one on its way waits behind it; a later one replaces it (the latest value wins), and every replaced write settles when the one that goes commits. A refused write rolls back, and listeners hear rollback; one that fails retryably stays shown and is sent again with backoff — at once when the browser comes back online.

Changing a record

update(id, fn) is read-modify-write: it reads the record, asks fn for the next value (undefined leaves the record as it is) and writes it only if nobody wrote in between — otherwise it reads again and asks again (five times by default, { attempts }). A write that fails retryably is sent again as the same request, so one update never applies fn twice. It resolves with the record as written.

const reads = db.collection("reads");
await reads.update(spaceId, (current) => (current?.through >= through ? undefined : { through }));

On a live query, update starts from what the projection shows — a write still on its way included — and queues behind it.

Many records at once

putMany(records) writes many records, not as one unit: in order, in transactions of at most 64. Calling it again with the same records is safe — a chunk that already committed answers as it did, and a record that already holds the value given counts as stored. The first refusal stops the batch; the answer says what was stored.

const result = await operations.putMany(pending.map((op) => ({ id: op.id, value: op, expectedVersion: 0 })));
// { stored: [...ids], unstored: [...ids], chunks: [{ ids, transactionId, status, replayed, error }], error }
if (result.error?.retryable) retryLater(result.unstored);

Writes that must arrive

db.outbox(name) keeps writes on this device until they reach the platform — across a reload, a closed lid, a lost connection. An entry is an ordered list of steps: uploads to a bucket, then the record that points at them. Entries go one at a time, in the order they were queued. Everything an entry carries — values, bytes — is copied when it is queued and each step's idempotency key is fixed then (derived from the entry's id: use each id once), so every try sends the identical request and nothing is delivered twice.

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

const outbox = await db.outbox("messages");
await outbox.enqueue({
  id: message.id,
  meta: { spaceId, message }, // what you draw the pending message from
  steps: [
    { type: "upload", bucket: "attachments", spaceId, path: photoPath, blob: photo },
    { type: "upload", bucket: "attachments", spaceId, path: thumbPath, blob: thumbnail, optional: true },
    { type: "deliver", collection: "messages", id: message.id, value: message, spaceId, notify, mentions },
  ],
});

outbox.subscribe((entries, event) => render(entries)); // state: pending | sending | failed
await outbox.retry(id);   // a failed entry, from the step that failed
await outbox.discard(id); // take it out, bytes and all
await outbox.flush();     // now, not at the next retry

Steps are upload, put, delete, deliver and transact (the last two ring with notify and mentions). A try that fails retryably is tried again with backoff — at once when the browser comes back online — and the entries behind it wait; one the platform refuses is failed, with its lastError, and the queue moves on. An optional upload the platform refuses is skipped. When an entry has gone, listeners hear sent with the platform's answer to each step — a delivery's sequence and recipients, say. An ended session stops the queue without failing anything: the next session sends it. The outbox is kept in IndexedDB, per person, installation and release; where the browser refuses storage it runs from memory, and outbox.persistent is false. What an earlier release of the app queued goes after an update like anything else — the person's writes, sent in order with this release (its collection definitions, the entry's own fixed keys) — and becomes failed only if this release refuses it; one the earlier release had seen refused stays failed for the app to retry or discard. Windows of the app — an updated one and an older one alike — share their outbox and take turns: one sends at a time, and an entry another window sent leaves the others with a gone event.

Spaces

A space is a shared place an app makes — a conversation, a channel, a shared page. kind is "group" (the default) or "direct" (the creator and exactly one other person); a space can name a parentId (another space of the same app the creator is in) and carry meta, app-defined JSON of at most 4096 bytes every member can read. Creating a space invites everyone it names; an invitation is not a member until the invitee accepts it in the trusted Terminus notification surface.

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

const { space, invitations } = await spaces.create({
  name: "#launch",
  memberHandles: ["bob", "carol"],
  parentId: orgSpaceId,
  meta: { topic: "Launch week" },
});
const direct = await spaces.create({ kind: "direct", memberHandles: ["bob"] });

await spaces.update(space.id, { name: "#launch-crew", meta: { topic: "Shipped" } });
await spaces.invite(space.id, "dana"); // role "editor"; "admin" and "viewer" too
await spaces.updateMember(space.id, memberId, "viewer");
await spaces.removeMember(space.id, otherMemberId);
await spaces.delete(space.id); // the owner deletes the space and its app data, for everyone

A Space says what the signed-in person may do there — capabilities.canSend, canInvite, canRemoveMembers, canManageRoles, canUpdate, canLeave, canBlockPeer, canDelete — along with myRole (owner, admin, editor or viewer), block state and muted. Render those rather than recreating the permission matrix. Direct spaces refuse group management; groups refuse peer blocking (spaces.blockPeer). A direct space names who it is with as peer (a PublicUser): its other member, or, until they answer, the person invited — null for a group, or once nobody else is in it or invited — so a conversation list labels it without reading its roster:

const title = (space) => (space.kind === "direct" ? space.peer?.displayName ?? "…" : space.name);

Live views multiplex onto the page's one stream and re-read only what their own frames say changed:

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

const roster = await spaces.liveMembers(space.id); // members + pending invitations of one space
roster.subscribe(({ members, invitations }) => renderRoster(members, invitations));

const cursors = await spaces.liveCursors(space.id); // delivery head + everyone's delivered/read cursors
cursors.subscribe(({ headSequence, members }) => renderReceipts(headSequence, members));
await cursors.update({ readThrough: cursors.snapshot.headSequence });

refresh() on any of them reads everything again now — a roster both its members and its invitations, whatever its frames said — and resolves with the value from a read that began after the call.

A person can mute a space (iMessage's Hide Alerts) or mute every space they have not decided for; a mention of them still rings:

await notifications.mute(spaceId);        // muted
await notifications.mute(spaceId, false); // rings
await notifications.mute(spaceId, null);  // follows the default
await notifications.muteByDefault(true);

Rooms and presence

A room is ephemeral coordination in one space: signals and presence. It hears room_events of its own space only, and a page never hears its own publishes. Presence states arrive with their frames and apply in place; setPresence merges this page's rooms into one state per space and sends it without reading first, coalescing a burst of updates:

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

const room = await rooms.open("typing", { spaceId });
room.subscribe(({ payload, envelope }) => showTyping(envelope.from.userId, payload));
room.subscribePresence(({ online, states }) => renderCursors(states));
await room.setPresence({ editing: "task-1" });
await room.signal({ cursor: 42 }); // transient: live subscribers only
await room.close(); // clears this room's presence

A room with presence open in a space is how the platform knows a person is looking at it: a message there does not ring them while this page shows it. The SDK says which window each page is, re-says it when the page is hidden or shown, and says the window left when a room closes or the page goes away. A hidden page stops re-saying its presence, which lapses within a minute; shown again, it says it at once.

Durable streams

A durable stream is an ordered journal of app-defined entries. Every listener sees every entry exactly once and in journal order — its own appends included, which come back through the journal like everyone else's — so two members folding the same stream fold the same thing:

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

const ledger = await streams.open("kills", {
  spaceId,
  onEvent: (event) => (event.type === "reset" ? restore(event.snapshot) : apply(event.entry.operation)),
});
await ledger.append({ killer: me, victim }); // resolves once listeners have seen it
await ledger.checkpoint(currentScoreboard()); // through what this stream has delivered

If an append commits but the subsequent read fails, it throws a non-retryable TerminusError with code stream_catchup_failed and the committed receipt in error.details.operation. Resume the reader with ledger.drain(); appending again with a new id would create a second entry. A failed catch-up never reports that listeners received the entry.

A checkpoint lets the platform compact what it covers; a reader behind it receives reset with the snapshot and then only the entries after it. Inside a listener, ledger.cursor is the cursor of the event it is handling — the entry's own, or where a reset put the stream — so a checkpoint taken there with the default through claims exactly what the listener has folded.

Notifications

notifications.create notifies the signed-in person themself (never anyone else); while they paused this app's notifications nothing is written and the answer says paused. A replayed idempotencyKey answers the first answer.

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

await notifications.create({ title: "Export ready", route: "/exports/1" }, { idempotencyKey: "export-1" });
let page = await notifications.list({ limit: 50 });
await notifications.markRead(page.notifications[0].id);
await notifications.pause(new Date(Date.now() + 86_400_000)); // at most 31 days; null resumes

A notification opens its app on its route. When the app's window is already open it is not reloaded for that: the route arrives as an event, and an app that can show the place where it stands takes it:

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

routes.onRoute((route) => openConversation(new URL(route, location.origin).searchParams.get("space")));

Automations

An app's declared automations run as durable jobs. jobs.wait answers the moment the platform announces the job finished and polls only slowly as a fallback; it shares its options (timeoutMs, pollIntervalMs, signal) with services.jobs.wait:

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

const queued = await jobs.run("send-reminders", { day: "2026-09-23" }, { idempotencyKey: "reminders-0923" });
const done = await jobs.wait(queued.id, { timeoutMs: 60_000 });
if (done.status === "failed") report(done.error);

const list = await schedules.list();
await schedules.setEnabled("morning", false);
const { installedDataVersion, targetDataVersion } = await lifecycle.status();

The public web

net reaches public web resources through the platform's SSRF-guarded proxy; the URLs may be runtime values. net.fetch answers text (a page, a feed, a calendar) up to 512 KiB, or its start with partial — all a link preview needs. net.image answers a raster image (PNG, JPEG, GIF, WebP or AVIF, at most 5 MiB) as a Blob: an app frame cannot load another origin's image, and should not, since every reader would be announced to it.

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

const page = await net.fetch(url, { partial: true }); // { url, status, contentType, body, truncated }
const { blob } = await net.image(previewImageUrl(page.body));

Connectors and services

A connector call runs with the person's own credential, which never reaches the app. The body comes back as UTF-8 text (body) or base64 (bodyBase64); connectors.json parses a successful JSON answer:

import { connectors, services } from "@terminus-ai/app-sdk";

const repo = await connectors.json("github", "/repos/acme/app", { resourceGrantId });
const answer = await connectors.request("google-calendar", "/calendars/primary/events", { method: "POST", body: event, idempotencyKey });

Services are developer-operated: the release names each @handle/slug and operation it calls, and the provider receives a short-lived Terminus identity, never the person's credentials. services.json parses a JSON answer; the platform's own web and job services have typed helpers:

const created = await services.json("@acme/calendar", "create-event", { input: { title: "Launch" }, idempotencyKey });
const { results } = await services.search("@terminus/web-search", { query: "Rust HTTP clients", count: 5 });
const article = await services.fetch("@terminus/web-fetch", { url, representation: "markdown", render: "auto" });

const job = await services.executeCode("@team/code-execute", { runtime: "python", code: "print(2 + 2)" }, { idempotencyKey: "calc-1" });
const finished = await services.jobs.wait(job.jobId, { timeoutMs: 120_000 });

Image generation, document conversion, bounded code execution and email (generateImage, convertDocument, executeCode, sendEmail) submit jobs; services.jobs.get, cancel, file and save inspect, cancel, download and keep their results. wait also stops at reconciling: an uncertain provider outcome must not be resubmitted. One logical invocation is billed once even if a route tries two providers.

Capabilities, server code, egress and collect

import { collect, egress, horizontal, server } from "@terminus-ai/app-sdk";

const rendering = await horizontal.loadModule("rendering", 1); // a shared, pinned module
horizontal.require("external-previews", 1); // a policy-only capability: a marker, no I/O

const { entries } = await server.call("top", { n: 15 }); // the app's own server code
const upstream = await egress.request("sync-orders", { since }); // a published request template
const submitted = await collect.submit("feedback", { rating: 5 }, { idempotencyKey });
await collect.retract("feedback", submitted.id); // inside the undo window: gone without a trace

server.call runs one op of the app's server/ folder and answers what it returned (an op the app calls answers within 10 s). The first collect.submit on a channel needs the person's one-time consent: it refuses with grant_required until they give it.

What a release is allowed to do

Named dependencies stay literal at the call site — connector slugs, service addresses and operations, capability ids and versions, server ops, egress templates, collect channels — and so do the calls that need a grant (net.fetch, net.image, notifications.create). When a developer runs terminus push, the CLI compiles the release's capabilities from those calls (conformance/capability-calls.json lists every call and the grant it compiles); the platform enforces that contract, and publishing happens on the web. terminus.json never repeats what the code already says.

Logs for the app's maintainers go through log.debug/info/warn/error (at most 60 a minute).

The raw bridge

Every entry point takes { terminus } to use a given bridge instead of the page's own, and createClient() returns the raw bridge — a seam for tests and harnesses, not an app API: its doors answer the same camelCase results the namespaces above do. A harness outside a page builds one with createBrowserBridge({ fetch, EventSource }): the bridge sends its same-origin /_terminus/… paths through those.

Conformance and verification

conformance/ ships the runtime contract: doors.json (every door, field, header, error and limit), app-host.json (what the app host forwards), capability-calls.json (what each SDK call compiles into a release) and the record, grammar and delivery vectors. The fingerprinted conformance/manifest.json is the release boundary the backend, terminus dev and the app host consume; changing a vector requires an intentional manifest refresh and a coordinated consumer update.

The source is TypeScript under src/; npm run build emits dist/ (ESM + declarations), and npm test builds first, then runs the suite against the built output — the conformance vectors, an export-parity check between dist/index.d.ts and the runtime module, the wire of every door, and multi-writer scenarios for journals, rooms and live queries. Run both before pushing code changes. Pull-request CI cancels superseded runs and skips the suite for docs-only diffs while still completing its check.

npm run scenarios runs test/e2e/ and checks a host from the outside: two members drive the SDK against it — no replay after a reconnect, the echo rule, recovery from a compacted journal, replays that ring nobody, the cursor frontier, pause and mute, two writers on one journal, spaces with meta and parents, paging and write receipts — each namespaced by a run id and cleaned up after. --fake runs them against an in-memory host (as npm test does); --member alan=http://localhost:8868 --member bob=http://localhost:8869 against terminus dev --members 2; --help lists the rest.

Agent operations (SDK 0.0.3)

Apps can publish a typed interface that runs with the app window closed. Norbert and the local terminus apps commands discover the same interface. Keep app rules in shared modules, then call those rules from both the UI and these operations. Reuse the app's existing SDK collection definitions and version checks; do not expose a generic SQL, filesystem, or arbitrary HTTP tool.

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

export const { agent, ops } = defineAgent({
  list_notes: {
    description: "Read one page of your personal notes.",
    effect: "read",
    input: {
      type: "object",
      properties: { after: { type: "string" } },
      additionalProperties: false,
    },
    runtime: ["GET /collections/notes"],
    async run(input, ctx) {
      return db.collection("notes", { terminus: ctx.terminus })
        .findPage({}, { after: input.after as string | undefined, limit: 25 });
    },
  },
});

Bundle this entry as CommonJS into server/agent.js, with dependencies included and browser assets excluded (publicDir: false in Vite). A small server/main.js contains module.exports = require("./agent.js"). Run this build before terminus dev, validate, or push. The CLI compiles the exported metadata into capabilities.server.agent; do not copy it into terminus.json. Server source still obeys the platform's bounded server runtime, entrypoint and total bundle limits. The four official apps provide full examples.

Each handler receives ctx.terminus, ctx.userId, ctx.spaceId and a stable ctx.invocationId. The platform supplies this context; it is never accepted from the operation's JSON input. Use invocationId for transaction/mutation IDs and deterministic created IDs. Reads should paginate; writes should use exact record versions or append-only operations. The platform durably claims every invocation before execution and never repeats a mutation after an uncertain failure. Clients can inspect the invocation status and then reconcile through read operations.

effect is read, write, or send. Use send for actions that deliver content or notify people. Each runtime entry is an HTTP method plus an app-runtime path; * matches one path component. These are an execution ceiling: users must also grant the named operation and its personal or selected-space scope. Every request rechecks the grant, installation, release, membership and record policy. Account, session, connector, arbitrary network and nested server-operation doors are unavailable through this transport. App code receives no credentials.

Inputs use a bounded JSON Schema subset: objects, arrays, strings, booleans, numbers/integers, required fields, enums, length/item limits and numeric bounds. Unsupported keywords should not be used as validation guarantees. This first transport supports JSON collections, transactions and space operations; binary attachments and full external-service migrations require separate interfaces. Operations may return attachment references without transferring the bytes.

The browser runtime protocol stays at v1: existing browser door payloads are unchanged. This is an additive server capability and SDK subpath. It requires the matching backend, execution-host and CLI release; it does not make older published app releases automatically agent-accessible.

Platform data for apps (admin-data v1)

People an administrator gives access to can build their own apps on platform data: dashboards, charts and reports over the aggregates the admin console reads. An administrator opens Data access in the console (right after Overview), chooses Give access, finds the person and ticks what they may read. Access belongs to the person, never to an app:

  • A read needs access for both the person using the app and the person who built it, of the kind the operation reads under. An app built by someone without access reads nothing, whoever uses it.
  • An app that reads platform data is never public. Publish it Restricted (only you and the people you let in) or Unlisted (address only); publishing it public, or switching it to public, is refused, and the Creation page says why before you press Publish.
  • terminus dev reads with your own access, so you can build against real numbers before the first publish. Draft Test windows never read platform data.
import { horizontal } from "@terminus-ai/app-sdk";

const overview = await horizontal.invoke("admin-data", 1, "overview", {
  input: { days: 30 },
});
const page = await horizontal.invoke("admin-data", 1, "users", {
  input: { limit: 50 },
});
const conversations = await horizontal.invoke("admin-data", 1, "conversations", {
  input: { user_id: "<account UUID>" },
});
const transcript = await horizontal.invoke("admin-data", 1, "messages", {
  input: { user_id: "<account UUID>", session_id: "<session UUID>" },
});

Keep the capability, version, and operation literal at each call site so terminus push compiles the exact operations into the release declaration. Declaring an operation grants nothing by itself. No admin password, admin token, or platform token belongs in app code.

| Operation | Access it reads under | Input and output | | --- | --- | --- | | overview | Analytics | days: 1–90, default 30. Returns display_timezone, aggregate users, usage, all Overview metrics (including revenue and costs), user_growth, retention, skill_growth, skills, search_usage, daily_usage, model_token_usage, key_source_token_usage, billing_trend, provider_balance_trend, and system_token_expense. These are the console’s aggregate datasets and series, suitable for charts and comparisons. No individual accounts, provider-account identifiers, credentials, or conversation content. | | users | User activity | limit: 1–100, default 50; optional after UUID. Returns users (user_id, email, username, status, created_at) and next_after; pass it as after for the next page. Test accounts are excluded. | | conversations | User activity | user_id UUID. Returns up to 50 recent non-empty chat_sessions with IDs, dates, status, surface and message counts. Excludes titles, search queries and message text. | | messages | Conversation content | user_id and session_id UUIDs. Returns session_id, title, and up to 500 messages (seq, role, flattened text, created_at). The session must belong to the specified user. |

The three kinds are independent. Reading the directory and then opening a transcript needs both User activity and Conversation content, for you and for the app's builder. Every request rechecks both people's access, so a change reaches apps that are already open on their next request. All reads and access changes are audited. Responses are private and not cacheable; keep sensitive results in memory and clear them when access is refused.

Access never opens the admin console or allows any write. Anonymous or invalid sessions receive 401; missing access (on either side), undeclared operations and draft Test windows receive 403. This capability uses the existing v1 SDK transport and does not change the runtime protocol.

For a live SDK check, node test/e2e/admin-data.mjs takes ADMIN_DATA_API, ADMIN_DATA_TOKEN, ADMIN_DATA_DENIED_TOKEN (two published-app session tokens: the first from a person who, like the app's builder, has all three kinds of access; the second from a person without access), ADMIN_DATA_USER_ID, and ADMIN_DATA_SESSION_ID. It reads all four operations and verifies the other account is refused, printing a JSON result without tokens or user content.

Hosted service handlers

@terminus-ai/app-sdk/service defines a server-side operation. Each invocation gets a fresh SDK bridge, caller identity, cancellation signal, and (when declared) a short-lived guarded browsing proxy. It never receives the publisher's session or a connector secret.

import { defineService } from "@terminus-ai/app-sdk/service";
export default defineService({
  echo: {
    description: "Echo a message",
    input: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
    async run(input, ctx) { return { message: input.message, userId: ctx.userId }; },
  },
});

The Node host is serveService(service) from @terminus-ai/app-sdk/service/node. It reads PORT and the platform-provided TERMINUS_RUNTIME_URL. Other hosts can call the Fetch-compatible service.fetch(request, { runtimeUrl }) directly. service.openapi is the generated contract used by the CLI.

Runtime access must be declared on the operation (runtime: ["GET /data/*"]) and explicitly delegated by the calling app with the same delegate option on terminus.services.invoke. The backend rechecks the original app session and its permissions on every callback. A direct catalog or agent call has identity and declared network access, but no app's data context. Declaring a runtime door alone does not grant access. Templates match one path segment per *.

Declare network: "public-web" and use await ctx.network.proxy() for guarded browsing. The returned URL is a credential: keep it out of logs and responses. Services use bounded request/response operations; the broker does not support persistent event streams or nested service calls. Files and JSON responses are supported, with an 8 MiB transport limit and at most 128 broker calls per invocation.

Updating delivered current state

An app can opt a personal collection into author-only fan-out updates:

const messages = db.collection("messages", {
  spaceId,
  ownership: "personal",
  permissions: { update: "deny" },
  deliveryUpdates: {
    fields: ["text", "unsent"],
    maxAgeSeconds: 900,
    sealedField: "unsent",
    sealWithinSeconds: 120,
  },
});
const record = await messages.getRecord(id);
if (!record) throw new Error("Message no longer exists");
await messages.deliver(id, { ...record.value, text: "Edited" }, {
  spaceId, expectedVersion: record.version,
});

Positive expectedVersion means update; zero/omitted means first delivery. The runtime checks original authorship, allowed fields, its creation clock and matching replica versions. Updates reach only surviving original recipients who have remained members. They never restore deleted copies, acquire new recipients or ring notifications. A true sealed field permanently prevents further updates. Ordinary put follows ordinary permissions; disable it when recipients must not rewrite their own delivered copies. Wire definitions use delivery_updates.

A required single-record relation may name creatorField to bind a field in its child to the referenced record's immutable platform creator. This enables child permissions based on a channel creator without trusting a copied user ID. Parent and child must share the owner/app/scope; create the parent before the child in a transaction.

Generated stream operation and direct delivery mutation IDs use t1_<Unix milliseconds>_<random> and accept retries for seven days (with at most 60 seconds future clock skew). Expired IDs are refused even after receipts are removed. Persist the same ID while retrying; do not turn an expired retry into a new write. Explicit caller IDs retain their permanent idempotency contract.