Services

Last updated

A service is an operation someone else runs — a search, a page read from the web, a program, an image, a document conversion, or your own API — or files pages load, such as a library or a font. Apps and agents use it by address, and Terminus takes care of who is calling, the user's consent and the bill, with no credential in anyone's code.

On this page⌄

Terminus's own services#

Terminus's own capabilities are services like anyone's, published under @terminus and called the same way.

@terminus/web
The public web: search (each result's title, address and snippet), fetch (a page, as it is or as its text) and image (a picture's own bytes). Answers in the call.
@terminus/bash
Run a shell script over the files you send it, without network access: run. Bash syntax and about 80 commands, and python3 and node inside a script run on the next two. Answers in the call, with what it printed and the files it changed.
@terminus/python
Run a Python program over the files you send it, with the standard library and no network access: run. Answers the same way.
@terminus/javascript
Run a JavaScript program over the files you send it, with Node-style fs, path, process and Buffer and no network access: run. Answers the same way.
@terminus/three, @terminus/fonts
Files for pages: three.js, and Terminus's fonts. See Files for pages.

Pictures, music, speech, transcripts, vectors and video are made by models, not services: see Models. The Services directory lists everything published, document conversion included, with each one's operations and price. Quick operations answer in the call; slow ones are jobs: you submit once and wait for the result. An operation may limit the calls one person makes of it a day: past that, it answers rate_limited until midnight UTC.

Call a service from an app#

An app declares every service it calls under services in its schema, schema.ts, and calls it through its handle — from the window or from the app's server:

schema.ts

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

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

src/main.js

import app from "../schema";

const { results } = await app.web.call("search", { query: "rust http clients", count: 5 });
  • A service is declared by its address, without a version: calls reach its current release. Declared once, it can be called on any of its operations. See Services in the SDK reference.
  • call(operation, input) answers the operation's JSON — or, for an operation that runs as a job, the job; request answers the whole response, for an operation that returns a file.
  • { address, reads, writes } lends the service the app's collections it names, as the person calling, for each call — for a service that keeps the app's data, such as a calendar. It reaches nothing else of the app's.
  • An operation that runs as a job answers the job; job.wait() waits for it. A job's results are private and temporary. job.file(id) downloads one; job.save(id, path) keeps it in the user's files.
  • Services bill the person using the app, and answer quota_exceeded when their credits run short. What the app spends on its own — a call a guest makes, its own schedule — is paid by its owner, within the app's daily budget.
  • Under terminus dev, a declared service reaches the real one, billed to you, at most 200 calls a day — or a hosted service you are writing runs beside the app: see Run an app locally.

Files for pages#

A service can also publish files for pages — a library's modules, stylesheets, fonts, pictures — and an app's pages load them by the service's address, so every app shares one copy rather than bundling its own. Declare the service in the schema, then import it like a package:

// schema.ts
services: { three: "@terminus/three" }

// a page
import * as THREE from "@terminus/three";
import { GLTFLoader } from "@terminus/three/gltf.js";
  • The address alone is the service's index.js; @terminus/three/gltf.js is a file of it. The service's handle names its files too: app.three.url(path) is a file's address (index.js unless you say), app.three.style(path) adds a stylesheet to the page and resolves once it loads (index.css unless you say), and app.three.import(path) imports a module.
  • A page loads the version its app was published against, and keeps it until the app is published again. A version's files never change, so they are kept for good.
  • To publish files, put them in an assets/ folder beside the service's terminus.json (or name another folder with assets). A service of files alone has no type and no operations.

Call a service from an agent#

Agents name services as tools: a whole service, or one operation of it. Terminus's own give the agent its web, code and picture tools; every other operation that answers JSON is a tool of its own:

"tools": [
  "service:@terminus/web",
  "service:@terminus/python",
  "service:@acme/calendar#create-event"
]

Connectors#

A connector is a service that works in each person's own account — their Gmail, their GitHub, their Slack. Each person connects it once, by signing in to it or by pasting their own key, and from then on every call they make goes out as them. Their credential never reaches an app's, an agent's or a service's code, and nobody else's call ever uses it.

In an app
Declared under services in its schema, like any service — gmail: "@acme/gmail" — and called through its handle, app.gmail.call(operation, input), as the person using the app. A call from someone who has not connected it yet is refused with not_connected; its details name the service and whether it connects by signing in (sign_in) or with a key (key).
In an agent
Named in its tools — service:@acme/gmail — and called with the account of whoever it is talking to. Norbert asks before he uses one in a conversation: to read it, or to read and act, and anything that changes the account waits for the person's approval.
In a skill
[[Gmail]](service:@acme/gmail) names a service the skill needs. Its page lists it under Works with, where a signed-in reader sees whether theirs is connected and connects it there.

People see and remove their connections in System Settings › Connections, where each can be checked, connected again or disconnected.

Make a connector

A connector is an external service whose contract says each person brings their own account. On the scheme its operations use, one of two things says how:

Signing in
An oauth2 scheme with the authorizationCode flow, its authorizationUrl and tokenUrl; x-terminus-client-id, your OAuth app's id; and x-terminus-secret, the name of the service secret that holds its client secret. The scopes it asks for are the ones security names. Provider parameters the sign-in needs — such as asking for a refresh token — go in the authorizationUrl.
Their own key
"x-terminus-person": true on an apiKey scheme, or an http bearer one: each person pastes their key, and it is sent where the scheme says.
x-terminus-health
Required for a connector: a path that only reads who the person is, such as their profile. Terminus calls it to check a pasted key before keeping it, to name the account people connected, and to test yours before you publish.

openapi.json

{
  "openapi": "3.1.0",
  "info": { "title": "Gmail", "version": "1.0.0" },
  "servers": [{ "url": "https://gmail.googleapis.com/gmail/v1" }],
  "x-terminus-health": "/users/me/profile",
  "security": [{ "google": ["https://www.googleapis.com/auth/gmail.readonly"] }],
  "components": { "securitySchemes": { "google": {
    "type": "oauth2",
    "flows": { "authorizationCode": {
      "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent",
      "tokenUrl": "https://oauth2.googleapis.com/token",
      "scopes": { "https://www.googleapis.com/auth/gmail.readonly": "Read your mail" }
    } },
    "x-terminus-client-id": "1234567890-abc.apps.googleusercontent.com",
    "x-terminus-secret": "CLIENT_SECRET"
  } } },
  "paths": {
    "/users/me/messages": { "get": {
      "operationId": "list-messages",
      "parameters": [{ "name": "q", "in": "query", "schema": { "type": "string" } }]
    } },
    "/users/me/messages/{id}": { "get": {
      "operationId": "get-message",
      "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }]
    } }
  }
}

A key connector's scheme is shorter — { "type": "http", "scheme": "bearer", "x-terminus-person": true } — and needs no OAuth app and no secret.

  • Set the client secret with terminus secrets set CLIENT_SECRET, or under the key on the service's page in Creations.
  • Register the redirect URI the service's page in Creations shows in your OAuth app. It is the one address people are sent back to after signing in, and it never changes, even if you rename the service.
  • An operation's OpenAPI parameters say where its input goes: a call's input fills the path, query and header parameters by name, and the rest is its body. A header parameter with a const or default — an API's version header, say — is sent with that value on every call.
  • Before you publish, connect your own account on the service's page (Connect your account), then Test connection: the test calls the service as you.
  • Only a person's own call can use a connector — from an app, as the person using it, or from an agent, as the person it talks to — never another service's call.

Publish your own service#

A service of your own is one of two kinds, and type in its terminus.json says which: **hosted** — code Terminus runs for you — or **external** — an HTTPS API you already run. Either describes its operations with OpenAPI, and apps, agents and other services call it through Terminus.

Hosted services

A hosted service is code Terminus runs for you, as WebAssembly: every call runs a fresh copy of it in about a millisecond, with no server to keep, nothing to wake and nothing to pay for while it waits. Write it in Rust (what terminus init service starts), TypeScript or JavaScript, Python, Go, C or C++: language in its terminus.json says which, and the CLI builds it on your machine with that language's own tools. Terminus only ever receives the finished component. In TypeScript you write defineService, the same way an app writes its own server:

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

export default defineService({
  forecast: {
    description: "Tomorrow's weather for a city",
    input: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
    network: ["api.weather.example"],   // the web hosts it fetches from
    secrets: ["WEATHER_KEY"],            // keys you set on Terminus
    services: ["@acme/geo#lookup"],      // other services it calls
    async run({ city }, ctx) {
      const place = await ctx.services.call("@acme/geo", "lookup", { city });
      const answer = await fetch(`https://api.weather.example/tomorrow?lat=${place.lat}&lon=${place.lon}`, {
        headers: { authorization: `Bearer ${ctx.secrets.get("WEATHER_KEY")}` },
      });
      return answer.json();
    },
  },
});

Each operation declares what a call may reach, and Terminus grants that and nothing more:

network
The web hosts it fetches from — api.example.com, or *.example.com for any name under it. HTTPS only, never a private address, redirects handed back rather than followed, at most 32 requests a call.
secrets
The secrets it reads with ctx.secrets.get(name), which you set on Terminus. See Secrets.
services
The operations of other services it calls with ctx.services.call(address, operation, input), each @handle/slug#operation. See Services that call services.
runtime
The app data its caller may lend it, such as GET /collections/* — only what the calling app lends, by naming its collections in reads and writes where it declares the service.
timeoutSeconds, memoryMb
How long a call may run (1–120 seconds, 10 unless you say; 300 for a job) and the memory it may use (16–512 MB, 128 unless you say). Past either, the call stops and its caller is not charged.
job
true makes a call answer at once with a job its caller waits on, for work longer than a caller waits on one request. Its answer is kept an hour; files it answers with become the job's files.
dailyLimit
The calls each caller may make of it in a UTC day. Past it, a call answers rate_limited until midnight UTC, and nothing is charged.
ctx.caller
Who the call is for: userId, when a person's app or agent called it; service, when another service did — and then never the person behind it.
ctx.settings
The service's settings: ctx.settings.get(name) reads one, as its owner last set it.
ctx.cache
The service's own cache: JSON values any later call to the same version reads, each kept for the seconds you give (an hour unless you say, a week at most).
ctx.invocationId
The call's own id, as its record on the service's page shows it.
  • An operation answers what run returns, as JSON. Throwing OperationError(code, message) refuses the call in your own words, and the caller pays nothing; any other error fails the call, and its message is kept in the call's record, which only you read.
  • Code at the top of the module runs once, when the service is built — not on each call. console.log lands in the call's record, with any secret's value masked.
  • In Rust, Python, Go, C or C++, the starter (terminus init service --template <language>) serves its contract at GET /openapi.json — the same x-terminus-network, x-terminus-secrets, x-terminus-services, x-terminus-runtime, x-terminus-timeout-seconds and x-terminus-memory-mb on each operation, and x-terminus-job or x-terminus-daily-limit where it wants them — answers each operation at POST /operations/<name>, and reads its secrets and settings through wasi:config/store. Any other language that builds a wasi:http component works too: leave the component at dist/service.wasm, or name it with build in terminus.json, and a build script in package.json can make it.
  • A call never opens a socket of its own: it reaches the web over HTTPS, to the hosts its operation declares. A language whose runtime carries socket code anyway (Python's does) still runs; any connection it tries is refused.
{ "kind": "service", "type": "hosted", "language": "typescript", "id": "@you/weather", "version": "0.0.1" }

Your service's page shows each operation and what it reaches, what each costs to run — what callers paid, what a call spent calling other services, how long it ran — and its latest calls, each with what it wrote.

External services

An external service's contract, openapi.json, says everything about the API in OpenAPI's own places: its URL in servers, and what it takes in securitySchemes and security. A vendor's published spec, or the one a framework generates, works as it is. On the scheme Terminus uses, one line says what to send: "x-terminus-secret": "VENDOR_KEY" (an API key's value or a bearer token — an OAuth client's secret, with x-terminus-client-id), or "x-terminus-signed": true (Terminus signs each call). A service that works in each person's own account — they sign in to it, or paste their own key — is a connector.

{
  "openapi": "3.1.0",
  "servers": [{ "url": "https://api.vendor.com" }],
  "components": { "securitySchemes": { "vendor": {
    "type": "apiKey", "in": "header", "name": "X-API-Key",
    "x-terminus-secret": "VENDOR_KEY"
  } } },
  "security": [{ "vendor": [] }],
  "paths": { "/search": { "get": { "operationId": "search" } } }
}

In Creations, make a Service and choose Set up connection on its page to write the same file without a repository: enter the endpoint and authentication, paste the credential — it is kept as one of the service's secrets, under a name like API_KEY — and import an OpenAPI 3.x JSON file, or define your first JSON operation with its name, method and path.

Test connection sends an authenticated GET to the test path (x-terminus-health, /health/ready unless you name another). If you run the server, show it: serve the displayed challenge token at its URL, then choose Verify ownership — Terminus signs calls only to a server shown to be yours. Charging for someone else's API needs your confirmation, beside the price, that you may. When the checks pass, publish from the creation page. A test may incur a provider charge.

Work locally

terminus init service weather && cd weather   # a hosted service, in Rust unless you name another
# or --template typescript (then npm install), python, go, c or cpp
terminus dev                    # build it and run it here, as Terminus runs it, with a test page
terminus dev --call greet --input '{"name":"Ada"}'   # one call from the terminal
# create it on the web (Creations → New creation), then connect the folder
terminus remote add @you/weather
terminus secrets set WEATHER_KEY
terminus push                   # build it and update its draft; then publish it on the web

terminus init service my-api --template external starts an external one instead. A hosted service and the app that calls it run together with terminus dev ../my-app --service .: the app's calls reach this folder and are answered as on Terminus, with nothing billed. A service that calls another runs it beside itself the same way: terminus dev --service ../geo.

rust
The default. cargo build --target wasm32-wasip2; needs Rust from rustup, which adds the target on the first build.
typescript
defineService, built by the CLI; needs npm install in the folder. Plain JavaScript builds the same way.
python
Built with componentize-py (pip install componentize-py), from app.py; Python's standard library is there.
go
Built with TinyGo; needs TinyGo with Go, wasm-tools, Binaryen's wasm-opt and wit-bindgen-go.
c, cpp
Built with wasi-sdk's clang (set WASI_SDK_PATH) and wit-bindgen; the starters use cJSON for JSON.

Like every creation, a service is made on the web first, which gives it its address; the CLI updates it but never creates one, and it is published from its page in Creations.

terminus.json
Its header: kind, type (hosted or external; none for a service of files alone), id and version — a hosted service's language (rust, typescript, python, go, c or cpp), and build when its component is not at dist/service.wasm; openapi when an external service's contract is not at openapi.json; and assets when its files for pages are not in assets/. Its name, description and price are set on the web.
openapi.json
An external service's contract: where its endpoint is (servers), what it takes (securitySchemes, with the x-terminus- line that says what to send), and every operation — each operationId — with its input and its output. A hosted service's contract is the one its code serves.
README.md
When an agent should use it, its limits, and examples.
dev/
An optional test page. terminus dev serves it, without marketplace charges; without one, it makes a page with a form for each operation.

Try it before you publish

terminus service test . echo --input '{"text":"hello"}'    # JSON in
terminus service test . convert --file ./sample.docx         # a file in, at most 8 MiB
terminus service test . search --input '{}' --query '{"q":"rust"}' --json
  • terminus service test <dir> <operation> updates the service's draft from the folder, then calls one operation the way Terminus will. --input and --file are the body, --query its query parameters. Draft tests have no marketplace charge; external provider charges still apply.
  • terminus dev serves dev/index.html on its own port. The page calls window.__SERVICE_DEV__.invoke(operationId, { input, query, bytes, idempotencyKey }) — input for JSON operations, bytes for binary ones — and gets back the answer's status, content_type and body (or body_base64). Never call your service from the page directly: whatever serves the page provides invoke, and the dev/ folder ships with each release.
  • terminus service inspect @publisher/slug shows any service's operations and price without calling it.

A caller's idempotency key reaches your service in the Idempotency-Key header. A caller who sends the same key again is answered idempotency_conflict and is never charged twice.

Secrets#

A secret is a key your service keeps on Terminus — the upstream API key a hosted operation reads, or the one an external service's endpoint sends. It belongs to the service, not to a release: set it under the key on the service's page in Creations, or with terminus secrets set NAME, and a new value holds from the next call. Terminus never shows a value again, only its last four characters; it never enters your files, a package or a release. A release that needs a secret nobody set does not publish, and a secret the published release uses can be replaced but not deleted. For terminus dev, put them in .env.local, which is never uploaded.

An app's server keeps its secrets the same way, and terminus secrets sets them from the app's folder: see Secrets and the daily budget.

Settings#

A setting is a value your service's code reads by name — which provider it uses, the models it offers, a rate — changed without a release. Set it beside the secrets on the service's page in Creations, or with terminus settings set NAME value; terminus settings lists them and terminus settings unset NAME forgets one. A new value holds from the next call, and every call reads them all: ctx.settings.get(name), or wasi:config/store in another language.

Unlike a secret, a setting is read back whole: everyone who works on the service sees it, and only its owner changes it — so never put a key in one. A name is capitals, digits and _, starting with a letter, never TERMINUS_ and never a secret's name; a service keeps at most 64, each value at most 4 KiB.

Services that call services#

A service can build on others: declare the operations it calls in services, and call them with ctx.services.call. The service it calls sees your service as its caller, never the person behind the call, and you — its owner — pay that service's price, so price yours to cover it. What your service may spend a day calling others is its daily budget, behind the gear on its page beside its price: $1 a day until you change it, starting over at midnight UTC; past it, its calls to others are refused until the next day. A chain of calls goes at most four services deep, and never calls a service already on it.

An app has a daily budget of its own, spent by its schedules and its guests' calls: see Secrets and the daily budget.

How jobs behave#

  • Submit once with an idempotency key. Sent again with the same key and the same input, a job answers the same job instead of running twice; a new key is a new job.
  • An operation that answers in the call receives your key in its own Idempotency-Key header. The same key again answers idempotency_conflict rather than the first answer, and never charges twice. Look before calling it again — Retrying safely has the rest.
  • Wait with job.wait() in an app — on the job a call answered, or on app.<service>.job(id) after a reload — or terminus service job <id> --wait in a terminal.
  • A job that stops at reconciling has an uncertain outcome. Look before retrying — never resubmit it automatically.
  • Cancelling cannot undo a charge already made or an email already accepted.
  • Results expire unless they are saved into the user's files.

For your coding agent

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