All HTTP endpoints are served on port 9091 (configurable via [server].http_port).
JWT-authenticated endpoints require the Authorization header:
Authorization: Bearer <access_token>Access tokens expire after access_token_ttl_secs (default: 1 hour). Use the /token/refresh endpoint to obtain a new pair.
All error responses follow this format:
{ "error": "error message" }Health check.
Response 200 OK
{ "status": "ok" }Authenticate with username and password. Returns JWT access and refresh tokens.
Request Body
{
"username": "admin",
"password": "your-password",
"device_id": "macbook-pro"
}| Field | Type | Required | Description |
|---|---|---|---|
username |
string | Yes | Account username |
password |
string | Yes | Account password |
device_id |
string | No | Device identifier (included in refresh token for per-device management) |
Response 200 OK
{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600
}Errors
| Status | Message | Description |
|---|---|---|
401 |
invalid credentials |
Wrong username or password |
401 |
this account uses OIDC login |
Password login not available for OIDC accounts |
429 |
too many attempts, retry after Ns |
Brute force lockout active |
Self-register a new user account. Only available when registration_mode = "open" or registration_mode = "approval" in config.
Request Body
{
"username": "newuser",
"password": "strong-password"
}| Field | Type | Required | Constraints |
|---|---|---|---|
username |
string | Yes | 3-32 characters, alphanumeric + _, -, . |
password |
string | Yes | 8-128 characters |
Response 201 Created
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "newuser"
}Errors
| Status | Message | Description |
|---|---|---|
403 |
registration is disabled |
registration_mode is "closed" |
409 |
username already exists |
Duplicate username |
422 |
username must be 3-32 characters |
Invalid username length |
422 |
password must be 8-128 characters |
Invalid password length |
Refresh an access token using a refresh token. Implements one-time-use rotation: the old refresh token is invalidated and a new pair is returned.
Request Body
{
"refresh_token": "eyJhbGciOi...",
"device_id": "macbook-pro"
}| Field | Type | Required | Description |
|---|---|---|---|
refresh_token |
string | Yes | Current refresh token |
device_id |
string | No | Device identifier |
Response 200 OK
{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600
}Errors
| Status | Message | Description |
|---|---|---|
401 |
invalid or expired refresh token |
Token is expired, already used, or invalid |
Check available authentication methods for a username. Returns "password" or "oidc" depending on server configuration.
Request Body
{
"username": "admin"
}Response 200 OK (password auth)
{ "method": "password" }Response 200 OK (OIDC configured)
{
"method": "oidc",
"auth_url": "/auth/oidc/authorize?redirect_uri=..."
}Returns server authentication configuration (registration mode, OIDC availability).
Response 200 OK
{
"registration_mode": "open",
"oidc_enabled": true,
"server_version": "0.2.0"
}The device code flow allows CLI tools to authenticate via browser without passing credentials on the command line.
Request a device code for CLI authentication.
Response 200 OK
{
"device_code": "550e8400-e29b-41d4-a716-446655440000",
"user_code": "WDJB-MJHT",
"verification_url": "https://sync.example.com/login/device",
"expires_in": 300,
"interval": 5
}Poll for device code completion. The CLI polls this endpoint at the specified interval.
Request Body
{
"device_code": "550e8400-e29b-41d4-a716-446655440000",
"device_key": "optional-stable-uuid",
"device_name": "optional-hostname"
}| Field | Type | Required | Description |
|---|---|---|---|
device_code |
string | Yes | Code returned by /device/code |
device_key |
string | No | Stable client UUID. If supplied, the device is registered on approval |
device_name |
string | No | Human-readable device name (truncated to 64 chars) |
Response 200 OK (authorization complete)
{
"access_token": "eyJhbGciOi...",
"refresh_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600
}Errors
| Status | Body | Description |
|---|---|---|
400 |
{ "error": "authorization_pending" } |
User hasn't approved yet, keep polling |
400 |
{ "error": "slow_down", "interval": 10 } |
Client is polling faster than 5s |
400 |
{ "error": "expired_token" } |
Device code is unknown or already consumed |
410 |
{ "error": "expired_token" } |
Device code has expired |
Approve a pending device code. Called by the browser after the user submits the user_code from a logged-in session. Requires JWT.
Request Body
{
"user_code": "WDJB-MJHT"
}Response 204 No Content on success.
Errors
| Status | Message | Description |
|---|---|---|
404 |
invalid or expired code |
Unknown user_code |
409 |
code already approved |
Code already consumed |
410 |
code expired |
Code expired |
These endpoints are only available when OIDC is configured (all oidc_* fields set in config).
Initiates the OIDC login flow. Redirects the user to the identity provider.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
redirect_uri |
No | Client redirect URI after authentication (CLI flow: must be localhost) |
Response 307 Temporary Redirect to the identity provider's authorization endpoint.
OIDC callback handler. Exchanges the authorization code for tokens and creates/finds the user.
Query Parameters
| Parameter | Description |
|---|---|
code |
Authorization code from the identity provider |
state |
CSRF state token |
error |
Error from the identity provider (optional) |
Response
- CLI flow (localhost
redirect_uri):307 Redirecttoredirect_uri?access_token=...&refresh_token=...&token_type=Bearer&expires_in=... - Browser flow (no
redirect_uri):307 Redirectto/admin#access_token=...&refresh_token=...&expires_in=...
Queries are served directly from the EventStore. The same interface as the local toki daemon's REPORT protocol — toki virtual queries (usage{}, events{}, cost{}) and the daemon's JSON output format.
Single endpoint covering both instant (stat) and range (chart) queries. When step is supplied, results are bucketed; without step, a single aggregated result for the full [start, end) range is returned.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
query |
Yes | Toki virtual query: usage{}, events{}, or cost{}. Group-by via by (model) or by (project) |
start |
No | Epoch seconds, YYYYMMDD, or YYYYMMDDhhmmss. Defaults to 0 |
end |
No | Same formats as start. Defaults to now |
step |
No | Bucket size (e.g., 3600, 1h, 1d, 1w). Omit for instant query |
scope |
No | self (default), team:<team_id>, or all. Subject to server's max_query_scope |
tz |
No | IANA timezone name for bucket formatting (e.g., Asia/Seoul). Defaults to UTC |
start_of_week |
No | Week start for step=1w (mon-sun). Default mon |
Range queries are capped at 2000 buckets per request — the server rejects steps that would exceed this for the given range.
Response 200 OK
{
"providers": {
"claude_code": [
{
"period": "2026-03-28T00:00:00|claude-opus-4-6",
"usage_per_models": [
{
"model": "claude-opus-4-6",
"input_tokens": 12345,
"output_tokens": 6789,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"total_tokens": 19134,
"events": 42,
"cost_usd": 0.18
}
]
}
]
}
}period is <ISO timestamp>|<group key>. Codex provider entries use cached_input_tokens and reasoning_output_tokens in place of the cache fields. cost_usd is only present for cost{} queries or when a pricing entry matches the model.
Errors
| Status | Description |
|---|---|
400 |
Invalid time format, invalid scope, step too small for range, or step > range |
403 |
Scope not enabled by server (max_query_scope) or not a team member |
502 |
EventStore backend unavailable |
List all devices registered under the authenticated user.
Response 200 OK
[
{
"device_id": "550e8400-e29b-...",
"device_name": "macbook-pro",
"last_seen": "2026-03-28T10:30:00Z"
}
]Remove a device from the authenticated user's account.
Response 200 OK
{ "deleted": true }Rename a device.
Request Body
{ "name": "work-laptop" }Response 200 OK
{ "updated": true }Change the authenticated user's password.
Request Body
{
"current_password": "old-password",
"new_password": "new-strong-password"
}Response 200 OK
{ "updated": true }List team memberships for the authenticated user.
Response 200 OK
[
{
"team_id": "team-uuid",
"team_name": "engineering"
}
]All admin endpoints require a JWT from a user with the admin role.
| Method | Path | Description |
|---|---|---|
GET |
/admin/settings |
Get current server settings (registration_mode, OIDC fields, max_query_scope) |
PUT |
/admin/settings/:key |
Update one setting by key |
Allowed :key values: registration_mode, oidc_issuer, oidc_client_id, oidc_client_secret, oidc_redirect_uri, max_query_scope.
Request Body
{ "value": "approval" }Response 204 No Content. Returns 422 if the key is unknown or the value fails validation (registration_mode must be open|approval|closed; max_query_scope must be self|team|all).
| Method | Path | Description |
|---|---|---|
GET |
/admin/pending |
List users awaiting approval (when registration_mode = "approval") |
POST |
/admin/pending/:id/approve |
Approve a pending registration |
POST |
/admin/pending/:id/reject |
Reject a pending registration |
| Method | Path | Description |
|---|---|---|
GET |
/admin/server-info |
Server version, uptime, connected devices count, database stats |
| Method | Path | Description |
|---|---|---|
PATCH |
/admin/users/:user_id/role |
Change a user's role ("admin" or "user") |
| Method | Path | Description |
|---|---|---|
GET |
/admin/users |
List all users |
POST |
/admin/users |
Create a user |
DELETE |
/admin/users/:user_id |
Delete a user |
PATCH |
/admin/users/:user_id/password |
Change a user's password |
PATCH |
/admin/users/:user_id/active |
Activate or deactivate a user ({ "active": bool }) |
Request Body
{
"username": "newuser",
"password": "strong-password"
}Response 201 Created
{
"id": "550e8400-e29b-...",
"username": "newuser"
}Request Body
{ "password": "new-password" }| Method | Path | Description |
|---|---|---|
GET |
/admin/devices |
List all devices across all users |
DELETE |
/admin/devices/:device_id |
Delete any device |
| Method | Path | Description |
|---|---|---|
GET |
/admin/teams |
List all teams |
POST |
/admin/teams |
Create a team |
DELETE |
/admin/teams/:team_id |
Delete a team |
GET |
/admin/teams/:team_id/members |
List team members |
POST |
/admin/teams/:team_id/members |
Add a team member |
DELETE |
/admin/teams/:team_id/members/:user_id |
Remove a team member |
Request Body
{ "name": "engineering" }Request Body
{ "user_id": "user-uuid" }| Method | Path | Description |
|---|---|---|
GET |
/ |
Redirects to /admin |
GET |
/admin |
Admin dashboard (HTML/JS SPA) |
GET |
/login |
Login page (HTML) |
The dashboard authenticates via JWT stored in browser localStorage. After OIDC login, tokens are passed via URL fragment (#access_token=...).
Port 9090 uses a custom binary protocol (bincode serialization), not HTTP. The protocol is implemented in the toki-sync-protocol crate and is not intended for direct use — connect via the toki CLI (toki settings sync enable).
| Frame field | Size | Meaning |
|---|---|---|
| Message type | 4 bytes (u32 LE) | Frame type (AUTH, SYNC_BATCH, etc.) |
| Payload length | 4 bytes (u32 LE) | Payload byte count |
| Payload | N bytes | bincode-encoded message, optionally zstd-compressed |
For the full message-type table, handshake sequence, and design rationale, see DESIGN.md — Sync Protocol.