Documentation
HTTP API
Every endpoint Kino Cloud exposes - who may call it, what it takes, and what it returns.
Base URL: https://kino.dpdns.org. Everything is JSON except the
install script and the health check. All timestamps are integer unix seconds.
Authentication#
Pass one of these:
| Header | Who uses it |
|---|---|
Authorization: Bearer <session JWT> |
API clients, after /api/auth/signin |
Authorization: Bearer kck_… |
Kino SSH Manager, with an account key |
Authorization: Bearer kca_… |
An agent, refreshing its token |
Authorization: Bearer <relay JWT> |
An agent or manager, on the relay-facing routes |
| session cookie | The web UI only; not part of this API |
Errors come back as {"detail": "..."} with a matching status code: 401 for
a missing or bad credential, 403 for a valid credential that is not allowed,
404 for something that does not exist or is not yours, 409 for a conflict,
422 for a validation failure.
There is no interactive docs page
The OpenAPI UI is disabled. This page is the reference.
Accounts#
POST /api/auth/signup#
Create an account. Returns 403 if registration is closed on this server,
409 if the email is taken.
{ "email": "[email protected]", "password": "at least 8 chars" }
{ "session_token": "eyJ…", "email": "[email protected]" }
POST /api/auth/signin#
Same body, same response. 401 on a wrong email or password — deliberately
indistinguishable from each other, in content and in timing.
GET /api/auth/me#
Auth: session. Returns { "id": 1, "email": "[email protected]" }.
Machines#
Auth for all of these: session or account key.
POST /api/machines#
{ "name": "homelab-pi" }
{
"agent_id": "6b1d…",
"name": "homelab-pi",
"created_at": 1785000000,
"relay_url": null,
"last_seen": null,
"enroll_key": "kce_…",
"install_command": "curl -fsSL …/install/kce_… | sudo sh"
}
enroll_key is shown exactly once. 403 if you are at the machine limit.
GET /api/machines#
Your machines, oldest first, each with relay_url and last_seen filled in
from presence if the agent has reported.
DELETE /api/machines/{agent_id}#
Soft-deletes the machine and drops its presence row. Returns {"ok": true}, or
404 if it is not yours. This is what revokes the agent key — see
Revocation.
POST /api/machines/{agent_id}/connect#
Everything the manager needs to dial one machine, in one call.
{
"token": "eyJ…",
"expires_at": 1785003600,
"relay_url": "wss://relay.example.com",
"relay_healthy": true
}
The token is a 1-hour manager JWT scoped to this agent_id. relay_url is
null if the agent has never reported presence.
Account keys#
Auth: session only. An account key cannot mint account keys — see why.
POST /api/account-keys#
{ "name": "laptop" }
Returns the key record plus "key": "kck_…", the only time the full secret
exists outside the caller.
GET /api/account-keys#
All your keys, newest first, with prefix, created_at, last_used_at and
revoked_at. Never the key itself.
POST /api/account-keys/{key_id}/revoke#
{"ok": true}, or 404 if there is no such active key.
Agent lifecycle#
POST /api/agents/exchange#
No auth — the enroll key is the credential. One-time.
{ "enroll_key": "kce_…" }
{
"agent_id": "6b1d…",
"name": "homelab-pi",
"agent_key": "kca_…",
"token": "eyJ…",
"expires_at": 1785086400
}
401 if the key is unknown or already used. The exchange is transactional, so
two racing installers cannot both win.
POST /api/agents/refresh#
Auth: kca_… agent key. No body.
{ "agent_id": "6b1d…", "token": "eyJ…", "expires_at": 1785086400 }
401 once the machine is deleted — this is the churn cutoff.
POST /api/agents/presence#
Auth: agent relay JWT.
{ "relay_url": "wss://relay.example.com", "agent_id": "optional" }
The token decides which agent_id the presence belongs to; agent_id in the
body is only consulted when the token is unscoped ("*"), and is required in
that case. A client cannot claim someone else’s id with a scoped token.
GET /api/agents/{agent_id}/relay#
Auth: session, or a manager JWT scoped to this agent.
{
"agent_id": "6b1d…",
"relay_url": "wss://relay.example.com",
"updated_at": 1785000000,
"relay_healthy": true
}
404 if no presence has been reported yet; 403 if the token is scoped to a
different agent.
Relays#
POST /api/enroll-codes#
Auth: session. Returns { "code": "…" } — a one-time relay enrollment code.
POST /api/relays/enroll#
No auth — the code is the credential. Called by the relay itself.
{ "code": "…", "url": "wss://relay.example.com", "name": "eu-1" }
{ "public_key": "-----BEGIN PUBLIC KEY-----\n…" }
The controller probes <url>/healthz first and returns 400 if it cannot
reach it, so the relay must already be serving publicly. 401 if the code is
unknown or spent. Re-enrolling an existing URL updates the row rather than
duplicating it.
GET /api/relays#
Auth: session, agent JWT, or manager JWT — any of the three.
[
{ "url": "wss://relay.example.com", "name": "eu-1",
"healthy": true, "last_healthy": 1785000000 }
]
Ordered best-first: assignment policy lives here, and agents latency-race the top of the list. The current policy is simply healthy-first; region, tier, and load slot in later without any client change.
Relay tokens#
POST /api/tokens#
Auth: session.
{ "role": "agent", "agent_id": "6b1d…", "ttl_hours": 720 }
{ "token": "eyJ…", "role": "agent", "agent_id": "6b1d…", "expires_at": 1787592000 }
role is agent or manager. agent_id defaults to "*" (any agent);
ttl_hours defaults to 720 and is capped at 8760. Prefer the short-lived
refresh flow — see the note on long-lived
tokens.
GET /api/tokens#
Auth: session. Your last 200 mints, newest first. Metadata only — the signed tokens are never stored.
Infrastructure#
GET /healthz#
ok as text/plain. No auth.
GET /api/public-key#
The controller’s Ed25519 public key, PEM, as text/plain. No auth — it is
public by definition, and it is what a relay verifies with.
GET /install/{enroll_key}#
A shell script bound to one machine, as text/x-shellscript. Fetching does not
consume the key; running it does. Returns a script that exits with an error
message (status 404) if the link is invalid or spent.
Worked example#
BASE=https://kino.dpdns.org
# 1. Sign in
TOKEN=$(curl -fsS -X POST "$BASE/api/auth/signin" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"…"}' | jq -r .session_token)
# 2. Add a machine, keep the install command
curl -fsS -X POST "$BASE/api/machines" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"homelab-pi"}' | jq -r .install_command
# 3. Later: where is it, and what do I dial it with?
curl -fsS -X POST "$BASE/api/machines/$AGENT_ID/connect" \
-H "Authorization: Bearer $TOKEN" | jq