@terminus-ai/cli
v0.0.4
Published
Terminus CLI (`terminus`): search and use skills, and develop, run, and publish apps, services, and agents on the Terminus platform.
Readme
@terminus-ai/cli
Lightweight Terminus tooling for developers working on their own machines (Node.js 22.16 or newer; zero runtime dependencies). (Inside the Terminus platform itself there is no CLI: apps are created and staged natively by asking Norbert, and published from the Store UI.)
Installs the terminus command (once the package's first npm publish lands —
until then install from the repo: npm install -g github:Terminus-Intelligence/terminus-cli):
npm install -g @terminus-ai/cli(One-off runs work too: npx @terminus-ai/cli status.)
Catalog search
Search the Terminus catalog as your account (run terminus login first):
terminus search "browser automation"
terminus skills use "browser automation"The installed binary is also available as terminus-cli:
terminus-cli search "browser automation"
terminus-cli skills use "browser automation"skills use searches the catalog, prompts for a numbered result, records the selected use on your
account, and prints the selected skill content for the agent to follow. In non-interactive
shells, pass an explicit selection:
terminus-cli skills use "browser automation" --select 1
terminus-cli skills use "browser automation" --use-firstWhen the argument is a skill address or id instead of a query, skills use skips search and loads
that exact skill (installed pointer skills rely on this):
terminus-cli skills use "@zarazhangrui/frontend-slides"Installing skills (pointers by default)
terminus skills install <skill> --agent claude|codex|both adds a skill to a coding agent so it auto-triggers in future sessions. By default it
writes a small pointer stub — the skill's name and description plus instructions to load the real
content from Terminus at use time — so content stays fresh on the platform and every use is
counted. --local copies the full package to disk instead, like npx skills; local copies do
not count uses, and terminus skills update refreshes them. Either way the content comes through
terminus skills use, which serves open-source skills only: a skill whose content is closed is
used on Terminus itself, and skills install, fetch, files, and file refuse it (exit 77) —
though its owner can still fetch it and read its files.
terminus skills install "@zarazhangrui/frontend-slides" --agent claude # → ./.claude/skills/
terminus skills install "@zarazhangrui/frontend-slides" --agent claude --global # → ~/.claude/skills/
terminus skills install "@zarazhangrui/frontend-slides" --agent codex # → ./.codex/skills/
terminus skills install "@zarazhangrui/frontend-slides" --local # full copy (open-source only)
terminus skills list
terminus skills update # or: terminus skills update frontend-slides
terminus skills uninstall frontend-slidesWithout --agent, install picks the agent you are running in (Codex when its environment says
so), else Claude Code. Installs are recorded in a .terminus-install.json manifest inside the skill
directory; skills uninstall only removes directories that have one. Every skill command lives
under terminus skills, and terminus skills on its own lists them. terminus skills update
rewrites a pointer an older CLI wrote into today's wording.
skills update looks every install up by the uid its manifest recorded (signed in, never by
search, so a skill that is gone is never swapped for a look-alike):
- A pointer already loads the latest instructions at each use, so update only rewrites the stub, and only when it would read differently: a new name, description, or address, or wording from an older terminus. The folder keeps its name. No content is delivered and no use is counted.
- A
--localcopy is downloaded again when the skill's change hash moved; that delivery counts as a use, like the install did. - A skill that left Terminus, or a local copy whose skill is no longer open source, is reported and left as it is; the command then exits 1.
Fetching supporting files
Skills that reference scripts, templates, or other package files can be materialized into a
revision-keyed local cache (~/.terminus/cache/<uid>@<change_hash>/). The cache is ephemeral: safe to
delete anytime, reused until the publisher ships a new revision.
terminus skills fetch "@zarazhangrui/frontend-slides"
terminus skills fetch "@zarazhangrui/frontend-slides" --refreshScaffolding a new skill
terminus init my-skill --name my-skill --description "One sentence on when agents should use this."
terminus validate my-skill
# make the skill on the web (Creations → New creation); it shows the address
terminus remote add @you/my-skill my-skill
terminus push my-skillThen publish it from its page on the web; terminus push prints the link.
Login
login checks the local session first. If you are already logged in it prints your current profile
name and @username.
Otherwise it opens the browser login and stores a local device session at
~/.terminus/session.json.
terminus loginSee who you are signed in as and what is waiting for you:
terminus account # Hi, Winston Gao (@winstongu) / Notifications: 3 · Creations: 5
terminus notifications # invitations, then unread notifications (--all adds read ones)
terminus creations # what you have published (and anything Terminus has suspended)
terminus drafts # what you have not published yetaccount refreshes your profile, then counts the two things the web keeps for you:
Notifications is what the notification box's badge counts (pending invitations to collaborate
or to join a chat, plus unread notifications), and Creations is how many apps, agents,
services, and skills you have published, the way Creations on the web lists them (not ones shared
with you, not the platform's official rows, not external skill mirrors). A count that cannot be read
drops out of the line; on the session's last day a third line says it is about to expire. Signed
out, it says how to log in and exits with status 3, so scripts can check. Its JSON output never
includes the session token. Reading notifications here does not mark them read. (terminus status
is the working-copy command; outside a package it points here.)
Browser login opens https://www.terminus.build/login with a loopback callback. After the user signs
in, the web app hands the callback a five-minute, one-time CLI login code bound to this process with
PKCE S256. The CLI exchanges it via
POST /v1/auth/cli-login-code/exchange for a seven-day database-backed session associated with this
device. The browser tab says "You're signed in" only after that exchange succeeds; if Terminus
refuses it, the tab shows the reason instead. The session is stored with owner-only permissions.
terminus logout revokes it immediately before clearing the local file. System Settings → General
→ Devices on the web shows its computer, platform, client version, activity, and expiration, and
can revoke it remotely.
For a fully local auth test, run both the local API and local frontend and point the CLI at both:
terminus login --api-base http://localhost:3001 --web-base http://localhost:3000The hosted login page intentionally refuses a localhost API target so a crafted production login link cannot send credentials to an arbitrary service on the user's machine.
For automation (a coding agent in a container, a script), set TERMINUS_TOKEN to a session
token; it takes precedence over the saved login and terminus account names its account. There
are no API keys: every token is a device session, so it expires after seven days and is revoked
with its device.
Catalog commands (search and every skill command) require a signed-in account; there is no
anonymous lane. A 401 from Terminus usually means the login expired or was revoked on the web; the
error says to run terminus login.
How creations move (the Git and GitHub model)
Every creation starts on the web — Creations → New creation
(https://www.terminus.build/os?open=create) — which gives it its address, the
way a new GitHub repository gets its URL. The CLI moves files between a folder
and a creation that already exists; it never makes or publishes one (a fork is
made by Terminus, the way GitHub's Fork button makes one):
terminus clone @you/my-app # a fresh folder, linked (git clone)
terminus remote add @you/my-app # connect a folder you already have (git remote add)
terminus push # upload your changes to its draft (git push)
terminus pull # bring the draft down (git pull)
terminus fork @someone/their-app # fork it on Terminus, then clone your forkfork makes free forks. When a creation charges for forking, fork says the
price and prints the creation's page: the fork is bought there, and then
terminus clone brings your fork down.
Publishing is done on the web, from the creation's page: that is where the
version is chosen and what people get changes. terminus push prints the page.
Developing a skill
One of your own skills comes down the same way an app does: terminus clone
@you/my-skill writes its SKILL.md and package files into a fresh directory and
records the skill's id in the frontmatter (terminus remote add records it in a
folder you already have), so a push updates that exact skill even if you rename
it. terminus push uploads SKILL.md and the files it references.
A skill keeps a draft beside its live version, exactly as an app does: a push
lands on the draft, and what people are using does not move until the skill is
published again on the web. terminus status, diff, log, pull, and
restore read a skill folder the same way they read an app's. Publishing, and
a skill's source visibility, billing, and price, are set on the web.
Commands
Browser apps can use a zero-build starter or a full framework project:
terminus init app my-app --template react # positional kind shorthand
terminus init my-app --kind app --template react # vanilla | react | svelte
cd my-app && npm install
terminus remote add @you/my-app # the address the web gave it; writes id into terminus.json
npm run build # required before starting the runtime
terminus dev # terminal 1: the app runtime (/_terminus) + fixture data
npm run dev # optional terminal 2: hot reload; proxies /_terminus
terminus push # builds, validates, uploads the draftCloning an empty creation gives a folder holding only its address;
terminus init <kind> . fills it in and keeps that link.
The same shorthand works for agent, service, and skill (including the
plural aliases). Every creation versions the same way: strict
MAJOR.MINOR.PATCH, starting at 0.0.1 — app, agent, and service scaffolds
carry "version": "0.0.1" in terminus.json, and skills declare version:
in their SKILL.md frontmatter. The version is chosen when you publish on the
web, and every release must be strictly above the latest; you pick the bump
(patch, minor, or major), the platform only requires that there is one.
Pushing hashes files locally and sends only the bundle index through the Terminus API. Missing content hashes stream directly from the CLI to short-lived, checksum-bound S3 upload URLs with bounded concurrency; repeated assets are deduplicated. Bundle bytes are therefore not base64-expanded into one JSON request, and the former 256-file / 4 MiB-file / 16 MiB-bundle CLI caps do not apply. The platform retains configurable operational guardrails for abuse and storage safety.
terminus dev first requires the configured bundle entry point (normally
dist/index.html). If it is missing, the command exits before it creates
fixture state or binds ports and tells you to run npm run build.
It then serves the production runtime protocol over a local fixture data space,
one port per simulated member. Plain terminus dev is Alan alone;
--members 3 creates Alan, Bob, and
Carol; counts from 1 through 26 use the stable Alan-to-Zoe directory, with a
display name, bio, deterministic default avatar, and local public profile for
every account. Comma-separated handles such as --members alice,bob remain
supported for compatibility and receive generated profiles too. It also serves
the built bundle from disk, so terminus build + refresh works without vite.
The first member uses port 8868 by default and later members use consecutive
ports; --port 5000 changes the starting port.
A coding agent can use the running local app from a second terminal in the same app directory (or a subdirectory), with no login or credential files:
terminus apps notes --dev
terminus apps notes create_page --title "Test plan" --dev
terminus apps notes get_page --page-name "Test plan" --dev
terminus apps notes --dev --member bob--dev discovers this project's runtime; it never falls back to production or
uses your saved login or TERMINUS_TOKEN. The default test user is the first
member passed to terminus dev; --member selects another. The local target
prints to stderr, keeping --json stdout machine-readable. Explicit --api-base
and --web-base cannot be combined with --dev.
Access starts Off for each member. Open the App access URL printed by
terminus dev or terminus apps notes --dev in a browser. The human selects
Off, Read only, or Read and write, and personal/shared data scopes. CLI commands
cannot grant themselves access. These are trusted local fixture permissions,
not production account authentication. Changes to the app's server code require
restarting dev and reviewing access again. The app's own operation schema drives
the commands, so this works for any app with agent operations.
The runtime records its ports and instance ID in .terminus/dev-target.json.
The CLI verifies the live instance, project, app and member before issuing any
operation. A stopped or mismatched runtime fails locally. Only one local runtime
can run per project directory; use a separate copy for independent fixtures.
--dev targets the local SQLite harness, not terminus dev --remote.
Every dev server (this harness, --remote, agents, services) listens on
127.0.0.1 only: dev tokens, fixture data, and capabilities billed to you are
never reachable from another machine. localhost reaches it, and a port that
another program holds on any loopback address, 127.0.0.1, ::1, or all
interfaces, counts as taken, so a browser resolving localhost never lands on
that program instead. Each one also answers only requests addressed to a
loopback name (localhost, *.localhost, 127.x, or [::1], at any port):
a web page that re-points its own domain at 127.0.0.1 still sends its own
Host, and gets a 403 rather than a dev token. Proxies that keep a loopback
Host, like the scaffolded Vite proxy, pass. The app runtime (/_terminus/*)
and the harness's own controls (/__terminus_dev/*) also refuse what a page
on another site sends blind: an Origin that isn't a loopback page, or
Sec-Fetch-Site: cross-site on a request with no Origin (an <img>, a
no-cors GET). Loopback pages at any port, the Vite dev server's included,
and clients that send neither header (curl, scripts, the SDK outside a
browser) pass, and the app's pages stay open to a link from anywhere. Agent
and service dev apply the same test to their token-gated /api doors.
Before --fresh clears fixture data, the harness checks the entire requested
port range. If a listener is already present, startup reports every blocked
port and its simulated member, and says who holds it: another terminus dev by
its app, folder, member, PID, and uptime (every harness answers
GET /__terminus_dev/about), and any other program by its
command line, folder, PID, and uptime where ps and lsof can tell; otherwise
it prints a command to inspect the listener. It then says how to stop the
holder and suggests a currently free consecutive range with a copyable version
of the original command. When the holder is this same folder's harness, it
reports the app as already running instead, since two harnesses on one folder
would overwrite each other's spaces. An explicit --port is replaced in that
suggestion, never changed silently; when using the scaffolded Vite proxy, point
its target at the chosen port. Agent and service dev report a busy explicit
--port the same way, before they start the engine or import the draft;
without --port they take the next free port.
--guest adds one more port, after the members': a visitor who is not signed
in, the way an app open to guests meets one on Terminus. There the bootstrap
is the platform's guest bootstrap (guest: true, no installation, no user),
the app's own files are served (without the notification corner: a guest has
no account), and a capability's assets are forwarded for a capability the app
declares, fetched with no credential, as the app host fetches a guest's. Every
other /_terminus door answers 401 unauthorized ("Sign in to use this (guest
mode)"), as production does, and so do the harness's own controls. The app's
session.signIn() opens /_terminus/signin?return_to=<path>: on the guest's
port that is a page listing the members, and picking one (a same-origin form
POST, never a link) makes the port that member, on the same origin, so what the
SDK kept for the guest in the browser moves into the account, and returns to the
path. A return_to that is not a path on this origin returns to /. Signing
out there (/_terminus/logout) makes the port a guest again. On a member's
port, sign-in goes straight back to the path.
User lookup and app-owned space/member operations are intrinsic, app-scoped SDK
calls. Optional cross-boundary authority such as network_proxy still refuses
locally unless the release declares it.
The fixture runtime also mirrors the production space contract. A direct
space is limited to two people, rejects group member actions, and persists
peer block/unblock state; a group persists owner/admin/editor/viewer roles (a
viewer reads the space and writes nothing in it) and enforces rename, invite,
role-change, removal, and leave permissions. Space rows carry the same
my_role and capabilities fields that apps render in production, and a
direct space names the other person as its peer — the person invited,
until they answer.
When one simulated member starts a chat or sends a space invitation, the invitee's local app origin shows a Terminus-owned request popup with Accept, Decline, and a close-for-now button. Closing hides that request for the life of the page without accepting or declining it; reloading shows an unresolved request again.
Being asked to talk to one person and being added to a room are different
questions, so the card is built from the space's kind and asks each on its
own terms. A direct request leads with the person — their face in
a circle, "@handle wants to chat", and Accept. A group invitation leads
with the room — its mark in a rounded square, its name, who invited you, the
people already in it, and Join. Pending requests are shown one at a time
with the queue depth beside them, and GET /__terminus_dev/requests carries
kind, a ready-made title, and the current members so any surface can
draw the same distinction. The control is injected into served HTML
by the local harness and lives outside /_terminus, so apps still cannot
consent on a user's behalf through the SDK. The JSON controls remain available
for automated tests at GET /__terminus_dev/requests and
POST /__terminus_dev/requests/{id}/{accept|decline}. Like the desk's corner,
it hears the member's account frames live — GET
/__terminus_dev/notifications/stream, which never counts as the member
being online — rather than polling. dev --remote does not
inject this simulator because production consent belongs to the Terminus
notification surfaces.
terminus dev --remote keeps the local ui/ bundle but sends /_terminus/*
to the production runtime instead of the fixture space: the CLI uses your
terminus login session to mint an app session exactly as the hosted app
origin does (POST /v1/app-runtime/authorizations → POST
/v1/app-runtime/session), then proxies every runtime call with that bearer and
streams SSE feeds through. Signing out of the app (/_terminus/logout) revokes
that session as the hosted origin does, and opening the app again mints a new
one. It needs a published app whose address matches the package id and an
installation for your account. Sign-in (/_terminus/signin) goes straight
back to the path it names, minting a new session first when signed out. You are
acting on real data under real quotas; the SQLite harness stays the default and
--members, --profiles, --guest, and --fresh do not apply.
Use --profiles only when a test needs custom identities. If --members is
omitted, the JSON keys become the member list; omitted avatars keep their
generated defaults, and avatar paths are resolved relative to the JSON file:
{
"alice": {
"name": "Alice Chen",
"bio": "Designing calm collaboration tools.",
"avatar": "./fixtures/alice.png"
},
"bob": {
"name": "Bob Li",
"bio": "Local test account"
}
}terminus dev . --profiles ./dev-profiles.json --freshready().user, users.lookup/search, and spaces.members then expose the
same { id, handle, displayName, avatarUrl, publicProfileUrl } SDK shape as
published apps. Avatar and public-profile URLs remain same-origin and open
local fixture content. The profile file is development input only; exclude it
with .terminusignore if it should not be included in source sync.
The online editor and CLI share one canonical source draft, identified by
the committed id in terminus.json and an increasing server draft revision.
Bring a creation made on the web down with terminus clone @you/app, or run
terminus remote add @you/app inside an existing directory. Link writes that
canonical address into terminus.json (a service is named by it too); there is
no second local identity file. After that, from the project directory:
terminus status # what changed here, and what the draft has that you do not
terminus diff # those changes, line by line
terminus pull # merge the draft's commits into this folder
terminus push --message "..." # send yours, as one entry in the draft's history
terminus log # the draft's commits, newest first
terminus restore <commit> # put the draft back the way it wasEach push is one commit to the draft (POST /v1/apps/{id}/draft/commits): the
files that differ from the draft's head, the ones the folder no longer has,
and the fields terminus.json compiles to. The draft keeps every commit —
pushes, web-editor saves, a version chosen on the web — and its page shows
them under History, with the --message a push gave. A push that changes
nothing records nothing ("Everything up to date"). A service's push goes
through its import door, which compiles the OpenAPI contract and records the
import the same way.
The folder remembers which commit it matches, in .terminus/sync.json (git's
origin/main, with a .gitignore of its own so it stays out of your
repository). That is what makes the loop behave like git:
- push refuses a non-fast-forward. If the draft has commits this folder
has not pulled, push says so and stops rather than replacing them;
--forceis the deliberate override. - pull merges. A file only the draft changed is taken, one only you
changed is kept, one both changed is merged line by line, and
terminus.jsonmerges key by key. What cannot be merged is written between<<<<<<< yours/>>>>>>> draftmarkers and named; push waits until you resolve it.pull --forcereplaces the folder with the draft instead. - restore is a revert, not a reset. It puts the draft's files and compiled settings back to an earlier commit as a NEW commit, so the state it replaced stays in the history.
Git remains your local history: Terminus keeps the draft's, one commit per push or save.
A push never publishes: open the creation's page (push prints it) and publish there, which releases the draft as it stands.
clone answers for a skill you own as well. Someone else's open-source
creation clones read-only — the files of its latest release, with
terminus pull bringing newer ones in. It cannot be pushed; terminus fork
makes a copy in your account that can.
An app package can be as small as:
{
"kind": "app",
"id": "@publisher/chat",
"version": "0.0.1"
}Name, description, icon, listing, source visibility, and commerce settings are
set on the web and are never duplicated in this file. The app uses the stable
same-origin /_terminus/icon URL; the hosted worker and terminus dev resolve
the icon set on the web, so generated icons do not become repository assets and
no icon pull command is needed.
Developer-authored terminus.json files — app, agent, and service alike —
have no top-level schema_version. The CLI accepts older app and agent files
that include one, removes it when reconstructing a working copy, and compiles
the current authoring format into the backend's internally versioned release
manifest; a service file that still carries the line is asked to delete it.
Source discovery respects two layers. .gitignore defines the repository's
normal source tree. Optional .terminusignore removes additional files from
Terminus source sync even when Git tracks them—for example local fixtures or
internal design notes. Neither file controls the explicitly configured runtime
payload: terminus push runs the build and attaches that output as a derived,
content-addressed bundle bound to the resulting source revision. The platform
serves that bundle, but it is not editable state and is never pulled back into
the working tree.
Secrets, dependency trees (node_modules/), Git metadata, and local dev fixtures
are denied in every plane even if an ignore file or Git index includes them. A
source edit makes the previous bundle stale; publishing succeeds only after a
fresh bundle is attached for that exact revision. A differing pull stops
without changing files unless --force is supplied.
Published apps are normal multi-file websites on their own
*.apps.terminus.build origin. The SDK uses same-origin, app-scoped APIs for
managed collections, storage buckets, the person's own files (the private and
output zones), spaces, collaborative events, notifications, logs, and the
external services an app's code names. The user's Terminus login token is never exposed to
app JavaScript. Managed collections are defined once where app code opens them
(db.collection(...)) and are provisioned lazily for the installed
release; they are not duplicated in terminus.json (only a collection that
nothing but an automation writes is declared there, below). For conventional npm
projects the CLI also infers npm run build and dist/, so ui is needed only
as a nonstandard override. The manifest stays focused on canonical identity and
technical release configuration.
Every generated starter imports the SDK by name (import { db, ready } from
"@terminus-ai/app-sdk"), so its bundle carries only the namespaces it uses. It
is collection-first: its sample UI opens a live items collection, renders the
persistent IndexedDB-backed cached view, and uses optimistic put/delete
mutations. The generated AGENTS.md records the same architecture for future
coding agents: collections for structured state, storage buckets and files for
blobs and exported files. App code must not add an app-owned backend database;
user-owned capsules stay independent of the app release and therefore survive
forks, upgrades, and migrations.
Apps declare background work as data, not as a deployment.
workloads.automations is a bounded list of JSON steps using only
collection.put, collection.delete, notification.create,
connector.request, service.invoke, and server.run.
workloads.schedules attaches a five-field cron plus an optional IANA
timezone (UTC by default) to one automation, and lifecycle.migrations advances an
integer app-data version through the same durable job path.
An app that needs logic the browser cannot run keeps it in server/:
standard-library Node in server/main.js, exporting its ops
(exports.ops = { top(input, { terminus, ctx }) { … } }), or Python in
server/main.py. Nothing about it is written in terminus.json: what the
server code may do is read from the code itself, as it is for everything else
an app uses:
- An op exists because something calls it: the app, with
server.call("<op>", input)from@terminus-ai/app-sdk, or an automation, with aserver.runstep — always by literal name. An op the app calls runs within 10 s; an op only automations run is background-only and runs within 120 s. - Records are the scopes and collections the server code's literal
terminus.records.<get|put|delete|query|update>("<scope>", "<collection>", …)calls name:globalis one pool for the whole app,installationone per user. - Every op called must be one the entry exports;
terminus validateloads the entry to check.
terminus.records.query answers one page, {documents, next_cursor}: by
doc id, or by one top-level field with sort: "-best", at most limit
(1–100) documents, and the next page with after: next_cursor.
terminus.records.update(scope, collection, doc, fn) is the compare-and-set
loop in one call: it reads the record, writes what fn(current) returns
against the version it read, and reads again on a version_conflict. A
refused syscall throws an Error whose code and status are the
platform's (version_conflict 409, quota_exceeded 402, …) — branch on
error.code, never on the message.
The CLI ships server/ as release materials with role: "server" (runtime
code: it travels with a closed-source release and never enters the browser
bundle) and holds the entrypoint to 128 KiB and server/ to 512 KiB.
terminus dev runs it the way the platform does: every server.call and
every server.run step runs the op in a fresh sandbox — the standard
library and server/ siblings only, no network but terminus.webFetch, no
files outside server/, the op's time budget — under the platform's
records, capsule, fetch and collect rules, limits and error messages.
Records live in .terminus/dev/ (one global pool for the app, one
installation pool per member), and each op's console output prints in the
terminal. Node server code runs locally on macOS and Linux (where one
syscall argument, such as a records.put document, is capped at 128 KiB —
tighter than on the platform); Python needs python3.
Agents do not use workloads at all: an agent is its own orchestrator, so a
trigger on an agent just means "wake the agent". Agents declare four
top-level prompt-form sections, each entry carrying at most a prompt (what
the wake says — optional, since AGENT.md already says what the agent does)
and enabled. Every standing run notifies — there is no toggle: the
notification is a compact card (the agent's identity, a short preview the
platform derives from the reply's first line, and the agent as the
click-through), and a run whose reply is empty sends nothing:
schedules(≤64): a structured cadence —{ "every": "day" | a weekday | "month" | "30m" | "2h", "at": "HH:MM", "on_day": 1-31, "timezone": IANA or"user"}— or a raw five-fieldcronescape hatch."user"means each installer's own timezone: the platform resolves it per installation (captured at install approval, re-resolved hourly), so "every Monday morning" is every user's own Monday morning.webhooks({ name?, prompt?, description?, enabled?, coalesce_seconds? }, ≤16): installing provisions one capability-URL endpoint per webhook; the person who installed the agent makes its URL in the installation's Automations window on Terminus, which shows it once (making a new one revokes the old), and deliveries atPOST /v1/hooks/{token}wake the agent with the payload.watches({ name?, url, pattern?, interval_minutes?, condition?, prompt?, description?, enabled? }, ≤8, https only, 15-minute floor, hourly by default): the platform polls the source, extracts the optional regex match, and only a real content change wakes the agent with the diff.conditiongates firing on a numeric threshold the poller evaluates with no model spend — exactly one of{ "changed_by_pct": 2 }(the watched number moved 2% from the value last fired about),{ "below": 220 }, or{ "above": 250 }(crossing the level) — so "tell me when AAPL moves 2%" costs nothing while the market sleeps.events({ name?, collection, prompt?, description?, enabled?, coalesce_seconds? }, ≤16): wakes the agent when records in one of the agent's own declared collections change.
name is optional while a section has a single entry and required to
disambiguate several. coalesce_seconds (0–3600) batches webhook/event
firings into fixed wall-clock windows delivered together when the window
closes. Event payloads are data, never instructions — the platform appends
them to the wake prompt inside an explicit untrusted-event-data fence. A
trigger that fails ten firings in a row pauses itself and notifies the
owner.
terminus validate compiles these sections into the platform's canonical
workloads wire shape (shared scheduled-run automations parametrized by
input.prompt), so the runtime — scheduling, fatigue, the durable
runner — is identical to an app's. Publishing a workloads block on a
kind: agent manifest is refused with a migration hint. An app example:
{
"workloads": {
"automations": [{
"name": "remind",
"steps": [{
"action": "notification.create",
"params": { "title": { "$from": "input.title" } }
}]
}],
"schedules": [{
"name": "morning",
"cron": "0 9 * * *",
"automation": "remind",
"input": { "title": "Good morning" }
}]
},
"shell": { "notifications": true },
"lifecycle": { "data_version": 1, "migrations": [] }
}terminus validate checks names, cron syntax, migration chains, action
permissions, and explicit input.* / context.* substitutions. terminus
dev simulates collection and notification actions plus job/schedule/lifecycle
SDK routes. Connector and external-service actions stay broker boundaries and
should be mocked in local app tests. Unlike collections opened only by browser
code, a collection referenced by a background automation must also be declared
in resources.collections; this gives the platform a release-approved schema
to enforce when no browser session is present.
Agent packages keep their prompt in AGENT.md, any files the agent should
know beside it (the whole package ships read-only in the agent's working
directory, at its own paths), and a small semantic tool list in
terminus.json.
Terminus compiles
those ids into the detailed release grant; OAuth tokens, connection ids, and
per-user resource grants never enter the package. A scheduled inbox agent can
look like this:
{
"kind": "agent",
"id": "@publisher/daily-inbox",
"version": "0.0.1",
"tools": [
{
"id": "gmail.read",
"when": "Prepare the user's daily inbox digest."
}
],
"schedules": [{
"every": "day",
"at": "07:00",
"timezone": "America/Los_Angeles",
"prompt": "Summarize what happened in my inbox yesterday."
}]
}That one entry is the whole standing life: the agent runs every morning at
7:00 Pacific, and a compact notification lands — the agent, one preview
line drawn from the reply's opening, and a tap-through to the agent (a
morning with nothing to say sends nothing). The prompt is optional —
without it the wake just tells the agent which schedule fired and
AGENT.md carries the task — and shell.notifications is derived from
declaring a trigger, never hand-written.
Run terminus connectors gmail to inspect the currently supported Gmail tool
ids, operations, and scopes. Then terminus validate . and terminus dev .
— local agent dev: the loop runs on your machine (bash/python execute
natively inside a workspace-write sandbox over the lane workspace in
.terminus/dev/agent/), while the system prompt and tool definitions
compile server-side from your manifest, so local runs always match what
publishing produces. It works immediately after init — no creation or link
required — and terminus dev . --prompt "…" [--json] gives scripts and coding
agents a one-shot lane.
An agent chooses its models in one models section. terminus init agent
writes the house configuration, which is also what an agent that says nothing
gets: every model on Terminus, starting on GPT-5.6 Luna.
"models": { "default": "openai:gpt-5.6-luna", "mode": "all" }"mode": "selective" with an "available" list narrows the choice, and
"mode": "default" keeps the agent on its default alone.
An agent's type says who can read its conversations. Left out, it is
"default": each conversation stays private to the person having it.
"observed" shares every conversation with the agent's maintainers, and
people accept a notice saying so before their first message. The
inspector's Type switch writes the key for you.
"type": "observed"Declared triggers are exercisable locally before publishing: the
inspector's Schedules section shows every trigger as a card — the cadence in
words, the wake prompt, whether it notifies — with add, edit, and remove
writing terminus.json in place (webhooks also accept real deliveries at
the local POST /hooks/{token} URL the webhook's card shows; point curl or a
tunnel at it. As on the platform, the token is the whole capability: it
admits a delivery under any Host, and each webhook keeps its token in
.terminus/dev/agent/webhook-tokens.json, so a tunnel survives restarts;
delete the file to rotate them), and
terminus dev . --trigger <name> [--payload file.json] [--json] is the
headless lane. A simulated firing builds the trigger's input the way the
platform builds it, runs the wake as a real local turn behind the same
untrusted-event fence, and reports the notification (or its skip, when the
run produced no text) on the transcript. Connect your own Gmail account in Terminus to
exercise gmail.read live; before publishing, terminus dev . --remote
runs the same conversation on the platform's real engine and real isolate.
Publishing and installing the release binds the same gmail.read
declaration to each visitor's own Gmail connection; the hosted schedule then
runs on that visitor's durable VM-free session.
Service artifacts describe atomic callable APIs. runtime.kind says how the
platform reaches the implementation: external names an HTTP endpoint (yours,
platform-hosted, or a third party's — the authentication mode and explicit
commerce fields carry that difference, while provider_relationship,
upstream_cost_bearer, payout_recipient, and health_path default to the
first-party posture owned/none/publisher//health/ready); builtin
names an engine implemented inside the Terminus backend and carries nothing
else.
terminus init my-converter --kind service # describe an existing HTTPS API
# set runtime.base_url and the auth mode in terminus.json; state
# provider_relationship/cost bearer only when they differ from the defaults
terminus validate my-converter # validates the manifest + OpenAPI
# make the service on the web (Creations → New creation), then connect the folder
terminus remote add @you/my-converter my-converter # names the package after its address
# deploy the API on AWS/Azure/etc; test updates the service's private draft
terminus service test my-converter document.convert --file sample.pdf
terminus dev my-converter # serves the package's dev/ test page
terminus push my-converter # uploads the draft; publish it on the web
For a hosted service, create its container deployment in the web hosting panel,
select it for the draft, then clone or pull the service. Its runtime contains
only {"kind":"hosted","deployment_id":"<uuid>"}. push, service test
and dev preserve that binding; calls go through the authenticated gateway.
Container environment variables and secrets stay in the hosting panel.
Draft tests have no marketplace charge; hosted compute and external provider
charges still apply.
terminus dev <service-dir> starts a local test session on its own port: it
refreshes the linked service's draft from the folder, serves the package's own dev/index.html (plus siblings in
dev/), and relays invocations through the draft-test lane — a direct
signed call for public terminus_signed endpoints, the gateway test door
otherwise. The page contract is host-provided: the harness injects
window.__SERVICE_DEV__ = { service, operations, invoke }, and the page calls
invoke(operationId, { input, query, bytes, idempotencyKey }) — input for
JSON operations, bytes (an ArrayBuffer or Blob) with query parameters for
binary ones — and receives the test-lane result { status, content_type,
body, body_base64, … }. Never hard-code a transport: dev/ files upload
with the release (role: "dev", outside every runtime plane), and whatever
serves the page later provides its own invoke.
For an external service, openapi.json is the machine contract and README.md
is human/agent guidance. The contract lives at the repository root by default;
terminus.json states an openapi path only when the file deliberately lives
elsewhere, and a package with neither is refused. The manifest otherwise
contains the unscoped package name, version, description, endpoint,
provider/cost relationship, payout policy, and authentication mode. API-key or
OAuth secrets are referenced by environment-variable name and uploaded
separately; their values never enter the package or release. The backend is the
authoritative parser: each test/publish uploads the raw package, compiles
OpenAPI, and creates or updates the private service draft and binding. Terminus
retains the publisher address, price, icon, listing, release/verification state,
and encrypted secrets. Terminus-signed tests receive a short-lived scoped JWT
and call the manifest endpoint directly; other auth modes use the broker. No
credits move during tests, and there is no arbitrary URL override.
Terminus does not execute service-package code. Browser apps call only the external operations declared in their release; the broker supplies scoped identity, enforces consent and billing, and keeps provider credentials out of browser JavaScript.
Bare terminus (or terminus help) prints its version and the everyday commands, grouped, each
with what it does on one line (<word> is a placeholder, [ ] is optional):
Terminus v0.0.4, created by Terminus Intelligence
Account:
terminus login Open your browser and sign in
terminus account Your account's station
terminus notifications Check your notifications
Explore:
terminus search <query> Search the Terminus catalog
Developing:
terminus init <kind> [<dir>] Start a new creation
terminus clone <address> [<dir>] Download a creation to edit
terminus remote add <address> [<dir>] Connect a folder to a creation
terminus dev [<dir>] Mock your creation locally
terminus validate [<dir>] Check a package for problems
terminus status [<dir>] Compare your copy with Terminus
terminus pull [<dir>] Merge the draft's changes into yours
terminus push [<dir>] Upload your changes as a draft
terminus logs <app> Show a published app's logs
Mock it locally so your coding agent can test end to end — an app:
terminus clone @you/app && cd app
npm run build # apps are built before they run
terminus dev --members 3 # three at once; --fresh to start empty
terminus push --message "Rooms can be renamed"
An agent, which has no build step:
terminus clone @you/agent && cd agent
terminus dev # a chat page; --remote for real data
terminus dev --prompt "Two lines on today's inbox"
terminus push --message "Sharper summary"
Every other key, --port included, and what each kind runs: terminus help dev
Install Skills:
terminus skills Install skills in Claude Code or Codex
Management:
terminus creations Manage your published creations
terminus drafts Manage your drafts
Further help:
terminus commands List every command
terminus help [<command>] Show how to use a command
https://www.terminus.build/docs/terminus commands lists every command, then the options, environment
variables, and exit codes they share; terminus help <command> and
terminus <command> --help show one command's usage, options, and examples, the
way brew search --help does. terminus skills on its own lists every skills
command, each with what it does. A command given the wrong arguments prints that
same help, then Error: Invalid usage: … naming what was wrong (--json keeps
just that line). Both blocks here are the CLI's exact output, kept in sync by a
test.
Usage: terminus <command> [<arguments>] [<options>]
Account:
login Sign in to Terminus in your browser
logout Sign out and revoke this device's session
account Your account's station
notifications Check your notifications
Explore:
search Search the catalog
Developing:
init Create a new app, agent, service, or skill
clone Copy a creation into a new folder
fork Fork someone's open-source creation and clone it
remote Show or change the creation a folder is connected to
dev Run a package on this machine
build Build an app's browser bundle
validate Check a package for problems before pushing
status Compare your working copy with its draft and live release
diff Show your changes, or compare two releases
pull Merge the draft's changes into your working copy
push Upload your working copy to its online draft
log Show a draft's history, or a creation's releases
restore Put the draft back the way it was at an earlier commit
logs Show a published app's recent logs
secrets Manage sealed credentials for your package
data Inspect, export, or import local dev data
service Inspect services, test operations, and run jobs
connectors List the connectors your agents can use
Install Skills:
skills Install and use skills in Claude Code or Codex
Management:
creations Manage your published creations
drafts Manage your drafts
Your published apps:
apps List apps, check access and use their operations
inspect Show a published app's runtime state
Releases:
outdated Check a creation's dependencies for newer releases
Help:
help Show help for a command
commands List every command
version Print the CLI version
Options:
-h, --help Show help for terminus or a command
-v, --version Print the CLI version
--json Print machine-readable output (most commands)
--api-base <url> Use another Terminus API (default:
https://api.terminus.build)
Environment:
TERMINUS_TOKEN Use this access token instead of your saved login
TERMINUS_API_BASE Same as --api-base
Exit codes:
0 success, 1 error, 2 bad usage, 3 not signed in, 69 Terminus unreachable,
75 rate limited, 77 not allowed. With --json, errors are printed to stderr
as JSON.
Run 'terminus help <command>' for details on a command.
Docs: https://www.terminus.build/docs/Flags are parsed strictly: an option a command does not declare (or a typo
such as --limt) fails before any network call.
The agentd binary
terminus dev <agent-dir> runs the agent on terminus-agentd. Installing
this CLI installs it too: the four @terminus-ai/agentd-* packages are
optionalDependencies carrying one prebuilt binary each, and their os
and cpu fields mean npm unpacks only the one your machine can run. Once
those packages are on npm there is no download on first use and nothing to
authenticate against. Until their first publish, which waits for initial
development to finish, agentd runs from a build of your own: lane 1, or
lane 3 staged by hand.
The CLI takes the first of these it finds:
--agentd PATHor$TERMINUS_AGENTD— point either at your own build;- a
terminus-agentdon$PATH; - the installed
@terminus-ai/agentd-*package.
On a platform with no build — Windows, or an unusual architecture — npm
skips all four packages and installs the CLI anyway; everything except
terminus dev on an agent works, and that command asks for
$TERMINUS_AGENTD.
To run terminus dev against an agentd you built yourself, set
$TERMINUS_AGENTD (lane 1) — or, to exercise the packaging itself, follow
docs/LOCAL_DEVELOPMENT.md in the terminus-agent repo.
Sealed secrets
Outbound calls a package declares can use credentials the platform keeps sealed. Set them per
linked package; values come from stdin (or $TERMINUS_SECRET_VALUE), never the command line, and
can never be read back:
pbpaste | terminus secrets set OPENAI_API_KEY
terminus secrets list
terminus secrets delete OPENAI_API_KEYExit codes
| Code | Meaning | --json error code |
| ---- | ------- | ------------------- |
| 0 | success | — |
| 1 | any other failure (another API 4xx/5xx, an invalid package, …) | error |
| 2 | usage: unknown command or option, missing argument | usage |
| 3 | not signed in: no login, an expired one, or the API answered 401 (unauthorized, session_ended) | auth |
| 69 | Terminus unreachable: connection failure, timeout, 502/503/504 | unavailable |
| 75 | rate limited (429; the message carries the retry hint) | rate_limited |
| 77 | not allowed: the API answered 403 (forbidden, grant_required), or a closed skill's content was asked for | forbidden |
With --json, a failure writes exactly one JSON object to stderr —
{"error":{"code":"…","message":"…"}} — so agents can branch on the code
without parsing prose. A failure the Terminus API answered also carries its
HTTP status, the API's own api_code (for example grant_required or
price_confirmation_required), and its details when it gave any. Without
--json the message is printed as plain text.
Reads (and any request carrying an Idempotency-Key) are retried twice, with backoff, when Terminus is briefly unavailable or the connection drops; other writes are sent once. A request has 30 seconds to be answered, plus time for what it uploads, and a download may take as long as its bytes keep arriving.
Version history
Artifacts whose listing enables source visibility (set on the web) expose their code and release history publicly — on the web (the artifact page's Code and History tabs) and locally:
terminus log @terminus/chats # release list with publish messages
terminus diff @terminus/chats # latest release vs the previous one
terminus diff @terminus/chats --from 1.0.0 --to 1.2.0
terminus diff @terminus/chats --from 1 --to 3 # release numbers work tooThe note you write when publishing on the web annotates the release, like a
commit message. Skills use the same commands over their revision ledger. Versions follow the
one grammar everywhere: "version": "1.2.0" in terminus.json (version:
frontmatter for skills) names the release; a named version can never be
reused for different content, and every publish must climb past the latest —
bump patch, minor, or major per release, your call. terminus outdated <ref>
reports every declared dependency's pin against its current version.
The CLI compiles the authored package into the backend's internal wire contract. Every runtime and source file carries its decoded byte length and SHA-256 digest, and a sorted aggregate digest identifies the complete package. The backend verifies both the file index and file bytes before accepting or materializing a release.
Presentation, visibility, and commerce settings change live on the web without a republish because they are artifact metadata, not package or release fields.
Environment
TERMINUS_TOKEN: a session token every command uses instead of the saved login.TERMINUS_API_BASE: defaults tohttps://api.terminus.build;/v1is added automatically if omitted.TERMINUS_WEB_BASE: the web app browser login opens. Defaults tohttps://www.terminus.build.TERMINUS_AGENTD: aterminus-agentdbinary forterminus devon agents (same as--agentd).TERMINUS_SECRET_VALUE: the value forterminus secrets setwhen stdin is not used.
Platform services
terminus service inspect @terminus/brave-search --json reads the published
operation contract without making a provider call. Every provider is an atomic
service under the same address/operation permissions. In app source,
services.search("@terminus/brave-search", input) and
services.fetch("@terminus/obscura", input) infer search and fetch grants.
Use literal addresses so the release can show its exact dependencies. Provider
credentials stay with each service on Terminus and never enter a package; the
agent tools web.search and web.fetch are routed across providers by the
platform. terminus service test tests your own external service package.
Durable service jobs
Submit a managed image, document, code, or email operation with a stable key:
terminus service submit SERVICE_UUID execute --input '{"runtime":"python","code":"print(2 + 2)"}' --idempotency-key calculation-123
terminus service job JOB_UUID --wait
terminus service jobs --service-id SERVICE_UUID
terminus service cancel JOB_UUID
terminus service save JOB_UUID FILE_UUID home/result.txtUse --file input.json instead of --input for larger inputs. File references
must name a home/ path and its SHA-256 version. save creates by default; add
--expected-sha256 PREVIOUS_SHA to replace exactly the version you inspected.
Temporary results expire unless saved. The list command returns the latest 50
jobs; API consumers can page using the last (created_at, job_id) tuple.
Operations are generate/edit, convert, execute, and send. SDK helper
calls compile to the corresponding service-operation grants during app build.
Image helpers grant both generation and editing. Execution supports bounded
shell/Python/JavaScript, not native packages or network access. Private jobs
require the hosted runtime; the local app harness does not emulate provider
side effects. --wait stops after five minutes or at a terminal/reconciling
state. Reuse the same key to inspect an uncertain submission; do not generate
a fresh key automatically. SMTP acceptance is distinct from inbox delivery.
Working on this CLI
npm test # the offline suite (no network)
npm link # use this checkout as `terminus`
npm uninstall -g @terminus-ai/cli # remove the linked CLIPull-request CI runs npm test and npm run build and cancels superseded runs
for the same PR; a change to docs alone skips them, except this README, whose
help blocks the suite checks. The suite is not repeated after merge; run it
locally before pushing code changes.
Releases are published from GitHub, never from a laptop: a maintainer
dispatches the manual cli-publish workflow on main, which runs the suite on
that commit and publishes it to npm as @terminus-ai/cli (with provenance once
this repository is public). The unscoped name terminus belongs to another
npm package; never publish under it.
Using installed apps from a local coding agent
The human enables access in System Settings → App access. Every app has separate Norbert and Local CLI choices: Off, Read only, or Read and write, plus personal data and selected shared spaces. The CLI cannot authorize itself. Its signed-in device identifies the account; the backend enforces app permission.
terminus apps
terminus apps notes
terminus apps notes list_pages
terminus apps notes get_page --page-id PAGE_ID
terminus apps notes append_text --page-id PAGE_ID --text "Hello"Bare apps lists names and addresses. apps notes reports Local CLI access,
data scope and operation arguments, even while Off. Denied operations exit 77
and direct the human to Settings. Use @owner/notes if names are ambiguous.
No grant files are needed. Access persists until disabled or an app update
requires review. Shared data uses --space <id> from the enabled spaces.
Arguments follow the operation schema: page_id becomes --page-id; strings
stay strings, numbers/booleans are typed, arrays/objects accept JSON. Use
--args '{"blocks":[...]}' or pipe JSON to --input - for complex input.
Choose one form. Omitted arguments mean {} and never wait on stdin.
Discovery prints readable text, or structured metadata with --json.
Calls return JSON with the invocation ID and operation result.
Use --invocation <uuid> for safe retries and
terminus apps notes --status <uuid> to inspect an invocation. After a permission
change, reconcile an unknown earlier result before starting a new write.
Disabling access stops later calls; committed actions remain committed.
These permissions govern app-agent operations, not the account owner's ordinary
APIs. Agents sharing a CLI device use the same Local CLI setting; this is not
process isolation or a replacement for an OS sandbox.
Norbert uses the native app_actions tool and points the human to Settings when
access is missing. The cloud agent never installs this CLI.
Earlier delegated credential files remain readable for compatibility, but changing Local CLI access in Settings revokes those earlier app grants. The CLI no longer grants authorization.
Four-app integration test
Build the SDK and each app's UI and agent bundle, then run:
PLAYWRIGHT_CORE_FROM=/path/to/package-with-playwright/package.json \
node test/e2e/official-app-agents.mjs /path/to/official-appsThe scenario copies Chats, Notes, Teams and Strike into throwaway directories,
starts real terminus dev servers, spawns real CLI processes, and opens their
actual UIs in Chrome after the agent operations finish. It records assertions,
checkout SHAs, dirty status and screenshots under .out/app-agent/. It uses no
production data. Backend grant/broker authorization is separately tested against
real Postgres in its app_spaces database lane.
Hosting a service from source
Create a service on the web and connect its address, then:
terminus init service my-service
cd my-service
npm install
terminus dev --members 2
terminus remote add @you/my-service
terminus pushEdit src/service.mjs. The SDK defines the handlers and OpenAPI; terminus dev
runs the source locally with the same test people and data host used by apps.
Runtime doors declared by the operation limit its local SDK access. On the
platform the calling app must explicitly delegate those doors too, and its
existing grants remain authoritative. Direct and agent calls have identity
but do not automatically gain access to an app's data.
push uploads source, builds the Dockerfile on Terminus, and prepares a hosted
draft. It does not publish a release. The manifest states a daily hosting budget
and the compute rate it accepts; a rate change is refused. The beta includes
three source builds per rolling day per owner, with 15-minute build deadlines.
Never put credentials in source: service secrets belong to hosting settings.
The platform handles image storage, HTTPS, invocation credentials and idle stop.
Native dependencies belong in the Dockerfile. A public-web network profile
uses the platform's guarded browser proxy; that proxy is available in hosted
invocations, so validate native guarded browsing through the online draft.
To register an API you already operate, use
terminus init service my-api --template external and configure its endpoint
and authentication as before.
terminus dev also enforces current delivery updates (expected_version with a
collection's delivery_updates policy), required relation creator bindings, and
the SDK's seven-day generated retry IDs. Its SQLite cleanup follows the same
expiry fence as production: an expired retry cannot become a new write after
receipt cleanup. Explicit caller IDs keep their permanent retry contract.
