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.
{
"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": {}
}
],
"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" }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:~$ " }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 |
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.