Okena includes a local HTTP/WebSocket server for remote control — useful for mobile companion apps or access via Cloudflare Tunnel.
- Open Settings and enable Remote Server (under Appearance)
- The status bar shows
REMOTE :19100 K7M2-9QFP— click to copy the pairing code - Pair from another device:
curl -X POST http://127.0.0.1:19100/v1/pair \ -H 'Content-Type: application/json' \ -d '{"code":"K7M2-9QFP"}'
- Use the returned token for all subsequent requests
- Server always binds to
127.0.0.1only — never exposed to the network - For remote access, use a tunnel (e.g. Cloudflare Tunnel, SSH port forwarding)
- Pairing codes are 8-character base32, valid for 60 seconds, single-use
- Tokens are stored as HMAC-SHA256 digests (never plaintext) using a persistent app secret (
remote_secretin your platform's config dir) - Rate limiting: 5 attempts per IP per minute, 30 globally per minute
- 300ms delay on every failed pairing attempt
In settings.json in your platform's config dir (macOS: ~/Library/Application Support/okena/, Linux: ~/.config/okena/):
{
"remote_server_enabled": true
}When running, the server writes remote.json to the same config dir:
{
"port": 19100,
"pid": 12345
}This file is deleted on shutdown.
No auth required.
{ "status": "ok", "version": "0.1.3", "uptime_secs": 120 }No auth required (has its own rate limiting).
Request:
{ "code": "K7M2-9QFP" }Response (200):
{ "token": "base64url-encoded-token", "expires_in": 86400 }Errors:
401— invalid or expired code429— rate limited
Requires Authorization: Bearer <token>.
Returns the workspace state with a monotonic state_version counter. Clients can detect missed updates by comparing versions.
The example below is a subset of the full project shape. One field worth calling
out: terminal_agent_status maps terminal id → { "lifecycle", "custom"?, "labels"? } for panes running an AI agent (see Agent Status).
It is runtime-only — omitted entirely for terminals with no status, and
absent after a daemon restart — and a change to it bumps state_version.
claude_dialogs (terminal id → open dialog count), claude_sessions
(terminals with a connected claude) and claude_pr_links (terminal id → the
session's pull requests) are runtime-only too; see
Claude remote.
{
"state_version": 42,
"projects": [
{
"id": "uuid",
"name": "my-project",
"path": "/home/user/my-project",
"is_visible": true,
"layout": {
"type": "terminal",
"terminal_id": "uuid",
"minimized": false,
"detached": false
},
"terminal_names": {},
"status": "review",
"terminal_agent_status": {
"uuid": { "lifecycle": "working", "custom": "running tests 3/5" }
}
}
],
"focused_project_id": "uuid",
"fullscreen_terminal": null
}Layout nodes are recursive:
| Type | Fields |
|---|---|
terminal |
terminal_id, minimized, detached |
split |
direction (horizontal/vertical), sizes, children |
tabs |
children, active_tab |
Requires Authorization: Bearer <token>.
Tagged enum body — the action field selects the operation.
Write raw text to a terminal (no newline appended).
{ "action": "send_text", "terminal_id": "uuid", "text": "ls -la" }Write text + newline to a terminal.
{ "action": "run_command", "terminal_id": "uuid", "command": "echo hello" }Set or clear a project's user status (a board column id; see
status-board.md). Omit status or send null to
clear. status on the project reflects it in the next state; it is
omitted while unset.
{ "action": "set_project_status", "project_id": "uuid", "status": "review" }Show (on: true) or flatten a project family's status lanes in a desktop
window. A worktree addresses its parent's family. Lanes are desktop
presentation: the daemon only checks the project exists, and after success
attached desktops receive a one-shot status_lanes_requested push and apply
it to window ("main" or an extra window's id), else their active window.
An extra window no attached desktop declared is rejected with
window not found.
{ "action": "set_status_lanes", "project_id": "uuid", "on": true, "window": "main" }Show (on: true) or flatten the status lanes of a desktop window's
overview (no project focused: every visible column grouped by status).
Desktop presentation like set_project_layout: the daemon only validates
the window, and after success attached desktops receive a one-shot
overview_status_lanes_requested push and apply it to window, else their
active window. An extra window no attached desktop declared is rejected with
window not found.
{ "action": "set_overview_status_lanes", "on": true, "window": "main" }Send a named key.
{ "action": "send_special_key", "terminal_id": "uuid", "key": "CtrlC" }Available keys: Enter, Escape, CtrlC, CtrlD, CtrlZ, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown
Split a pane at a layout path.
{
"action": "split_terminal",
"project_id": "uuid",
"path": [0],
"direction": "horizontal"
}{ "action": "close_terminal", "project_id": "uuid", "terminal_id": "uuid" }{ "action": "focus_terminal", "project_id": "uuid", "terminal_id": "uuid" }Get the visible terminal viewport as text.
{ "action": "read_content", "terminal_id": "uuid" }Response:
{ "content": "user@host:~$ ls\nfile1 file2\nuser@host:~$ " }Claude Code / Codex / Grok rate-limit usage measured on the daemon's machine,
and the AI proxy's account pool while new shells route through it (pool on
the Claude entry, see ai-proxy.md). Bearer
auth like the other /v1 routes. Returns ApiUsage
(crates/okena-core/src/usage.rs is the schema, with field docs):
{
"providers": [
{
"provider": "claude", "status": "ok", "plan": "max",
"fetched_at": 1792260185.0, "last_attempt_at": 1792260184.2,
"next_attempt_at": 1792260485.0, "fetching": false,
"error": null, "consecutive_failures": 0, "rate_limited_until": null,
"windows": [
{
"id": "five_hour", "label": "Session", "kind": {"type": "five_hour"},
"used_percent": 20.0, "window_seconds": 18000.0,
"reset_at": 1792264800.0, "active": true,
"history": [{"recorded_at": 1792259000.0, "used_percent": 18.0}]
}
],
"extra_usage": {"is_enabled": false, "monthly_limit_cents": 10000.0,
"used_credits_cents": 0.0, "utilization": 0.0},
"credits": null, "reset_credits": null
},
{ "provider": "codex", "status": "disabled", "windows": [] },
{
"provider": "grok", "status": "ok",
"windows": [
{
"id": "included", "label": "Credits", "kind": {"type": "other"},
"used_percent": 37.0, "window_seconds": 2678400.0,
"reset_at": 1793404800.0, "active": true, "history": []
}
],
"prepaid_balance": 1200.0
}
],
"working_days": [0, 1, 2, 3, 4],
"generated_at": 1792260190.0
}status:disabled(extension off in settings) ·no_credentials·pending·ok·error(windows are then the last good data, seefetched_at).- Window
kind:five_hour,weekly,weekly_scoped(+model),code_review,other. Window ids double as history series names. - Timestamps are epoch seconds. Time elapsed in a window is
(now - (reset_at - window_seconds)) / window_seconds; a window withwindow_seconds: 0(Grok without a billing period) has none. pooland thegrokentry come only from daemons that know them; older daemons omit both.working_days:0 = Monday; the desktop's weekly pace marker only advances on these days.- Polling: every 5 min per enabled provider (
enabled_extensionscontainsclaude-code/codex/grok), backoff 30 s → 1 h on errors,retry-afteron 429. Only while a client holds a/v1/streamWebSocket: with none, a fetch that falls due waits for the next client, which gets it at once.POST /v1/usage/refreshfetches without a client. - The
claudelogin is polled only while nopoolis published. With a pool, new shells use the pool accounts; the login's entry keeps its last data and refresh requests for it are ignored.
One AI proxy pool account's five_hour and seven_day windows with their
history (the pool in GET /v1/usage carries none), as
ApiPoolAccountHistory ({"account_id": "…", "windows": [ApiUsageWindow]}).
The daemon serves its own copy, loaded from the proxy at most once a minute;
an account that does not serve requests has no history. 404 while no pool
is published or the account is not in it, and from daemons older than the
route.
Body {"provider": "claude"}, {"provider": "codex"}, {"provider": "grok"},
or empty / {} for all. Returns the ApiUsage snapshot as soon as the daemon has applied the
request (at most ~2 s): a requested provider with fetching: true is being
refetched, otherwise the request was ignored. The fetch result arrives as
usage_changed. Ignored per provider when its last attempt is under 60 s old,
it is fetching, rate limited (rate_limited_until) or disabled. An error
backoff does not block it: it only spaces the automatic poll. Unknown fields or providers → 400, so a daemon
older than Grok support rejects {"provider": "grok"}.
Real-time PTY output and state change notifications.
Authentication — two modes:
- Query param:
ws://127.0.0.1:19100/v1/stream?token=YOUR_TOKEN - First message: connect without token, then send
{"type":"auth","token":"..."}within 2 seconds
| Type | Fields | Description |
|---|---|---|
subscribe |
terminal_ids: string[] |
Start receiving PTY output |
unsubscribe |
terminal_ids: string[] |
Stop receiving PTY output |
send_text |
terminal_id, text |
Write text to terminal |
send_special_key |
terminal_id, key |
Send named key |
ping |
— | Keepalive |
JSON text frames:
| Type | Fields | Description |
|---|---|---|
auth_ok |
— | Authentication succeeded |
auth_failed |
error |
Authentication failed |
subscribed |
mappings: {terminal_id: stream_id} |
Subscription confirmed with numeric stream IDs |
state_changed |
state_version: u64 |
Workspace state changed — refetch via GET /v1/state |
usage_changed |
usage: ApiUsage |
Full agent usage snapshot (same as GET /v1/usage); sent right after auth_ok and on every change |
dropped |
count: u64 |
Subscriber fell behind, N events were dropped |
pong |
— | Keepalive response |
Binary frames (PTY output):
[u8 proto_version=1] [u8 frame_type=1] [u32 stream_id (big-endian)] [raw PTY bytes...]
The stream_id maps to terminal UUIDs via the subscribed response, avoiding UUID overhead in every frame.
If a subscriber can't keep up, the server drops oldest events and sends a dropped message. The client should refetch state and/or resubscribe.
The server tries ports 19100-19200 in order, falling back to an OS-assigned port if all are taken. The actual port is always reported in remote.json and the status bar.
[Tokio thread] [GPUI main thread]
axum handler cx.spawn() loop
| |
async_channel::Sender ──────────► async_channel::Receiver
| |
tokio::sync::oneshot::Receiver ◄── oneshot::Sender (reply)
All terminal and workspace operations go through a single bridge channel. The GPUI-side processor handles them sequentially, ensuring thread safety without locks on GPUI entities.