Console API Reference
The Console node's HTTP service listens on :8089. All REST endpoints use the /api/v1 prefix.
Base URL: http://<console-host>:8089
REST prefix: /api/v1
Content type: application/json
Timestamps: RFC3339
ID format: Opaque strings (acc_*, tok_*, worker UUID, task ID)
Unified error response format:
{ "error": "message" }Auth Model
The Console exposes five credential types for different use cases:
| Item | Cookie session | API Key (Bearer) | Dashboard JIT (Bearer) | Execution Token (Bearer) | MCP JIT (Bearer) |
|---|---|---|---|---|---|
| Credential type | Cookie onlyboxes_console_session | Authorization: Bearer obxk_* | Authorization: Bearer obx_dashboard_jit_v1.* | Authorization: Bearer obx_* | Authorization: Bearer obx_jit_v1.* |
| Origin | POST /api/v1/auth/login | POST /api/v1/api-keys (cookie only) | Externally signed (HMAC-SHA256, CONSOLE_DASHBOARD_JIT_SIGNING_KEY) | POST /api/v1/tokens (cookie only) | Externally signed (HMAC-SHA256, CONSOLE_JIT_SIGNING_KEY) |
| Lifetime | 12 hours (in-memory; invalidated on console restart) | Persistent until manually deleted | Stateless; expires via payload exp or by rotating the signing key | Persistent until manually deleted | Stateless; revoke by rotating the signing key |
| Applicable endpoints | All management endpoints | Most management endpoints (read + selective write) | Selected management APIs for worker-sys provisioning | /api/v1/commands/*, /api/v1/tasks*, /mcp | /api/v1/commands/*, /api/v1/tasks*, /mcp |
| Primary use | Web UI management | Programmatic management | Server-to-server dashboard automation without MCP access | Command execution and MCP | Third-party execution integrations without pre-created accounts |
The following endpoints accept cookie session only and reject API Key Bearer and Dashboard JIT:
POST /api/v1/auth/password,POST /api/v1/api-keys,DELETE /api/v1/api-keys/:api_key_id, and all/api/v1/tokens*endpoints.If no execution tokens exist in the system, trusted-token auth for command execution endpoints and
/mcpreturns401; valid MCP JIT tokens can still authenticate whenCONSOLE_JIT_SIGNING_KEYis configured.MCP JIT tokens (
obx_jit_v1.*) are explicitly rejected on all dashboard/management routes. Dashboard JIT tokens (obx_dashboard_jit_v1.*) are explicitly rejected by/mcp.
REST API
The Web UI and third-party integrations use the same resource-oriented endpoints. Each endpoint enforces the management or execution credential requirements documented below.
Previously published /api/v1/console/* authentication, account, API key, and token paths remain available as compatibility aliases. New integrations should use the canonical paths shown below. Terminal session management has no /api/v1/console/sessions* alias because it was first published at /api/v1/sessions*.
Authentication and Accounts
Login
POST /api/v1/auth/login
Request body:
{
"username": "admin",
"password": "secret"
}| Field | Type | Required | Notes |
|---|---|---|---|
username | string | Yes | Account username |
password | string | Yes | Account password |
Response 200:
{
"authenticated": true,
"account": {
"account_id": "acc_xxx",
"username": "admin",
"is_admin": true
},
"registration_enabled": false,
"console_version": "v0.0.0",
"console_repo_url": "https://..."
}Also sets the onlyboxes_console_session cookie in the response.
Error codes:
| Status | Description |
|---|---|
400 | Malformed JSON |
401 | Incorrect username or password |
500 | Session creation failure |
Get Session
GET /api/v1/auth/session
Accepts Dashboard Cookie, Console API Key, or Dashboard JIT authentication. The response structure is identical to the login response.
Error codes:
| Status | Description |
|---|---|
401 | Not logged in or session expired |
Logout
POST /api/v1/auth/logout
Clears the response cookie and destroys the in-memory session (if present).
Response: 204 No Content
Register Account (Admin only)
POST /api/v1/accounts
Creates a new non-admin account. Requires an authenticated administrator and the registration toggle (CONSOLE_ENABLE_REGISTRATION) must be true.
Request body:
{
"username": "dev-user",
"password": "strong-password"
}| Field | Type | Required | Constraints |
|---|---|---|---|
username | string | Yes | Trimmed non-empty, length ≤ 64, case-insensitively unique system-wide |
password | string | Yes | Non-empty |
Response 201:
{
"account": {
"account_id": "acc_xxx",
"username": "dev-user",
"is_admin": false
},
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}Error codes:
| Status | Description |
|---|---|
400 | Empty username / length > 64 / empty password |
403 | Registration disabled (CONSOLE_ENABLE_REGISTRATION=false) or caller is not admin |
409 | Username conflict |
500 | Database or internal error |
Change Password
POST /api/v1/auth/password
Changes the current account's password. All active sessions for the account are rotated after a successful change.
Request body:
{
"current_password": "old-password",
"new_password": "new-password"
}| Field | Type | Required | Notes |
|---|---|---|---|
current_password | string | Yes | Current password for verification |
new_password | string | Yes | New password |
Response:
| Status | Description |
|---|---|
204 | Password changed successfully |
400 | Malformed JSON / missing field |
401 | Current password incorrect |
500 | Internal error |
List Accounts (Admin only)
GET /api/v1/accounts
Query parameters:
| Param | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | Positive integer |
page_size | int | 20 | Positive integer, max 100 |
Response 200:
{
"items": [
{
"account_id": "acc_xxx",
"username": "admin",
"is_admin": true,
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
}Error codes:
| Status | Description |
|---|---|
400 | Invalid query parameters |
403 | Caller is not admin |
500 | Database or internal error |
Delete Account (Admin only)
DELETE /api/v1/accounts/:account_id
Path parameters:
| Param | Description |
|---|---|
account_id | Account ID (acc_*) |
Response:
| Status | Description |
|---|---|
204 | Deleted successfully |
403 | Cannot delete the currently logged-in account / cannot delete an admin account |
404 | Account not found |
500 | Internal error |
Token Management
Tokens are scoped per account; each account can only manage its own tokens.
All token management endpoints require a Cookie session. API keys and Dashboard JIT tokens are rejected so dashboard bearer credentials cannot mint or manage MCP trusted tokens.
List Tokens
GET /api/v1/tokens
Response 200:
{
"items": [
{
"id": "tok_xxx",
"name": "default-token",
"token_masked": "obx_******abcd",
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}
],
"total": 1
}
token_maskedshows only the last four characters. The plaintext token is only returned at creation time.
Create Token
POST /api/v1/tokens
Request body:
{
"name": "ci-prod",
"token": "optional-manual-token"
}| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | Yes | Trimmed non-empty, length ≤ 64, case-insensitively unique within the account |
token | string | No | Omit to auto-generate (obx_<hex>); if provided, must contain no whitespace, length ≤ 256 |
Response 201:
{
"id": "tok_xxx",
"name": "ci-prod",
"token": "obx_plaintext_or_manual",
"token_masked": "obx_******abcd",
"generated": true,
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}The
tokenfield (plaintext) is only returned in this response and cannot be retrieved again. Save it immediately.
Error codes:
| Status | Description |
|---|---|
400 | Validation failure |
409 | Token name conflict or token value conflict |
500 | Internal error |
Delete Token
DELETE /api/v1/tokens/:token_id
Path parameters:
| Param | Description |
|---|---|
token_id | Token ID (tok_*) |
Response:
| Status | Description |
|---|---|
204 | Deleted successfully |
404 | Token not found (including cross-account access) |
Get Token Plaintext
GET /api/v1/tokens/:token_id/value
This endpoint always returns 410 Gone. Token plaintext is only returned at creation time.
{
"error": "token value is only returned at creation time; delete and recreate the token to obtain a new value"
}API Key Management
API Keys are for programmatic access to most management endpoints. Unlike execution tokens, they cannot be used for command execution endpoints or /mcp. Each account can only manage its own API keys.
List API Keys
GET /api/v1/api-keys
Auth: Cookie session or API Key Bearer
Response 200:
{
"items": [
{
"id": "apik_xxx",
"name": "my-key",
"key_masked": "obxk_******abcd",
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}
],
"total": 1
}Create API Key
POST /api/v1/api-keys
Auth: Cookie session only
Request body:
{ "name": "ci-management" }| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | Yes | Trimmed non-empty, length ≤ 64, case-insensitively unique within the account |
Response 201:
{
"id": "apik_xxx",
"name": "ci-management",
"key": "obxk_plaintext",
"key_masked": "obxk_******abcd",
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:00Z"
}The
keyfield (plaintext) is only returned in this response and cannot be retrieved again. Save it immediately.
Error codes:
| Status | Description |
|---|---|
400 | Malformed body / empty name / name exceeds 64 characters |
401 | Missing or invalid cookie session (API Key Bearer not accepted) |
409 | Name conflict |
503 | Storage unavailable |
500 | Internal error |
Delete API Key
DELETE /api/v1/api-keys/:api_key_id
Auth: Cookie session only
Path parameters:
| Param | Description |
|---|---|
api_key_id | API Key ID (apik_*) |
Response:
| Status | Description |
|---|---|
204 | Deleted successfully |
400 | Missing api_key_id |
401 | Missing or invalid cookie session (API Key Bearer not accepted) |
404 | API Key not found (including cross-account access) |
500 | Internal error |
Worker Management
Permission Matrix
| Operation | Admin | Regular user |
|---|---|---|
| List workers | All workers | Own worker-sys only |
| Stats | All workers | Own worker-sys only |
| Inflight | All workers | Own worker-sys only |
Create normal | Yes | No |
Create worker-sys | Yes (multiple per account) | Yes (multiple per account) |
| Delete any worker | Yes | No |
Delete own worker-sys | Yes | Yes |
Worker types: normal (backed by worker-docker/worker-boxlite), worker-sys (backed by worker-sys).
List Workers
GET /api/v1/workers
Query parameters:
| Param | Type | Default | Options |
|---|---|---|---|
page | int | 1 | Positive integer |
page_size | int | 20 | Positive integer, max 100 |
status | string | all | all / online / offline |
Response 200:
{
"items": [
{
"node_id": "worker-1",
"node_name": "node-a",
"executor_kind": "docker",
"capabilities": [
{ "name": "echo", "max_inflight": 4 }
],
"labels": {
"region": "us",
"obx.owner_id": "acc_xxx",
"obx.worker_type": "normal"
},
"version": "v0.1.0",
"status": "online",
"registered_at": "2026-02-21T00:00:00Z",
"last_seen_at": "2026-02-21T00:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
}Error codes:
| Status | Description |
|---|---|
400 | Invalid query parameters |
Worker Stats
GET /api/v1/workers/stats
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
stale_after_sec | int | 30 | Seconds threshold after which a worker is considered stale (no heartbeat) |
Response 200:
{
"total": 5,
"online": 4,
"offline": 1,
"stale": 1,
"stale_after_sec": 30,
"generated_at": "2026-02-21T00:00:00Z"
}For regular users, stats only reflect their own
worker-sys.
Worker Inflight
GET /api/v1/workers/inflight
Response 200:
{
"workers": [
{
"node_id": "worker-1",
"active_session_count": 3,
"terminal_session_capacity": {
"known": true,
"max_active_sessions": 4
},
"capabilities": [
{ "name": "pythonExec", "inflight": 1, "max_inflight": 4 }
]
}
],
"generated_at": "2026-02-21T00:00:00Z"
}For regular users, results only include their own
worker-sys.
active_session_countis the worker's latest terminal reservation count.terminal_session_capacity.known=falsemeans a legacy worker did not declare a maximum; it must not be interpreted as unlimited.known=true,max_active_sessions=0explicitly means unlimited active terminal sessions.
Create Worker Credentials
POST /api/v1/workers
Request body:
{
"type": "normal"
}| Field | Type | Required | Options |
|---|---|---|---|
type | string | Yes | normal / worker-sys |
Response 201:
{
"node_id": "2f51f8f9-77f2-4c1a-a4f5-2036fc9fcb9e",
"type": "normal",
"worker_secret": "a3f8c2..."
}
worker_secretis returned only once in this response and cannot be retrieved again.
Error codes:
| Status | Description |
|---|---|
400 | Malformed body / invalid type value |
403 | Regular user attempting to create a normal worker |
503 | Provisioning service unavailable |
500 | Creation failure |
Delete Worker
DELETE /api/v1/workers/:node_id
Path parameters:
| Param | Description |
|---|---|
node_id | Worker node ID (UUID) |
Response:
| Status | Description |
|---|---|
204 | Deleted successfully |
400 | Missing node_id |
404 | Worker not found (cross-account access by regular users also returns 404) |
503 | Provisioning service unavailable |
Get Worker Startup Command
GET /api/v1/workers/:node_id/startup-command
This endpoint always returns 410 Gone. The startup command (including the secret) is only returned when the worker is created.
{
"error": "worker secret is returned only when creating the worker; delete and recreate to get a new startup command"
}Terminal Session API
These endpoints require management authentication (cookie, Console API key, or Dashboard JIT). They list, inspect, renew, and delete confirmed terminal sessions. Sessions that have not finished their first terminalExec are not visible.
Scope:
- non-admin: only the caller's sessions;
account_idfor another account returns404 - admin: all sessions by default;
account_idfilters the list. Get, renew, and delete requireaccount_idbecausesession_idis unique per account, not globally
status values:
ready: the bound worker is dispatchableunavailable: the bound worker is disconnected; the session keeps its worker and leasereconciling: the bound worker is reconnecting and checking the session resource
Deleting a session removes Console's route and any public preview routes for that session immediately. The worker sandbox is reclaimed when its lease expires.
List Sessions
GET /api/v1/sessions
Query parameters:
| Parameter | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | Positive integer |
page_size | int | 20 | Positive integer, max 100 |
account_id | string | Optional. Admin uses it to filter; non-admin may only pass their own account |
Response 200:
{
"items": [
{
"account_id": "acc_xxx",
"session_id": "sess_xxx",
"worker_id": "worker-1",
"status": "ready",
"lease_expires_at": "2026-02-21T00:01:00Z",
"last_used_at": "2026-02-21T00:00:30Z",
"created_at": "2026-02-21T00:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
}Error codes:
| Status | Description |
|---|---|
400 | Invalid query |
401 | Missing or invalid dashboard credential |
404 | Non-admin requested another account |
503 | Session registry unavailable |
Get Session
GET /api/v1/sessions/:session_id?account_id=acc_xxx
Success 200: same object as a list item.
Error codes:
| Status | Description |
|---|---|
400 | Admin omitted account_id |
401 | Missing or invalid dashboard credential |
404 | Session not found (including cross-account) |
503 | Session registry unavailable |
Renew Session Lease
POST /api/v1/sessions/:session_id/renew?account_id=acc_xxx
Console asks the Worker that owns the session to extend its lease, then persists the absolute expiry confirmed by that Worker. Renewal is monotonic: a shorter TTL does not reduce the current expiry. Renewing a session does not extend any public preview route.
{
"lease_ttl_sec": 300
}lease_ttl_sec must be positive and within the owning Worker's configured terminal lease bounds.
Success 200: same object as a list item, with updated lease_expires_at and last_used_at values.
Error codes:
| Status | Description |
|---|---|
400 | Invalid body, admin omitted account_id, or TTL is outside the Worker's lease bounds |
401 | Missing or invalid dashboard credential |
404 | Session not found (including cross-account) |
502 | Worker rejected the renewal or returned an invalid result |
503 | Session registry or bound Worker unavailable |
504 | Renewal timed out |
Delete Session
DELETE /api/v1/sessions/:session_id?account_id=acc_xxx
Response:
| Status | Description |
|---|---|
204 | Deleted |
400 | Admin omitted account_id |
401 | Missing or invalid dashboard credential |
404 | Session not found (including cross-account) |
500 | Failed to delete |
503 | Session registry unavailable |
Public Preview Routes
These management APIs accept Dashboard cookie, Console API key, or Dashboard JIT authentication. Routes are account-scoped and persisted in SQLite. See Public Preview Proxy for the full deployment guide.
POST /api/v1/proxy-routes
{
"session_id": "sess_xxx",
"port": 8080
}Success 201:
{
"route_key": "ceirceirceirceirceirceirce",
"account_id": "acc_xxx",
"session_id": "sess_xxx",
"port": 8080,
"url": "https://ceirceirceirceirceirceirce.public-preview.example.com",
"created_at": "2026-02-21T00:00:00Z",
"expires_at": "2026-02-22T00:00:00Z"
}Rules and errors:
session_idmust belong to the current account and have a confirmed route to an online proxy-enabled Docker, Boxlite, or E2B Worker.portmust be1..65535.- route URLs use
CONSOLE_PROXY_PUBLIC_SCHEME(httpsby default) andCONSOLE_PROXY_PUBLIC_BASE_DOMAIN; usehttponly for trusted local development. - route keys use lowercase Base32 and default to 26 characters.
CONSOLE_PROXY_ROUTE_KEY_LENGTHcontrols the length of newly generated keys in the range8..26; existing routes remain valid after it changes. Values below16reduce resistance to URL guessing and are intended only for trusted local or low-risk deployments. - each account can hold at most 16 active routes by default; configure it with
CONSOLE_PROXY_ROUTE_MAX_PER_ACCOUNT. - each session can hold at most 2 active routes by default; configure it with
CONSOLE_PROXY_ROUTE_MAX_PER_SESSION. - default route TTL is
86400seconds, can be changed withCONSOLE_PROXY_ROUTE_TTL_SEC, and cannot exceed604800seconds (7 days). 400invalid body, session ID, or port.404session route not found.429account or session route limit reached.503the session's Worker has no available proxy endpoint.
GET /api/v1/proxy-routes?page=1&page_size=20&account_id=acc_xxx
Scope:
- non-admin accounts can list only their own routes; setting
account_idto another account returns404. - admins list routes across all accounts by default and can use
account_idto filter one account.
Query parameters:
| Parameter | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | Positive integer |
page_size | int | 20 | Positive integer, max 100 |
account_id | string | Optional account filter as described above |
Success 200:
{
"items": [
{
"route_key": "ceirceirceirceirceirceirce",
"account_id": "acc_xxx",
"session_id": "sess_xxx",
"port": 8080,
"url": "https://ceirceirceirceirceirceirce.public-preview.example.com",
"created_at": "2026-02-21T00:00:00Z",
"expires_at": "2026-02-22T00:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
}Error codes:
| Status | Description |
|---|---|
400 | Invalid page or page_size |
401 | Missing or invalid management credential |
404 | Non-admin requested another account's routes |
DELETE /api/v1/proxy-routes/:route_key
204deleted.404missing, expired, or owned by another account.
The returned preview URL is anonymous: anyone holding it can access the Sandbox service without an Onlyboxes Cookie or Bearer token. Nginx resolves every new HTTP request through the protected internal Console endpoint. Docker and Boxlite receive a fresh 15-second Route Token for the Worker hop; E2B returns the current sandbox origin and an internal traffic token so the data path is browser → Nginx → E2B. Token expiry does not end an accepted HTTP/SSE/WebSocket connection. Route access does not renew the Sandbox lease. Active routes and their original URLs survive a Console restart; new requests remain unavailable until the owning Worker reconnects and the terminal session finishes recovery.
Command Execution API
The following endpoints use Bearer token auth (Authorization: Bearer <token>) and are intended for external programs, AI agents, and MCP clients.
For /mcp only, clients that cannot set custom headers may pass the same token as a URL query parameter: POST /mcp?token=<token>. Bearer headers remain the recommended path and take precedence when present; command execution and task REST endpoints do not accept query-token auth. The query parameter name defaults to token and can be changed with CONSOLE_MCP_TOKEN_QUERY_PARAM.
Command Execution
Echo
POST /api/v1/commands/echo
Tests connectivity and verifies an echo worker is reachable.
Request body:
{
"message": "hello",
"timeout_ms": 5000
}| Field | Type | Required | Constraints |
|---|---|---|---|
message | string | Yes | Trimmed non-empty |
timeout_ms | int | No | 1..60000, default 5000 |
Response 200:
{ "message": "hello" }Error codes:
| Status | Description |
|---|---|
400 | Malformed body / missing message / timeout out of range |
429 | No available concurrency capacity |
503 | No online echo worker |
504 | Execution timed out |
502 | Execution or internal error |
Terminal Execution
POST /api/v1/commands/terminal
Executes a shell command inside a persistent terminal session, supporting state reuse across requests.
Request body:
{
"command": "pwd",
"session_id": "optional-session",
"create_if_missing": false,
"lease_ttl_sec": 60,
"timeout_ms": 60000,
"request_id": "optional-idempotency-key"
}| Field | Type | Required | Constraints |
|---|---|---|---|
command | string | Yes | Trimmed non-empty |
session_id | string | No | Existing session ID; omit to auto-create a new session |
create_if_missing | bool | No | Auto-create if session_id not found, default false |
lease_ttl_sec | int | No | Session lease duration in seconds; clamped to worker's configured [min, max] range (default min/default: 60, default max: 1800) |
timeout_ms | int | No | 1..600000, default 60000 |
request_id | string | No | Idempotency key, scoped per account |
When create_if_missing=true, a session_id beginning with the reserved Computer Use prefix (CU: by default) is rejected. With create_if_missing=false, it remains an ordinary sandbox-session lookup.
Response 200:
{
"session_id": "sess_xxx",
"created": true,
"stdout": "...",
"stderr": "...",
"exit_code": 0,
"stdout_truncated": false,
"stderr_truncated": false,
"lease_expires_unix_ms": 1770000000000
}| Field | Description |
|---|---|
session_id | Session ID used (new or existing) |
created | Whether the session was newly created |
stdout / stderr | Command output |
exit_code | Command exit code |
stdout_truncated / stderr_truncated | Whether output was truncated |
lease_expires_unix_ms | Session lease expiry (Unix millisecond timestamp) |
Error codes:
| Status | Error code | Description |
|---|---|---|
400 | invalid_payload | Invalid request parameters |
404 | session_not_found | session_id not found and create_if_missing=false |
409 | session_busy | Session occupied by another request, or task cancelled |
429 | no_capacity | The selected worker has exhausted the capability's concurrency quota |
429 | session_capacity_exceeded | The selected sandbox worker has reached WORKER_TERMINAL_MAX_ACTIVE_SESSIONS; existing sessions remain usable, but this request cannot create a new session |
503 | session_unavailable | The session's bound worker is offline or reconciling after restart; retry the same session_id without rerouting it |
503 | — | No available worker |
504 | — | Execution timed out |
502 | — | Other execution failure |
Computer Use Execution
POST /api/v1/commands/computer-use
Lists the calling account's Worker Systems or executes a shell command on one selected worker-sys host. Execution is pinned to the requested Worker and never falls back.
Request body:
{
"command": "pwd",
"worker_id": "worker-id",
"timeout_ms": 60000,
"request_id": "optional-idempotency-key"
}| Field | Type | Required | Constraints |
|---|---|---|---|
worker_id | string | No | Caller-owned worker-sys; omit for list mode |
command | string | Conditional | Required and non-empty when worker_id is present |
timeout_ms | int | No | 1..600000, default 60000 |
request_id | string | No | Idempotency key, scoped per account |
Response 200:
Without worker_id, Console performs no Worker dispatch and returns all caller-owned online and offline Worker Systems:
{"worker_list":[{"worker_id":"worker-id","node_name":"laptop","status":"online","capabilities":[]}]}With worker_id, the response is:
{
"stdout": "...",
"stderr": "...",
"exit_code": 0,
"stdout_truncated": false,
"stderr_truncated": false
}Unlike Terminal Execution, Computer Use has no session (
session_idabsent). Each command runs directly on the selected host; there is no container isolation.
Error codes:
| Status | Error code | Description |
|---|---|---|
400 | invalid_payload | Invalid request parameters |
409 | session_busy | Worker busy or task cancelled |
429 | no_capacity | No available concurrency capacity |
503 | — | Selected Worker is offline or lacks the capability |
504 | — | Execution timed out |
502 | — | Other execution failure |
Sandbox Metadata API
Sandbox metadata is account-scoped by bearer token. Execution tokens and MCP JIT tokens can call this endpoint.
Get Sandbox Metadata
GET /api/v1/sandbox/metadata
Response 200:
{
"provider": "onlyboxes",
"api_version": "2026-05-25",
"console": { "version": "0.6.1" },
"limits": {
"max_task_timeout_ms": 600000,
"max_task_wait_ms": 60000,
"max_terminal_timeout_ms": 600000,
"default_terminal_lease_sec": 60,
"max_terminal_lease_sec": 86400
},
"capabilities": [
{
"name": "terminalExec",
"available": true,
"online_nodes": 1,
"max_inflight": 4
}
],
"workers": {
"total": 1,
"online": 1,
"offline": 0,
"stale": 0
}
}Capability scope:
| Capability | Scope |
|---|---|
computerUse, readImage | Caller-owned worker-sys only |
terminalExec, terminalResource, pythonExec, echo | Global online worker availability |
Error codes:
| Status | Description |
|---|---|
401 | Missing or invalid bearer token |
503 | Worker registry unavailable |
Task API
Tasks are suited for asynchronous execution or polling workflows. Ownership is strictly scoped to the token's owning account.
Submit Task
POST /api/v1/tasks
Request body:
{
"capability": "pythonExec",
"input": { "code": "print(1)" },
"mode": "auto",
"wait_ms": 1500,
"timeout_ms": 60000,
"request_id": "optional-idempotency-key"
}| Field | Type | Required | Constraints |
|---|---|---|---|
capability | string | Yes | Non-empty; matches a capability name declared by a worker (case-insensitive; normalized to lowercase in storage and responses) |
input | object | No | Valid JSON object; defaults to {} if omitted |
mode | string | No | sync / async / auto, default auto |
wait_ms | int | No | 1..60000, default 1500; max milliseconds to wait in auto mode |
timeout_ms | int | No | 1..600000, default 60000; total task timeout |
request_id | string | No | Idempotency key, scoped per account |
Capability-specific routing is identical to MCP and the dedicated command endpoints:
computerUsereadsinput.worker_id; when omitted, the task completes locally withresult.worker_listand no Worker dispatch.readImageusesinput.session_id="CU:<worker_id>"to target a caller-owned Worker System. Other values are sandbox terminal session IDs.terminalExecrejectscreate_if_missing=truewheninput.session_idstarts with the reserved Computer Use prefix.
CU:is the default, case-sensitive Computer Use session ID prefix. It can be changed with theCONSOLE_COMPUTER_USE_SESSION_ID_PREFIXenvironment variable or thecomputer_use_session_id_prefixTOML setting; the example above uses the default. Console uses the same setting to interpret session IDs in the task API, MCPreadImage/exportFile, andterminalExec.
One task keeps the same
task_idandrequest_idacross internal terminal capacity retries;command_idis the current or last worker dispatch attempt, so it can change while the task is running.
mode field:
| mode | Behavior |
|---|---|
sync | Wait synchronously until the task completes |
async | Return 202 immediately without waiting |
auto | Wait up to wait_ms; return 202 if not yet complete |
Response — task still running 202:
{
"task_id": "task_xxx",
"request_id": "req-1",
"command_id": "cmd_xxx",
"capability": "pythonexec",
"status": "running",
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:01Z",
"deadline_at": "2026-02-21T00:01:00Z",
"status_url": "/api/v1/tasks/task_xxx"
}Response — task completed successfully 200:
{
"task_id": "task_xxx",
"capability": "pythonexec",
"status": "succeeded",
"result": {
"output": "1\n",
"stderr": "",
"exit_code": 0
},
"created_at": "2026-02-21T00:00:00Z",
"updated_at": "2026-02-21T00:00:01Z",
"deadline_at": "2026-02-21T00:01:00Z",
"completed_at": "2026-02-21T00:00:01Z"
}Task error field (present on terminal failure):
{
"error": {
"code": "execution_failed",
"message": "..."
}
}Terminal response codes:
| Status | Description |
|---|---|
200 | Task succeeded |
409 | Task was cancelled |
504 | Task timed out |
429 | Failed, error.code=no_capacity or error.code=session_capacity_exceeded |
503 | Failed, error.code=no_worker |
502 | Failed (other error code) |
Submission-phase error codes:
| Status | Description |
|---|---|
400 | Invalid parameters / invalid mode / malformed body |
409 | request_id already in progress |
429 | No available concurrency capacity |
503 | No worker with matching capability |
504 | Request timed out |
502 | Submission failure |
Get Task
GET /api/v1/tasks/:task_id
Path parameters:
| Param | Description |
|---|---|
task_id | Task ID |
Response:
| Status | Description |
|---|---|
200 | Returns task snapshot (see Submit Task response structure) |
404 | Task not found (including cross-account access) |
Cancel Task
POST /api/v1/tasks/:task_id/cancel
Path parameters:
| Param | Description |
|---|---|
task_id | Task ID |
Response:
| Status | Description |
|---|---|
200 | Cancellation accepted (or best-effort cancel acknowledged) |
404 | Task not found (including cross-account access) |
409 | Task already in terminal state; returns current task snapshot |
500 | Cancellation failure |
JIT Token Authentication
JIT (Just-in-Time) tokens let a trusted upstream system authenticate requests on behalf of a user without pre-creating an Onlyboxes account. The Console verifies the HMAC signature, derives a deterministic account from (iss, sub), and creates that account on first use with dashboard password login disabled.
Onlyboxes has two JIT channels with separate prefixes and signing keys:
| Channel | Prefix | Signing key | Required payload | Accepted by | Rejected by |
|---|---|---|---|---|---|
| MCP JIT | obx_jit_v1. | CONSOLE_JIT_SIGNING_KEY | iss, sub; optional exp | /api/v1/commands/*, /api/v1/tasks*, /mcp | Dashboard management APIs |
| Dashboard JIT | obx_dashboard_jit_v1. | CONSOLE_DASHBOARD_JIT_SIGNING_KEY | iss, sub, scope:"dashboard"; optional exp | Selected management APIs, such as worker-sys provisioning | /mcp and cookie-only endpoints |
The two signing keys must be different. This keeps MCP execution access and dashboard automation access isolated even though they share account derivation.
Shared Account Mapping
Both channels map the same (iss, sub) pair to the same non-admin JIT account. That means an upstream identity can create a worker-sys through Dashboard JIT and later use MCP JIT under the same account ownership, without sharing signing material between the two channels.
Rules:
issandsubmust be non-empty strings after normalization.- The derived account ID is deterministic, so repeated use of the same pair reuses the same account.
- JIT-created accounts cannot log in with a dashboard password.
- JIT-created accounts are not admins.
MCP JIT
MCP JIT is for execution clients and MCP clients that should not require a stored trusted token.
Prerequisite: set CONSOLE_JIT_SIGNING_KEY. If unset, MCP JIT tokens are rejected.
Token format:
obx_jit_v1.<base64url-payload>.<base64url-signature>| Part | Content |
|---|---|
| Prefix | obx_jit_v1. — literal string, included in the signed message |
| Payload | Base64url (no padding) of JSON containing iss, sub, and optional exp |
| Signature | Base64url (no padding) of HMAC-SHA256 over obx_jit_v1.<payload> |
Use as: Authorization: Bearer obx_jit_v1.<payload>.<signature>
Example payload:
{ "iss": "my-platform", "sub": "user-42", "exp": 1770000000000 }Accepted endpoints: /api/v1/commands/*, /api/v1/tasks*, /mcp
Rejected endpoints: dashboard management APIs, including worker and token management routes.
Dashboard JIT
Dashboard JIT is for server-to-server dashboard automation, especially provisioning a user's worker-sys, without granting MCP execution access.
Prerequisite: set CONSOLE_DASHBOARD_JIT_SIGNING_KEY. It must differ from CONSOLE_JIT_SIGNING_KEY.
Token format:
obx_dashboard_jit_v1.<base64url-payload>.<base64url-signature>| Part | Content |
|---|---|
| Prefix | obx_dashboard_jit_v1. — literal string, included in the signed message |
| Payload | Base64url (no padding) of JSON containing iss, sub, scope:"dashboard", and optional exp |
| Signature | Base64url (no padding) of HMAC-SHA256 over obx_dashboard_jit_v1.<payload> |
Example payload:
{
"iss": "my-platform",
"sub": "user-42",
"scope": "dashboard",
"exp": 1770000000000
}Use as: Authorization: Bearer obx_dashboard_jit_v1.<payload>.<signature>
Accepted endpoints: selected management APIs that are safe for non-admin account-scoped automation, such as worker-sys provisioning and listing under the derived account.
Rejected endpoints:
/mcp- MCP/execution REST APIs
- admin-only management APIs
- cookie-only management APIs, including all
/api/v1/tokens*endpoints
Signing Flow
- Pick the channel and signing key: MCP JIT or Dashboard JIT.
- Encode the channel-specific JSON payload as Base64url without padding.
- Sign
<prefix><payload>with HMAC-SHA256 and the channel's key. - Send the resulting token in the
Authorization: Bearer ...header. - The Console verifies the prefix, signature, required claims, and optional expiry.
- On first use, the Console derives and creates the non-admin JIT account, then handles the request under that account's ownership.
Revocation
- Rotate
CONSOLE_JIT_SIGNING_KEYto revoke all MCP JIT tokens. - Rotate
CONSOLE_DASHBOARD_JIT_SIGNING_KEYto revoke all Dashboard JIT tokens. - Use short
expvalues on Dashboard JIT tokens when possible to reduce replay risk.
Reference Implementations
The examples below mint MCP JIT tokens without exp; add an exp Unix-milliseconds field to make them expire. To mint Dashboard JIT tokens, use the obx_dashboard_jit_v1. prefix, sign with CONSOLE_DASHBOARD_JIT_SIGNING_KEY, and include scope:"dashboard" plus optional exp in the payload:
{ "iss": "my-platform", "sub": "user-42", "scope": "dashboard", "exp": 1770000000000 }Python
import hmac, hashlib, base64, json
def make_jit_token(signing_key: str, issuer: str, subject: str) -> str:
payload_bytes = json.dumps({"iss": issuer, "sub": subject}, separators=(",", ":")).encode()
payload_b64 = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
message = f"obx_jit_v1.{payload_b64}"
sig = hmac.new(signing_key.encode(), message.encode(), hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
return f"{message}.{sig_b64}"
token = make_jit_token("your-signing-key", "my-platform", "user-42")
# Use as: Authorization: Bearer <token>TypeScript / Node.js
import { createHmac } from "crypto";
function makeJitToken(signingKey: string, issuer: string, subject: string): string {
const payload = Buffer.from(JSON.stringify({ iss: issuer, sub: subject }))
.toString("base64url");
const message = `obx_jit_v1.${payload}`;
const sig = createHmac("sha256", signingKey).update(message).digest("base64url");
return `${message}.${sig}`;
}
const token = makeJitToken("your-signing-key", "my-platform", "user-42");
// Use as: Authorization: Bearer <token>Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
)
func makeJITToken(signingKey, issuer, subject string) (string, error) {
payload, err := json.Marshal(map[string]string{"iss": issuer, "sub": subject})
if err != nil {
return "", err
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
message := "obx_jit_v1." + payloadB64
mac := hmac.New(sha256.New, []byte(signingKey))
mac.Write([]byte(message))
sigB64 := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return message + "." + sigB64, nil
}
// token, _ := makeJITToken("your-signing-key", "my-platform", "user-42")
// Use as: Authorization: Bearer <token>Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public static String makeJitToken(String signingKey, String issuer, String subject) throws Exception {
String payloadJson = String.format("{\"iss\":\"%s\",\"sub\":\"%s\"}", issuer, subject);
String payloadB64 = Base64.getUrlEncoder().withoutPadding()
.encodeToString(payloadJson.getBytes());
String message = "obx_jit_v1." + payloadB64;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingKey.getBytes(), "HmacSHA256"));
String sigB64 = Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(message.getBytes()));
return message + "." + sigB64;
}
// String token = makeJitToken("your-signing-key", "my-platform", "user-42");
// Use as: Authorization: Bearer <token>