- Base URL:
https://<server>/api/v1. - Contract: the server serves its OpenAPI 3.1 document at
/api/openapi.json.termoak-server openapiprints it too, which is handy to generate Swift or Kotlin clients if you don't use the FFI library. - Format: everything is JSON, except SFTP downloads and uploads and session recordings.
- Authentication: send
Authorization: Bearer <access_token>with every request. On WebSockets, if the client cannot set headers,?access_token=<token>works too. The access token lasts 60 minutes and the refresh token 90 days by default (both configurable in[server]). - Errors: see Errors.
The server, the CLI, the desktop app and the mobile apps are released
separately, and every user updates whenever they want. A client from months
ago must keep working with a new server, so /api/v1 only grows:
- Routes, response fields and optional request fields can be added: clients ignore fields they don't know.
- A route or field cannot be removed or renamed, an optional field cannot become required, and the meaning of a field or an error code cannot change.
- Values cannot be added to an enum that clients already receive
(
SessionStatus,EntityKind,TeamRole,SharePermission...): current clients don't recognize them and the whole response fails to parse. To allow it later, the enum first needs a catch-all variant (#[serde(other)]) shipped in every client.
An incompatible change goes into a new API (/api/v2) that lives alongside
the old one while clients still use it. One deliberate exception: since
server 0.3, control on a session share means "can ask for the keyboard"
(one person types at a time) instead of "can always type"; older clients
keep a compatible behaviour, described in
WEBSOCKET-PROTOCOL.md.
GET /info returns the server version.
Every API error has the same shape, with the matching HTTP status:
{"error": {"code": "plan_limit", "message": "your plan allows at most 3 teams", "limit": "max_teams", "max": 3}}codeis a stable snake_case identifier. Clients use it to decide what to do and to show their own translation (error.<code>, see I18N.md).messageis English, meant for logs and as a fallback when a client has no translation.- Some errors add extra fields next to
codeandmessage(the placeholders of the translation). They are listed below.
A 401 always means the session is not valid (missing, expired or revoked
token). When the password asked to confirm an action is wrong (changing the
password or the email, disabling two-step verification, deleting the
account), the answer is 403 with invalid_password, and the session stays
valid.
A body that is not valid JSON, or that misses a required field, is rejected
before reaching the API with a plain-text 400, 415 or 422.
Generic codes, used when there is no more specific one:
| Code | Status | Meaning |
|---|---|---|
bad_request |
400 | Invalid request |
unauthorized |
401 | Missing, invalid, expired or revoked token |
forbidden |
403 | You are not allowed to do this (also: the monthly AI budget is spent) |
not_found |
404 | The resource does not exist, or is not yours |
conflict |
409 | Conflicts with the current state |
internal |
500 | Unexpected server error |
unavailable |
503 | The session holder is not responding; try again in a few seconds |
Accounts and sign-in:
| Code | Status | Meaning |
|---|---|---|
invalid_credentials |
401 | Wrong email or password |
totp_required |
401 | The account has two-step verification: repeat the request with the code |
totp_invalid |
401 | The two-step verification code is not correct |
too_many_attempts |
429 | Too many failed attempts (or emails requested too often); wait a few minutes. Extra field when asking for emails too often (/auth/resend-code, /me/verify-email): retry_after (seconds) |
invalid_password |
403 | The password (or code) asked to confirm the action is not correct |
registration_closed |
403 | Registration is closed: an invitation is needed |
invalid_invite |
403, 404 | The invitation is invalid, used, revoked or expired |
invite_email_mismatch |
403 | The invitation is for another email address |
invalid_email |
400 | The email address is not valid |
password_too_short |
400 | The password is too short. Extra field: min |
email_taken |
409 | An account with that email already exists |
same_email |
400 | The new email is the current one |
email_already_verified |
400 | The email is already verified |
email_not_verified |
403 | The server requires a verified email to use the account. Extra fields: email (the account's address) and verification (["code", "link"]): show the screen to enter the code from the email. See Email verification |
invalid_code |
400 | The email verification code is wrong, has expired or was used up (five wrong tries); the same answer whether or not the account exists |
email_disabled |
400 | The server does not send emails |
email_failed |
502 | The email could not be sent; try again later |
invalid_link |
400, 401, 404 | The link (email link or session share link) is invalid or has expired |
invalid_locale |
400 | The server does not have that language (see GET /locales) |
terms_not_accepted |
400 | Registration sent accept_terms: false on a server with terms of use |
invalid_terms_version |
400 | terms_version is longer than 16 characters or has control characters |
last_admin |
409 | You are the only administrator: appoint another one first |
last_team_owner |
409 | A team would be left without an owner. Extra field when deleting the account: teams (names of those teams) |
plan_limit |
403 | Your plan does not allow more. Extra fields: limit (max_teams, max_team_members or max_server_sessions) and max |
Administration:
| Code | Status | Meaning |
|---|---|---|
admin_only |
403 | Only for server administrators |
cannot_modify_self |
400 | You cannot disable yourself or remove your own administrator role |
unknown_plan |
400 | The plan does not exist (or is a team plan, when assigned to an account) |
Teams:
| Code | Status | Meaning |
|---|---|---|
team_owner_only |
403 | Only a team owner can do this |
team_admin_only |
403 | Only team owners and admins can do this |
user_not_found |
404 | There is no user with that email on this server |
account_disabled |
400 | That account is disabled |
already_member |
409 | Already a member of the team |
not_team_member |
403 | You can only share with teams you belong to |
Server sessions:
| Code | Status | Meaning |
|---|---|---|
session_owner_only |
403 | Only the owner of the session can do this |
session_limit |
409 | You reached the maximum number of active sessions ([sessions] max_per_user) |
session_connecting |
409 | The session is still connecting and its input buffer is full |
session_ended |
404 | The session is no longer active (also when sharing a session that just ended) |
recording_not_found |
404 | The session has no recording |
title_required |
400 | The title cannot be empty |
cannot_invite_self |
400 | You cannot share a session with yourself |
share_revoked |
409 | The invitation was revoked: it cannot be changed |
invalid_control_minutes |
400 | A timed grant of the keyboard (control_minutes) must be 1 to 240 minutes |
The terminal WebSocket has its own codes (revoked, kicked, expired,
session_ended, join_denied, forbidden, signed_out), see
WEBSOCKET-PROTOCOL.md.
SSH and SFTP:
| Code | Status | Meaning |
|---|---|---|
ssh_auth_failed |
502 | SSH authentication failed |
host_key_unknown |
409 | The host key is not known yet |
host_key_changed |
409 | The host key changed (the connection is always refused) |
host_key_rejected |
409 | The host key was rejected |
ssh_timeout |
504 | The SSH connection timed out |
ssh_error |
502 | Other SSH error |
telnet_not_supported |
422 | The host is a Telnet host: the server does not open sessions to it (POST /sessions) nor test it; the apps connect to Telnet hosts themselves |
protocol_not_supported |
422 | The host uses a protocol this server does not know (from a later app). Extra field: protocol |
not_supported |
422 | Not available for this host: SFTP or jumps through a Telnet host (/exec reports it in that host's error) |
invalid_path |
400 | Invalid path |
invalid_mode |
400 | Invalid mode (use octal, for example 644) |
cannot_delete_root |
400 | The root directory cannot be deleted |
runbook_empty |
400 | POST /ai/tasks/{id}/runbook: the task ran no commands to save |
Sync, AI, push notifications and updates:
| Code | Status | Meaning |
|---|---|---|
too_many_changes |
400 | Too many changes in a single sync request (maximum 5000) |
vault_not_found |
404 | The vault does not exist or you cannot see it |
vault_read_only |
403 | You can use the items of this vault (Use-only) but not change them |
vault_manager_only |
403 | Only the vault's managers (owner, team owners and admins) can do this |
secret_hidden |
403 | Use-only members never see the secrets of the vault |
vault_personal |
409 | The personal vault cannot be shared, deleted or left (and only its name, color and icon change) |
use_transfer |
409 | The item is in another vault: move it with POST /vaults/{target}/transfer |
cross_vault_reference |
422 | A reference points to an item of another vault. Extra field: field (group_id, settings.key_id...) |
still_referenced |
409 | You are moving a key or identity that other items still use (force: true detaches them). Extra field: used_by ([{kind, id, field}]) |
use_only_strict |
403 | Strict vault: Use-only members only connect through the server (no /credentials) |
invalid_role |
400 | Only editor and use_only can be granted |
member_exists |
409 | That user or team already has access to the vault |
id_in_use |
409 | The id belongs to an item you cannot see |
not_team_member |
403 | You can only share a vault with a team you belong to |
confirmation_required |
400 | Deleting a vault needs ?confirm=<its name> |
shared_vaults |
409 | Deleting the account would delete your shared vaults with members: confirm with delete_shared_vaults: true. Extra field: vaults ([{id, name, member_count}]) |
rate_limited |
429 | Too many requests (/hosts/{id}/credentials: 30 per minute). Extra field: retry_ms |
ai_not_configured |
503 | The AI provider is not configured |
ai_key_required |
403 | The plan does not include the server's AI and you have no API key of your own that can be used: add one in Settings → AI (PUT /me/ai/keys/{provider}) |
ai_budget_exceeded |
403 | This month's AI credit for the server's providers is spent (your own API keys keep working) |
unknown_provider |
400 | The provider does not exist or does not accept your own API key. Extra field: provider |
ai_error |
502 | The AI provider failed |
push_disabled |
400 | The server has no push notifications configured |
push_not_registered |
400 | This device has no push notifications enabled |
push_failed |
502 | The notification could not be sent |
updates_disabled |
404 | This server does not serve updates |
updates_upstream |
502 | GitHub could not be reached or answered with an error |
POST /hosts/{id}/test does not fail when the connection fails: it answers
200 with ok: false, the error text and an error_code
(host_key_unknown, host_key_changed, auth_failed, timeout or
connect_failed).
| Method | Route | Description |
|---|---|---|
| GET | /info |
Server version, registration state (open/closed), needs_setup, features, contact links and environment (e.g. preprod on a test server, null in production: show a banner when set) |
| GET | /locales |
Languages the server has for emails and notifications: {default, locales: [{code, name}]}. No authentication |
| POST | /auth/register |
{email, name, password, device_name, platform, invite?, locale?, accept_terms?, terms_version?} → {user, tokens, verification_required}. The first user becomes an administrator; with registration closed an invitation is needed. See Terms of use and Email verification |
| POST | /auth/login |
{email, password, device_name, platform, totp_code?} → {user, tokens, verification_required} |
| POST | /auth/verify-code |
{email, code, device_name?, platform?, totp_code?} → {user, tokens, verification_required: false}. Verifies the email with the six-digit code and signs in. No authentication. See Email verification |
| POST | /auth/resend-code |
{email} → {ok: true, resend_after: 60}. Emails a new code. No authentication |
| POST | /auth/refresh |
{refresh_token} → new tokens. Both rotate |
| POST | /auth/logout |
Revokes the current device |
| GET | /me |
{user, device, plan, verification_required} |
| PATCH | /me |
{name?, locale?} → the updated User |
| DELETE | /me |
{password, totp_code?}. Deletes the account and all its data |
| POST | /me/password |
{current_password, new_password} |
| GET | /me/2fa |
{enabled, recovery_codes_left} |
| POST | /me/2fa/setup |
→ {secret, otpauth_url, qr_svg}. A new secret, not enabled yet |
| POST | /me/2fa/enable |
{code} → {recovery_codes} (10 single-use codes, shown only now) |
| POST | /me/2fa/disable |
{password, code} (current code or recovery code) |
| GET | /devices |
Sessions and devices: {current, devices: [Device]}. See Sessions and devices |
| POST | /devices/sign-out-all |
{include_current?} → {revoked}. Signs out every other device (all of them with include_current: true) |
| DELETE | /devices/{id} |
Signs a device out (this one too, if it is the current one) |
| GET | /invites/{token} |
Public data of an invitation (no authentication): {email, team, expires_at} |
Every sign-in is a device with its own tokens. GET /devices lists them,
most recently used first; current is the id of the device making the
request. Each Device has id, name and platform (as sent on sign-in),
created_at (signed in since), last_seen_at, access_expires_at,
refresh_expires_at, push (apns/fcm when it receives notifications)
and:
last_ip: the last address it was used from: the connection's or, withtrust_forwarded_for, the last one inX-Forwarded-For(the same one the sign-in limits use).user_agent: a short description of the client from itsUser-Agent(Firefox 131 on Linux,Safari 18 on iOS,Termoak 0.4.0...).
Both are recorded on sign-in and on refresh and, while the device is used,
with last_seen_at (at most once a minute). They are null for devices
signed in before the server recorded them, until they are used again.
Signing a device out (DELETE /devices/{id}, POST /auth/logout, POST /devices/sign-out-all, a password reset, an administrator) takes effect at
once: its access token gets 401 from the next request (not when it
expires), its refresh token stops working, its push registration goes away
and its WebSockets (events, terminal, sharing) close with signed_out
(close code 4007). POST /devices/sign-out-all writes one audit entry
auth.devices_revoked with {count, include_current}; signing out one
device writes auth.device_revoked.
Every user has a locale (BCP 47, en by default). The server uses it for
emails and push notifications; apps can use it as the account's language.
- On registration,
localeis optional. Without it, or if the server does not have that language, the server picks the best match from theAccept-Languageheader, and otherwiseen. PATCH /mewith{"locale": "es"}changes it. A language the server doesn't have is rejected withinvalid_locale. Regional variants are normalized (es-ES→es).GET /localeslists the available languages.
GET /info returns the server's terms_url and privacy_url (null when
not configured, [web] in the configuration). Clients that show them at
sign-up send, with the registration:
accept_terms:truewhen the person ticked "I have read and accept the terms of use and the privacy policy".terms_version: the version of the documents they accepted ("1.0"), at most 16 characters (invalid_terms_versionotherwise).
Both are optional, so apps that predate them can still sign up. With
accept_terms: true, the acceptance is recorded in the audit entry of the
registration (auth.register, readable in GET /audit and
GET /admin/audit):
{"platform": "web", "invite": null, "terms": {"accepted": true, "version": "1.0"}}On a server with terms_url, accept_terms: false is rejected with 400
terms_not_accepted. Without accept_terms, nothing is recorded.
| Method | Route | Description |
|---|---|---|
| POST | /me/verify-email |
Sends the verification email again (once per minute; with a new code too when the server requires verification) |
| POST | /auth/verify-email |
{token} from the email. No authentication |
| POST | /me/email |
{email, password}. With email configured, the change stays pending ({pending: true}) until confirmed from the new address |
| POST | /auth/confirm-email |
{token} from the email-change email. No authentication |
| POST | /auth/forgot-password |
{email}. Same answer whether the account exists or not. No authentication |
| POST | /auth/reset-password |
{token, password}. Signs out every device. No authentication |
| GET | /plans |
Plan catalog {default, plans} (no authentication) |
| GET | /me/plan |
Your plan, its limits and your usage: {plan, usage: {teams_owned, server_sessions, ai_spent_usd, ai_credit_usd}} |
| GET | /downloads |
Files of the latest release of each component. No authentication. See Updates and downloads |
If the server requires a verified email ([email] require_verification), an
unverified account can only use /me*, /auth/* and /devices*; everything
else answers 403 with email_not_verified. See
Email verification.
On a server that requires a verified email ([email] require_verification
with email configured; GET /info says features.email_verification: true,
and features.email_verification_code: true when the routes below exist),
new accounts confirm their address with a six-digit code sent by email.
Accounts created from an invitation sent to their own email, and the first
account of the server, are verified from the start.
-
POST /auth/registeranswers as always, plusverification_required:{"user": {"email": "ana@example.com", "email_verified": false, ...}, "tokens": {...}, "verification_required": true}The email (subject
Your Termoak code: 123456, in the account's language) carries the code and, for older apps, the verification link. Whileverification_requiredistrue, the tokens only reach the account itself (/me*,/auth/*,/devices*); clients should show a "check your email" screen instead of the app. The web app discards them (POST /auth/logout) and signs in with the tokens of step 2. -
POST /auth/verify-codewith{"email": "ana@example.com", "code": "123456", "device_name": "Ana's iPhone", "platform": "ios"}(deviceis accepted as another name fordevice_name; spaces and dashes in the code are ignored) verifies the email and signs in: the same response as the login, withverification_required: false. Errors:400 invalid_code: wrong, expired, already used, or invalidated after five wrong tries; the same answer when there is no such unverified account.429 too_many_attempts: too many failures for that email or IP (they count together with failed sign-ins: 10 per email or 30 per IP in 10 minutes).401 totp_required/totp_invalid: only if the account already has two-step verification; repeat withtotp_code.
-
POST /auth/resend-codewith{"email": "ana@example.com"}emails a new code (and link), which replaces the previous one. It always answers{"ok": true, "resend_after": 60}, whether or not the account exists or needs it, except when asked too often: at most once a minute and five times an hour per address, and thirty times an hour per IP, counting every request, answered with429 too_many_attemptsandretry_after(seconds). Registration counts as the first email.
Codes last 15 minutes. Signing in to an unverified account
(POST /auth/login) still works and answers verification_required: true;
if the last code can no longer be used (expired, used up, invalidated), it
emails a new one, within the same limits. The link in the email
(POST /auth/verify-email, 48 hours) keeps working and then invalidates
the code. Any other route answers 403 email_not_verified with the
account's email and "verification": ["code", "link"], so clients can open
the code screen from anywhere.
Plans have a stable id (free, pro), English name and description,
price_cents (0 = free, null = not published yet), currency,
available (false = coming soon), for_teams, highlight, features and
limits. features are stable ids (encrypted_sync, ai_own_keys,
ai_credit...), not display text: clients translate plans.<id>.name,
plans.<id>.description and plans.feature.<feature>, and fall back to the
English texts and the raw id. The built-in catalog has Free (everything
included; AI runs with your own API keys) and Pro (coming soon: priority
support and Termoak AI credit).
Plan limits (teams owned, members per team, open server sessions) answer
403 with plan_limit. Server administrators have no limits.
Plans also decide the AI (see AI with your own keys and AI credit):
limits.server_ai (can use the server's AI providers; true when not set)
and limits.ai_credit_usd (their monthly credit; when not set, [ai] monthly_budget_usd, and without it no cap). The built-in Free plan has
server_ai: false; Pro has server_ai: true and ai_credit_usd: 5. In
/me/plan, usage.ai_spent_usd is what you spent this month (UTC) on the
server's providers and usage.ai_credit_usd your credit (null = none or
no cap).
| Method | Route | Description |
|---|---|---|
| POST | /push/register |
{platform: apns|fcm, token, sandbox?}. Binds the token to the device of the session |
| DELETE | /push/register |
Stops sending notifications to this device |
| POST | /push/test |
Sends a test notification to this device |
The notification data carries type (ai_approval, ai_finished,
session_shared, session_prompt, join_request, control_request,
team_added, test) and the relevant ids (task_id, approval_id,
session_id, prompt_id, participant, team_id). session_prompt,
join_request and control_request are only sent when you are not watching
that session (no device of yours attached to it). In
APNs they go under the termoak key of the payload; in FCM, in data. The
text is in the recipient's language.
If the account has 2FA and the login has no totp_code, the answer is 401
with totp_required; if the code is wrong, totp_invalid. The client asks
for the code and repeats the login. Both the 6-digit code of the
authenticator app (TOTP, RFC 6238, 30-second steps) and a recovery code
work. Each TOTP code is accepted only once.
After 10 failed attempts for the same email, or 30 from the same IP, within
10 minutes, the login answers 429 (too_many_attempts).
Server administrators only.
| Method | Route | Description |
|---|---|---|
| GET, POST | /admin/users |
Lists users or creates one {email, name, password, is_admin} |
| PATCH | /admin/users/{id} |
{name?, is_admin?, disabled?, plan?, email_verified?}. Disabling signs the user out everywhere |
| POST | /admin/teams/{id}/plan |
{plan}. Plan of a team |
| POST | /admin/users/{id}/password |
{password}. Sets a new password and signs the user out |
| POST | /admin/users/{id}/2fa/reset |
Removes two-step verification |
| GET | /admin/users/{id}/devices |
The user's devices (with last_ip and user_agent) |
| DELETE | /admin/users/{id}/devices/{device_id} |
Signs out one of the user's devices |
| GET, POST | /admin/invites |
Lists or creates invitations {email?, team_id?, team_role?, is_admin, expires_in_hours?, send_email?} → {invite, token, server, url, web_url, emailed} |
| DELETE | /admin/invites/{id} |
Revokes an unused invitation |
| GET | /admin/audit?limit=&before= |
Audit log of the whole server, newest first |
An invitation lets someone sign up even when registration is closed. It can
be bound to an email, make the account an administrator and add it to a
team. It expires after 7 days unless told otherwise (0 = never). The
token is only shown when it is created; url is the
termoak://invite?server=…&token=… link that opens the app.
| Method | Route | Description |
|---|---|---|
| GET, POST | /teams |
Your teams (all of them, for server administrators), or creates one {name} (you become its owner) |
| GET, PATCH, DELETE | /teams/{id} |
Shows, renames {name} or deletes it. Deleting it revokes what was shared with it |
| GET, POST | /teams/{id}/members |
Members, or adds one {email, role} |
| PATCH, DELETE | /teams/{id}/members/{user_id} |
Changes the role {role} or removes the member. Removing yourself leaves the team |
| GET, POST | /teams/{id}/invites |
Pending invitations, or invites by email {email, role}: someone who already has an account joins directly ({added: true}); otherwise they get an invitation to sign up ({added: false, token, url, web_url, emailed}) |
| DELETE | /teams/{id}/invites/{invite_id} |
Revokes a team invitation |
Roles: member (sees what is shared with the team), admin (also manages
members and invitations and renames the team) and owner (also deletes the
team and appoints or removes owners). A team always keeps at least one
owner. Whoever leaves a team immediately loses access to the sessions shared
with it, and to its vaults (their server sessions on those hosts close).
Deleting a team deletes its vaults with their items (GET /vaults shows the
item counts first).
A vault is the unit of ownership, sharing and sync of entities (hosts,
groups, identities, keys, snippets, forwards, known hosts, memories). Every
item is in exactly one vault (vault_id).
- Every user has a personal vault whose id is the user id. It cannot be shared, deleted or left.
- Users create shared vaults (they own them) and team owners and admins create team vaults (the team owns them).
- Roles:
use_only<editor<manager.manageris computed: the owner of a shared vault, the owners and admins of the team of a team vault. Grants giveeditororuse_onlyto a user or to a team (you must belong to it). Plain members of the owning team get the vault'steam_member_role(defaulteditor;null: no access). The effective role is the maximum of every grant. - Use-only members use the items (server sessions, SFTP, exec, the AI,
/hosts/{id}/test) but never see their secrets: nosecretin sync,secret_hidden: truein listings,GET /{col}/{id}/secretanswerssecret_hidden. For connections from their own device the app asks for just-in-time credentials (POST /hosts/{id}/credentials), unless the vault is Strict (settings.use_only_local: false): then they only connect through the server. - References (
group_id,parent_id,settings.identity_id,settings.key_id,settings.jump_host_ids,settings.startup_snippet_id, an identity'skey_id,host_idof forwards and memories) stay inside a vault: REST refuses others (cross_vault_reference) and the server resolves a reference outside the host's vault as missing. - Secrets are encrypted with a key per vault, wrapped with the server's master key; deleting a vault deletes its key.
| Method | Route | Who | Description |
|---|---|---|---|
| GET | /vaults |
any | Vaults you can access (personal first) with role, owner_name, member_count and item_counts |
| POST | /vaults |
any (team: owners, admins) | {name, description?, color?, icon?, team_id?, team_member_role?, settings?} → Vault |
| GET | /vaults/{id} |
member | Vault |
| PATCH | /vaults/{id} |
manager | {name?, description?, color?, icon?, team_member_role?, settings?} (null clears color, icon, team_member_role). Personal: only name, color, icon |
| DELETE | /vaults/{id}?confirm=<name> |
manager | Deletes it with its items and keys. Members lose access (vault/access with reason: deleted) |
| POST | /vaults/{id}/leave |
member with a direct grant | Gives up your own grant |
| GET | /vaults/{id}/members |
member | [VaultMember]: implicit ones first (implicit: true: the owner or the team's owners and admins as managers, and the owning team with team_member_role), then the grants |
| POST | /vaults/{id}/members |
manager | {email, role} or {team_id, role} → VaultMember. Unknown email: user_not_found |
| PATCH | /vaults/{id}/members/{member_id} |
manager | {role} |
| DELETE | /vaults/{id}/members/{member_id} |
manager | Revokes the grant |
| POST | /vaults/{id}/transfer |
see below | Moves or copies items into this vault |
| GET | /vaults/{id}/audit |
manager | ?before=&limit=: the vault's audit (vault.*, secret.reveal, secret.use, sessions...) |
Vault: {id, kind: personal|shared|team, name, description, color, icon, owner_user_id, owner_team_id, team_member_role, crypto: server, key_version, settings: {use_only_local}, rev, created_by, created_at, updated_at, role, owner_name, member_count, item_counts}. New enum values may appear later:
kind, role and crypto decode unknown values as unknown (a role
unknown gives no access).
When access is revoked (a grant removed, a vault or team deleted, a team
member removed, an account disabled or deleted) the user's server sessions
on hosts of that vault close (session_closed with reason
vault_access_revoked) and the server drops its pooled connections. A
downgrade to Use-only keeps the sessions running. Session shares are
independent of vaults: sharing a terminal never grants its vault.
POST /vaults/{target}/transfer
{"mode": "move" | "copy", "items": [{"kind": "host", "id": "…"}],
"dependencies": "auto" | "none", "dry_run": false, "force": false}
→ {"moved": [{"kind", "id"}], "copied": [{"kind", "from", "to"}], "reused": [{"kind", "from", "to"}],
"detached": [{"kind", "id", "field"}], "warnings": [{"code", "kind", "id"}], "rev": 1234, "dry_run": false}
- A group brings its subgroups and their hosts; a host brings its forwards, its memories and (copy; move if nothing else uses them) its known hosts.
dependencies: auto: the identity, key, jump hosts and startup snippet of each host (from its effective settings). A move moves a dependency when everything that uses it moves too, otherwise copies it (new id) and rewrites the references; a copy copies it (a key with the same fingerprint in the target is reused).noneclears those references (detached).- An item that leaves its group behind gets the inherited settings written
into its own (
group_id: null), so it connects the same way. - Moving a key or identity explicitly while items that stay still use it:
still_referenced, unlessforce: true(their references are detached). - Move: Editor on the source and target vaults; the ids are kept and the
source vault gets a departure (sync v2
removed, a deletion for old apps). Copy: Editor on the target and the source (Use-only members can copy snippets only); a copy never carries secrets out of a vault where you are not Editor. Audited asvault.transferin every vault involved. dry_run: trueanswers the same without writing (for the confirmation).
All of them follow the same CRUD pattern:
| Collection | Route |
|---|---|
| Hosts | /hosts |
| Groups | /groups |
| Identities | /identities |
| SSH keys | /keys |
| Snippets | /snippets |
| Port forwards | /forwards |
| Known hosts | /known-hosts |
| AI memories | /memories |
| Method | Route | Description |
|---|---|---|
| GET | /{col} |
List, from every vault you can use (?vault_id= for one). Each item has vault_id, updated_by and secret_hidden. Never includes secrets |
| POST | /{col} |
Create. The body is the entity plus secret (optional), sync_mode (synced or device_only) and vault_id (default: your personal vault; Editor) |
| GET | /{col}/{id} |
One entity. Without access to its vault: 404 |
| PUT | /{col}/{id} |
Update (Editor). Without secret the current one is kept, "secret": null deletes it and an object replaces it. Another vault_id: use_transfer |
| DELETE | /{col}/{id} |
Delete (Editor; leaves a tombstone for sync) |
| GET | /{col}/{id}/secret |
Reveals the secret (Editor; Use-only: secret_hidden). Audited as secret.reveal with the vault |
Hosts have protocol ("ssh" or "telnet"; left out for SSH, so SSH
hosts look the same to older apps; a value from a later version is kept as
it is) and icon (the logo id the apps show: ubuntu, debian, server,
router...; left out or null = automatic). Both are stored and returned
as sent. Telnet hosts have no keys, jump hosts, SFTP or tunnels and their
default port is 23 (written out by the apps). The server does not open
sessions to them (telnet_not_supported): the apps connect to them
themselves, and /hosts/{id}/credentials gives them the username and
password for the automatic login.
Other entity routes:
| Method | Route | Description |
|---|---|---|
| POST | /keys/generate |
{label, key_type, comment?, passphrase?, store_passphrase?, vault_id?}. Generates the key on the server |
| POST | /keys/import |
{label, private_key, passphrase?, store_passphrase?, certificate?, sync_mode?, vault_id?} |
| POST | /hosts/{id}/test |
Tests the connection (any role); returns the detected OS (saved by Editors only) and the latency. ?trust=true accepts a new host key: it is saved in the host's vault if you are Editor there, otherwise in your personal vault. SSH only: a Telnet host answers {ok: false, error_code: "telnet_not_supported"} |
| GET | /hosts/{id}/effective |
Effective settings after applying groups and identity (any role) |
| POST | /hosts/{id}/credentials |
Just-in-time credentials for a connection from this device (below) |
| POST | /exec |
{host_ids, command | snippet_id + variables, timeout_secs?}. Runs in parallel; any role, each host checked on its own |
| POST | /sync |
Legacy sync (below) |
| POST | /vaults/sync |
Sync v2 (below) |
| GET | /audit |
?before=&limit= |
POST /hosts/{id}/credentials {purpose: "ssh" | "sftp" | "forward" | "telnet"} →
{vault_id, expires_at, hops: [{host_id, address, port, username, password?, key?: {private_key, passphrase?, certificate?}, proxy_password?}]} with the
jumps first and the host last. Editors always; Use-only members only if the
vault is not Strict (use_only_strict). The answer has Cache-Control: no-store; the app keeps it in memory only until authentication ends and
never stores or logs it. Limited to 30 per minute per user (rate_limited)
and audited as secret.use (device and purpose) in the vault. This is
interface protection plus auditing, not cryptography: anyone with the token
can call it, which is what Strict vaults are for. Telnet hosts follow the
same rules (apps may send ssh or telnet as the purpose): the hop has the
username and password, never a key.
POST /vaults/sync (when /info has features.sync_v2):
{"vaults": [{"vault_id": "…", "cursor": 1234, "role": "editor"}],
"changes": [SyncRecord],
"limit": 2000}→
{"vaults": [Vault], "cursors": [{"vault_id", "cursor", "role"}],
"changes": [SyncRecord], "removed": [{"id", "vault_id", "kind", "rev"}],
"accepted": ["…"], "rejected": [{"id", "code", "message"}],
"warnings": [{"id", "code", "field"}], "resync": ["…"], "more": false}vaultsis authoritative: a vault the store has that is not listed was lost (revoked or deleted): delete its rows (no tombstones) and count its unsynced changes as discarded.- Cursors are per vault: "the highest revision received for that vault"
(revisions are global to the server, so a newly granted vault starts at
0 without touching the others). Keep the returned
cursors. - Pushed records carry
vault_id(missing: the personal vault). Last writer wins byupdated_at. An item moved meanwhile is changed where it is now if you are Editor there. Rejections:vault_read_only,vault_not_found,id_in_use(an item you cannot see) andinvalid(keep it dirty). A reference to another vault is accepted with awarningsentry. A push older than the server's version is accepted and the server's version comes back inchanges.base_rev(optional) is logged when the client overwrites a newer revision. changesalways havevault_id. In Use-only vaults thesecretis withheld andhas_secret: truesays there is one.removed: items that left a vault (moved): delete the local row wherevault_idmatches.resync: your role changed between Use-only and Editor in these vaults: drop their local rows (secrets must come, or go). Theirchangesin this response already start from 0.more: true: the limit (default 2000, max 5000) cut the answer; call again with the new cursors.- Sync on the
vault/changedandvault/accessevents, after local saves, periodically and after avault_read_onlyfrom REST.
POST /sync {since, changes: [SyncRecord]} → {rev, changes, accepted} is
what apps before vaults use. On a server with vaults it serves only the
personal vault: changes are the personal vault's records after since,
and items that left it (moved to another vault) come as deletions
(deleted: true), so an old app drops them. Pushes land in the personal
vault; an existing item that is now in another vault where you are Editor
is changed there (and stays out of the answer). rev is the server's
current revision.
All routes live under /hosts/{id}/sftp/:
| Method | Route | Parameters |
|---|---|---|
| GET | home |
— |
| GET | list |
?path= |
| GET | stat |
?path= |
| GET | download |
?path=. Streamed response |
| POST | upload |
?path=. The body is the content, streamed, with no size limit |
| POST | mkdir |
{path, parents?} |
| POST | rename |
{from, to} |
| POST | delete |
{path, recursive?} |
| POST | chmod |
{path, mode} |
| Method | Route | Description |
|---|---|---|
| GET | /sessions |
{active, shared, recent} |
| POST | /sessions |
{host_id, cols, rows, title?, record?}. Opens a session that lives on the server (SSH hosts only: 422 telnet_not_supported for a Telnet host) |
| GET | /sessions/{id} |
State, size, participants and driver (see below) |
| PATCH | /sessions/{id} |
{title} |
| DELETE | /sessions/{id} |
Closes the session |
| GET | /sessions/{id}/ws |
Terminal WebSocket. See WEBSOCKET-PROTOCOL.md |
| GET | /sessions/{id}/recording |
asciicast v2 recording (.cast). Owner only. See Who typed what |
| GET | /sessions/{id}/recording/authors |
Owner only. Who typed in the recording: {started_at, authors: [{time, participant?, name, kind}]} (see below) |
| GET | /sessions/{id}/shares |
Owner. Every share (also revoked and expired ones), with user_email/user_name or team_name, active (not revoked nor expired) and participants (people inside with it now) |
| POST | /sessions/{id}/shares |
Owner. {email? | team_id? | link: true, permission: view|control, expires_in_minutes?, require_approval?, auto_grant?, control_minutes?}. Returns {share, token?, link?, app_link?} (the token only once). 404 session_ended if the session already ended |
| DELETE | /sessions/{id}/shares |
Owner. Stops sharing: revokes every share and sends everyone but the owner away (revoked). Returns {ok, revoked} (shares that were active) |
| PATCH | /sessions/{id}/shares/{share_id} |
Owner. {permission?, expires_in_minutes?, expires_at?, no_expiry?, require_approval?, auto_grant?, control_minutes?, no_control_limit?}. Applied live to whoever uses it; returns the share as in the list |
| DELETE | /sessions/{id}/shares/{share_id} |
Owner. Revokes the share; whoever joined with it leaves, unless they have another valid share |
| POST | /relay |
{title, cols, rows, host_id?}. Shares a local terminal. Returns host_ws_path |
| GET | /join/{token} |
Public data of a link share (no authentication): {session: {id, title, kind, state, created_at, cols, rows, access, participants}, owner, permission, require_approval, expires_at, ws_path}. participants is a count: it never lists who is inside |
A session can be shared with users of the server (email), with every
member of a team (team_id) or with a link (link: true, anyone who has it,
no account needed; signed-in users can use links too). One person drives at
a time: the owner always can, everyone else joins read-only and the share's
permission is the most the owner can hand over (view: only watch;
control: can ask for the keyboard). Details in
WEBSOCKET-PROTOCOL.md.
| Field | Default | Meaning |
|---|---|---|
permission |
view |
view or control |
expires_in_minutes |
none | Expiry. It also sends away whoever is already inside when it passes |
require_approval |
true for links, false otherwise |
Whoever joins waits until the owner lets them in |
auto_grant |
false |
Requests for the keyboard are granted without asking the owner |
control_minutes |
none | With auto_grant: each automatic grant lasts at most this many minutes (1-240), then the keyboard goes back to the owner. PATCH with no_control_limit: true removes it |
PATCH changes a share live: going down to view takes the keyboard away
at once; expires_at (ms) or expires_in_minutes set a new expiry and
no_expiry: true removes it; turning require_approval off lets in whoever
is waiting with that share. A revoked share cannot be changed
(409 share_revoked).
When a share is revoked or changed, a team share is revoked, a member
leaves a team or an account is deleted or disabled, the server checks again
everyone affected: whoever has another valid share keeps the best one, the
rest leave with revoked (or expired). The best share is the one with the
highest permission, then one without a waiting room, then a direct one.
participants in a session (GET /sessions, GET /sessions/{id}, the
WebSocket hello) lists people, not sockets (see
Participant); driver is the
participant with the keyboard (null: the owner) and driver_until (ms,
only while it is a timed grant) when the keyboard goes back to the owner.
The owner can hand it over for a while: control_grant with minutes
over the WebSocket. Only the owner sees user
ids and share ids, in participants and in the old viewers list.
Everything is audited in the owner's log (/audit): session.join (once
per person, not per reconnect; guests with their name, actor
guest:<participant>), session.leave, session.join_requested,
session.join_allowed, session.join_denied, session.control_requested,
session.control_granted, session.control_released,
session.control_taken, session.control_denied,
session.control_expired (a timed grant ended), session.control_period
(see below), session.kicked (with reason: kicked, revoked or
expired), session.share, session.share_changed,
session.share_revoked and session.sharing_stopped.
One person drives at a time, so every input that reaches the terminal has an author: the owner, the driver, or the AI (on the owner's behalf).
- Audit: each period someone other than the owner had the keyboard is
one
session.control_periodentry when it ends (not one per keystroke), with actoruser:<id>orguest:<participant>anddetail{participant, name, kind, from, to, until?, bytes, owner_bytes, reason}:from/toin ms,untilthe end of a timed grant,byteswhat they typed,owner_byteswhat the owner typed meanwhile, andreasonwhy it ended (released,taken,grantedto someone else,expired,left,removed,permission(their share went down toview) orsession_ended). - Recordings (sessions with
record): the.castfile keeps the standard asciicast v2 events and adds an author mark each time the author changes, right before their input: an event of typeawhose data is the author as a JSON string,[12.5, "a", "{\"participant\":\"…\",\"name\":\"Zoe\",\"kind\":\"guest\"}"](kind:owner,user,guestorai; noparticipantfor the AI). It applies to all the input that follows until the next mark. Players that do not know it skip it (asciinema ignores unknown event types). The marks are written even when the input itself is not recorded ([sessions] record_input = false, the default): they say who typed and when, not what.GET /sessions/{id}/recording/authorsreturns them already parsed:{"started_at": 1791198048000, "authors": [{"time": 12.5, "participant": "…", "name": "Zoe", "kind": "guest"}]}(time: seconds sincestarted_at). Sessions kept by a session holder older than the server have no marks until the holder is restarted.
SFTP downloads (/hosts/{id}/sftp/download) and recordings
(/sessions/{id}/recording) are streamed; uploads
(/hosts/{id}/sftp/upload?path=) carry the file as the body. The mobile
library writes them straight to disk with progress.
| Method | Route | Description |
|---|---|---|
| GET | /ai/providers |
Providers, models and whether they are available (reason_code and reason when not; see below) |
| GET | /ai/tasks |
?limit= |
| POST | /ai/tasks |
{prompt, title?, mode?, provider?, host_ids?, session_id?, effort?, group_id?, tag?, fan_out?, plan_first?} (see Multi-host tasks) |
| GET | /ai/tasks/{id} |
The task and its messages, pending_approvals (each with its preview), steps and, for a multi-host task, hosts |
| DELETE | /ai/tasks/{id} |
Deletes the task |
| POST | /ai/tasks/{id}/messages |
{text}. Continues the conversation |
| POST | /ai/tasks/{id}/cancel |
Cancels the task |
| POST | /ai/tasks/{id}/mode |
{mode: read_only|ask|confirm|auto} (also while the task is stopped: it applies to the next messages) |
| GET | /ai/tasks/{id}/events |
?after=<seq>. Stored events, to catch up |
| POST | /ai/tasks/{id}/approvals/{approval_id} |
{approve, always?, edited?, reason?} (see Approvals) |
| GET | /ai/tasks/{id}/runbook |
{name, description, script, variables, steps}: the commands the task ran, as a snippet to review |
| POST | /ai/tasks/{id}/runbook |
{name?, vault_id?} (optional body) → the saved snippet (as in /snippets). Saved in vault_id (default: your personal vault; Editor there) with the tags ai and runbook; runbook_empty if the task ran no commands. Audited as ai.task.runbook in the vault |
| GET | /ai/approvals |
Pending approvals of all your tasks (each with its preview) |
| POST | /ai/suggest |
{request, context?, provider?} → suggested command |
| POST | /ai/explain |
{text, question?, context?, provider?} → explanation of an output or an error |
| POST | /mcp |
MCP server (JSON-RPC 2.0, HTTP transport). See AI.md |
| GET | /me/ai/keys |
Your own API keys: [{provider, label, model, hint, created_at, updated_at}]. Never the keys: hint is their last 4 characters |
| PUT | /me/ai/keys/{provider} |
{key?, model?} → the saved key (same shape as above). With key, replaces the one you had. Without it, only changes the model of the saved key (model absent or null = the provider's default); 404 if you have none |
| DELETE | /me/ai/keys/{provider} |
→ {ok: true, deleted} |
| POST | /me/ai/keys/{provider}/test |
{key?} (optional body) → {ok, error, status}. Checks the key in the body, or the saved one, with a call to the provider that spends nothing. At most 10 per minute (429, too_many_attempts) |
| GET | /me/ai/access |
{own_keys, server_ai, credit_usd, spent_usd, remaining_usd, providers}. See below |
Every approval (in pending_approvals, GET /ai/approvals and the
approval_requested event) has a preview with what it is about:
{"kind": "command", "host": "web-1", "command": "rm -rf /var/cache/app/*",
"risk": "high", "reasons": [{"code": "rm_rf", "text": "recursive forced delete"}],
"explanation": "Free disk space", "editable": true}kind is command (run_command), terminal (send_to_terminal), file
(write_file: path, a unified diff of the current file capped at 64 KB
with diff_truncated, added, removed, new_file or diff_error),
plan (plan: the numbered plan of a plan_first task) or other.
risk is low, medium or high; reasons[].code are stable for
translations. Fields that are empty or false are left out; approvals saved
by servers before 0.6 have no preview.
The answer is {approve, always?, edited?, reason?}:
edited(withapprove: true, for previews witheditable: true): the command or plan to use instead of the model's. It is what runs, and the model is told.reason: why it was denied (sent to the model, which does not retry the same action in another form), or a note with an approval.
Both are optional ({approve} alone works as before) and are recorded in
the audit entry (ai.approval.approved / ai.approval.denied, with
edited capped at 4000 characters and reason). The approval_decided
event has them too.
host_ids,group_id(the group and its subgroups) ortagchoose the hosts. With several hosts andfan_out: truethe task becomes the parent of one conversation per host (the same request,[ai] fan_out_concurrencyat a time, at mostmax_fan_out_hostshosts): the parent'sGEThashosts([{host_id, label, task_id, status, summary?, error?, duration_ms?, cost_micros, pending_approvals}]) and each host's task hasparent_id. A message to the parent goes to every host. Push notifications: approvals come from each host's task; only the parent notifies that it finished.plan_first: true: the model first writes a numbered plan without tools, approved (or edited, or denied with a reason so it proposes another) as an approval withtool: "plan"; the task'splanis{text, approved, edited?}.steps: the commands and file writes the task ran, in order ({call_id, tool, host?, command?, path?, ok, edited?, explanation?, at}).- Runbook:
GET /ai/tasks/{id}/runbookbuilds a snippet from the successful steps (the host's name becomes{{host}}when the task used one host; for a multi-host task, the first host that ran commands) andPOSTsaves it. - With
[ai] redact_secrets(default on) passwords, tokens and keys in tool results and terminal context are hidden before they reach the provider.
Who pays for the AI depends on the plan:
- With your own API key for a provider, that provider uses it instead
of the server's key, with the model you chose (or the provider's
default). It never uses the plan's credit. Providers that accept your key:
claude(Anthropic),gpt(OpenAI),openrouterandopencode-api(OpenCode Go). The CLI-based ones (codex,opencode) andlocalare server-only. - The server's providers (its API keys, the Codex subscription...) are
only used when your plan has
server_ai, within its monthly credit. Once it is spent, they answer403withai_budget_exceeded, unless you have a key of your own. - Without either, AI requests (tasks, messages,
/ai/suggest,/ai/explain) answer403withai_key_required: clients show "add your API key in Settings → AI".
The provider chain of a request is: the requested provider (with your key if you have one), then the providers you have a key for, then (if your plan allows it and there is credit left) the server's chain. Your own keys go first even without asking for them.
Keys are stored encrypted with the server's master key and are never
returned. unknown_provider (400) means the provider does not accept your
key. The key must be 1 to 512 printable characters without spaces; the
model, at most 128.
GET /me/ai/access explains your situation:
{
"own_keys": ["claude"],
"server_ai": true,
"credit_usd": 5.0,
"spent_usd": 1.25,
"remaining_usd": 3.75,
"providers": [{"provider": "claude", "label": "Claude", "default_model": "claude-opus-5", "models": ["claude-opus-5", "..."]}]
}credit_usd and remaining_usd are null without credit (no server AI,
or no cap). spent_usd only counts the server's providers, this month
(UTC), by their credit cost: what they cost the server or, for the server's
subscriptions (Codex with ChatGPT, OpenCode Go...) and providers without a
price, their tokens at a reference price (see docs/AI.md, "Cost").
providers are the ones that accept your own key.
In GET /ai/providers, available already takes your plan and your keys
into account; accepts_own_key and uses_own_key tell which providers
accept your key and which ones run with it. When a provider is not
available, reason_code says why (clients translate it):
reason_code |
Meaning |
|---|---|
not_configured |
The server's provider cannot run (missing key, executable, login...) |
own_key_required |
Your plan does not include the server's AI; add your own key for it |
plan |
Not included in your plan (and it does not accept your own key) |
reason is a short English text. For not_configured it is the detailed
cause (paths, environment variables...) only for administrators; everybody
else gets "not available on this server". Task usage events carry
cost_micros (real cost), credit_micros (taken from your credit; 0 with
your own key) and own_key. A task's cost_micros is its real cost.
No authentication. They only exist when the server has [updates] github_repo or [updates.repos] configured (see
DEPLOYMENT.md). The desktop app, the server, the CLI and
the mobile apps are released separately (desktop-vX.Y.Z, server-vX.Y.Z,
cli-vX.Y.Z, android-vX.Y.Z, ios-vX.Y.Z), each from its own repository
or all from one; for each one, the latest release is used, skipping drafts
and pre-releases.
| Method | Route | Description |
|---|---|---|
| GET | /updates/latest.json |
Signed manifest of the latest desktop release, with the downloads pointing to this server. 404 if there is none |
| GET | /updates/download/{file} |
A file of the latest release of a component, streamed |
| GET | /api/v1/downloads |
What can be downloaded, for a downloads page |
/api/v1/downloads answers:
{
"version": "0.2.0",
"tag": "desktop-v0.2.0",
"components": {
"desktop": {"version": "0.2.0", "tag": "desktop-v0.2.0"},
"server": {"version": "0.3.1", "tag": "server-v0.3.1"},
"cli": {"version": "0.2.0", "tag": "cli-v0.2.0"}
},
"files": [
{"name": "Termoak-linux-x86_64.AppImage", "size": 21936632, "os": "linux",
"kind": "app", "component": "desktop", "url": "https://…/updates/download/Termoak-linux-x86_64.AppImage"}
]
}version and tag are the desktop ones (null if none is published).
components may also include android. os is windows, macos,
linux, android or any. kind is app (installer or APK), update
(the macOS .app used to update), app-archive (standalone desktop
binary), manifest, server, cli or other. In old vX.Y.Z releases,
the same termoak-vX.Y.Z-… archive carries both the server and the CLI.
GET /events/ws is a WebSocket with the user's notices, including vault
changes and access changes ({"type":"vault",...}). See
WEBSOCKET-PROTOCOL.md.