This document describes hardware protocol v2 for the ESP32 agent controller and the temporary protocol-v1 compatibility path.
Project-wide firmware progress is maintained in roadmap/IMPLEMENTATION-STATUS.md. The current inventory is 15 PlatformIO environments; the post-TLS release and CrowPanel/Waveshare build evidence is recorded in firmware-build-gate.md. Hosyond is the only board with current silicon evidence. The CrowPanel sections below deliberately distinguish the implemented slice from the target protocol and UI contract.
The current design keeps the ESP32 simple:
- The cloud gateway owns account, device, policy, and durable control-plane state.
- The ESP32 authenticates with a per-device ID and secret.
- The ESP32 polls compact display state instead of holding a long-lived stream.
- The ESP32 sends opaque saved-action IDs, not action payloads or raw T3 commands.
- Phone/web clients and the ESP32 share the same intent model.
- T3 state and provider execution remain on the user's machine behind the outbound connector.
flowchart TD
ESP32["ESP32 controller<br/>keys, display, optional media"] --> Gateway["Cloud gateway<br/>auth, registry, policy, routing"]
Phone["Phone/web client<br/>audio, images, prompts"] --> Gateway
Connector["Outbound connector CLI<br/>on user machine"] --> Gateway
Connector --> Tailscale["Loopback or optional Tailscale"]
Tailscale --> T3["T3 Code on user machine"]
T3 --> Agents["Codex / Claude Code"]
The physical product target in this document is the CrowPanel ESP32 2.13-inch e-paper HMI
(e213-esp32-s3r8). Its visible panel is 250 pixels wide by 122 pixels high in landscape. The
vendored driver exposes a 250-by-128 logical buffer; UI code must keep meaningful pixels inside
rows 0..121 because the final six rows are not part of the visible product canvas.
The Vision Master T190 remains a development target with no silicon proof. Its adapter now uses the shared identity, provisioning, gateway, and browse layers. The checked-in build is status-only; navigation and operate capability compile only behind an explicit external-encoder verification gate, so they are not release or hardware evidence.
The following sections define the target CrowPanel interaction contract. They are deliberately more complete than the implemented first slice. The current source now includes:
- a shared 250-by-122 retro terminal renderer with a hardware-aligned left control rail, header, three-row list/two-line detail body, safe ASCII truncation, and original one-bit glyphs;
- Home, Actions, Threads, Gateways, and Detail screen states;
- a paged, bounded thread browser that renders gateway-provided
statusandselectedmetadata, and changes context only after OK on the visible row; - a full-height Home summary for selected task, system counts, and latest activity, plus explicit task-required and local-confirmation action metadata;
- MENU-to-Actions and EXIT-back behavior, gateway selection, result/error details, the global OK+EXIT stop chord, and the EXIT recovery hold.
The full root hierarchy, separate action/thread detail confirmation, Approvals and Session screens,
media review-before-upload, MENU-hold refresh, general OK-hold confirmations, and output paging are
still target behavior. Current thread OK switches directly from the list after a static
SWITCHING milestone; current reset confirmation uses a second OK tap rather than an OK hold.
The controller should feel like a small field terminal: terse, technical, and calm. “Retro hacker” means functional terminal grammar, not decorative noise.
- Use a one-bit, monospaced or pixel-compatible face for labels, counts, IDs, and state.
- Use uppercase for short system labels (
THREADS,RUN,WAIT), not for long user content. - Prefix selection with
>and label the active resourceACTIVE; use[STATE]for status. - Use single-pixel rules, square corners, slash counts (
2/7), and compact command-like verbs. - Keep the canvas predominantly white. Large inverse-black regions, checkerboards, fake scanlines, and ornamental noise increase ghosting and spend refresh time without adding information.
- Never encode status only in an icon. Pair every glyph with text such as
DONE,WAIT,RISK,OFFLINE, orERROR. - Show user-created titles in their original case where space permits. Truncate with
~rather than allowing a label to collide with the state, metadata, or control rail.
These are original Agent Controller semantics, designed for a 12-by-12 one-bit cell. They must not copy the T3 Code wordmark or branded artwork. A glyph is a navigation aid; the adjacent ASCII label remains authoritative.
| Glyph name | Pixel concept | Meaning |
|---|---|---|
ac_mark |
Square terminal frame containing >_ |
Agent Controller identity, boot, and Home |
thread |
Three offset horizontal traces joined by a rail | T3 thread list or active thread |
status |
Prompt caret followed by two short telemetry lines | Snapshot/status |
action |
Prompt caret entering a small node | Saved prompt or generic remote action |
shell |
Terminal frame containing $ |
Policy-screened shell request |
macro |
Three connected step nodes | Multi-step macro |
approval |
Diamond containing ? |
Decision required; always paired with risk text |
continue |
Broken trace completed by a right arrow | Continue the selected thread |
interrupt |
Split trace with a vertical break | Interrupt the current turn |
stop |
Octagonal outline with a center square | Stop the selected session |
audio |
Three vertical waveform bars | Audio capture, only when hardware reports it |
image |
Framed horizon and point | Still image capture, only when hardware reports it |
gateway |
Two endpoints joined through a center node | Gateway profile or route |
firmware |
Chip outline with a downward arrow | Firmware release/update |
ok |
Open square containing a check | Completed/success |
warning |
Triangle containing ! |
Risk, stale state, or recoverable warning |
error |
Open square containing x |
Failure, blocked, revoked, or unavailable |
The ac_mark appears on boot and may occupy the header's left glyph cell on Home. Other screens use
their capability glyph in the same cell, so the user's eye learns a stable location. Selected rows
use the > text cursor rather than a filled highlight. This keeps selection legible after a partial
refresh and avoids depending on inverse text support.
All product screens use the same visible coordinate contract:
| Region | Coordinates | Contents |
|---|---|---|
| Physical control rail | x=2..43, y=2..119 |
Centered top MENU, center rotary/OK, bottom BACK |
| Rail divider | x=43, y=2..119 |
One-pixel boundary matching the left-side bezel controls |
| Header glyph | x=49..60, y=5..16 |
One 12-by-12 Agent Controller glyph |
| Header title | starts x=67, y=5 |
Screen/resource title, bounded before state |
| Header state | boxed against x=247, y=3..19 |
Text such as LIVE, RUN, or 2/7 |
| Header rule | x=47..247, y=22 |
One-pixel separator |
| Body | x=47..247, y=23..101 |
Three 25-pixel list rows, or two compact detail lines |
| List baselines | y=27, 52, 77 |
Cursor, 12-by-12 glyph, label, and right metadata |
| Detail prompts | y=34, 64 |
$ system fact followed by > next/relevant fact |
| Context rule | x=47..247, y=102 |
One-pixel separator |
| Context footer | x=50..247, y=106..117 |
List movement hint; no relocated key labels |
| Buffer padding | y=122..127 |
Always blank; not visible on the panel |
The display buffer's rows 122..127 remain blank. A list row uses x=48 for the cursor,
x=59..70 for the glyph, x=77 for its label, and a right-aligned metadata field ending before
x=247. The renderer fits three rows per page. Detail screens use the content body right of the rail.
0 +-------+-----------------------+ 250 px
| MENU |[G] TITLE [STATE]| header: y 0..21
22 | |-----------------------|
23 | ^ |> primary content |
| OK | secondary content | body: y 23..101
| v | detail |
102 | |-----------------------|
103 | BACK |ROTATE:MOVE | context: y 103..121
122 +-------+-----------------------+ visible panel ends
The control rail is spatial, not a generic footer: MENU is adjacent to the upper bezel key, the
rotary up/OK/down cluster is centered, and BACK/CANCEL/LATER is adjacent to the lower EXIT key.
Emergency and recovery holds remain available without consuming content space. The rail shows only
the universal OK label; contextual operation names (OPEN, RUN, SWITCH, INSTALL) stay in
the title, selected row, or detail copy where they cannot crowd the physical control.
The five application inputs are active-low discrete switches. The wheel is not a rotary encoder; up, down, and press are independent buttons. GPIO and electrical details are in the firmware README.
| Input | Browse/list | Detail/output | Decision or capture |
|---|---|---|---|
| Dial up | Move cursor up; wrap only in short, stable menus | Scroll to previous detail page | Move to previous explicit choice |
| Dial down | Move cursor down; wrap only in short, stable menus | Scroll to next detail page | Move to next explicit choice |
| OK tap | Open the selected row or run a routine action from its detail | Refresh when offered; otherwise open deeper detail | Commit the selected non-destructive choice |
| OK hold 1.5 s | No alternate behavior | Confirm a screen explicitly marked HOLD OK |
Approve or start a risky/destructive operation |
| MENU tap | Open the root menu | Open the root menu without cancelling remote work | Ignored while recording/erasing; otherwise leave the decision pending and open root |
| MENU hold 1 s | Refresh the current list | Refresh current status/output | No action while a hold-to-confirm gesture is armed |
| EXIT tap | Return one level; from root return Home | Dismiss the local view; remote work continues | Cancel before dispatch, or leave an approval pending |
| EXIT hold 10 s | Enter local recovery reset | Enter local recovery reset | Disabled during erase, OTA partition write, and reboot |
| OK + EXIT hold 1.5 s | Reserved emergency stop | Reserved emergency stop | Takes priority over OK-only and EXIT-only holds |
The emergency chord is global once the application input loop is running, except while flash erase,
OTA partition write, or reboot makes input unsafe. Releasing either key before 1.5 seconds cancels
the chord and consumes both key edges. On threshold, submit system_stop once; do not also open the
selected item or begin the 10-second EXIT reset. An already-stopped response is success.
The physical BOOT and RESET buttons beside the display connector are maintenance controls, not
navigation inputs. RESET immediately restarts the microcontroller. BOOT is reserved for flashing
and recovery and must not be assigned a product action.
| Context | EXIT means | MENU means | Can the remote operation be cancelled? |
|---|---|---|---|
| List or detail before dispatch | Back | Root menu | Nothing has started |
| Confirmation | Cancel and return | Leave pending/open root | Nothing has started |
| Approval decision | Keep pending | Leave pending/open root | Approve or Deny is sent only after an explicit choice |
| Dispatched/running command | Dismiss local view | Root menu | No; use Interrupt or Stop as a separate action |
| Macro waiting for approval | Dismiss; approval remains pending | Root menu | Only the pending step can be denied; completed steps do not roll back |
| Audio before upload | Discard recording | Ignored | Yes, bytes remain local |
| Media upload | Leave progress view | Root menu | Not guaranteed once upload begins |
| Gateway probe | Return to gateway list after result | Ignored during probe | The previous URL remains active until the probe succeeds |
| OTA write or local erase | Ignored | Ignored | No; removing power is unsafe |
EXIT must never silently mean Deny. A denied approval, discarded recording, stopped session, and cancelled confirmation are different outcomes and use different words.
- Debounce key edges in firmware; never implement a press by waiting for an e-paper refresh.
- Coalesce quick dial presses for roughly 200–300 ms, update the cursor model immediately, then draw the final selection once. Audible or animated feedback is not available on the reference board.
- Determine tap versus hold from physical pin state. Consume the release after any successful hold.
- Do not refresh while recording audio or while measuring a multi-key chord; the refresh can take more than a second and would make the controls feel stuck.
- A hold-to-confirm screen is itself the warning. Once the threshold is met, replace it with one
static
SENDING,STOPPING,ERASING, orUPDATINGmilestone. - Do not redraw unchanged pixels on each five-second poll. Compare the full screen model, and prefer partial refresh only if the panel driver and ghosting tests prove it reliable.
Home is a glance surface, not a menu. MENU always provides a predictable route to the root. The target hierarchy is:
HOME
└─ ROOT
├─ THREADS
│ └─ thread list
│ └─ opened thread
│ ├─ latest agent response
│ │ └─ up to two validated follow-up actions
│ ├─ status
│ ├─ assigned prompt / shell / media action
│ ├─ assigned macro
│ └─ stop
├─ GATEWAY
└─ FIRMWARE
The root never mixes reusable Action Library entries with device administration. THREADS opens a
real task list; OK selects the highlighted task when necessary and then opens its owner-assigned
actions in three-row pages. Latest response is always the first opened-thread row, including when
no Action Library entries are assigned. MENU always returns to Root. EXIT walks up exactly one level—from
thread actions to the thread list, from the thread list to Root, and from Root to Home. A successful
action returns to an outcome screen, not silently to Home.
Global navigation does not change remote state. Entering another screen while a command is running only backgrounds its local progress view. The Home priority order is: pending approval, failed session, running session, latest outcome, idle thread, then device/network warning.
The target maps every T3-facing capability to a deliberate local interaction. “Cancel” below means no request is sent unless the operation is already dispatched.
| Capability | List/detail and trigger | Confirm, deny, or cancel | Output interaction |
|---|---|---|---|
| Status | Home OK or STATUS; fetch immediately |
EXIT leaves the view | Dial pages snapshot; MENU hold/OK refreshes |
| Saved agent prompt | THREADS -> open thread -> action -> OK:RUN |
EXIT before run cancels | Accepted, dispatched, running, done, or error |
| Image prompt | Feature-gated action; OK captures | Send/Retake/Discard; EXIT discards before upload | Size, upload, dispatch, and result milestones |
| Audio prompt | Feature-gated action; hold OK records | Send/Record again/Discard; EXIT discards before upload | Duration, upload, dispatch, and result milestones |
| Shell request | Shell action detail | Hold OK on risk screen; EXIT cancels | Approval-required, blocked, dispatch, or result |
| Continue | SESSION -> CONTINUE -> OK |
EXIT cancels before dispatch | Dispatch and selected-thread result |
| Interrupt | Only when a turn is running | Hold OK; EXIT cancels | Requested, interrupted, stale, or failed |
| Stop | Session detail or global chord | Hold OK; chord is its own confirmation | Stopping, stopped, already stopped, or failed |
| T3 approval | Approval list/detail/decision | Explicit Approve/Deny/Keep pending; high-risk Approve uses hold | Approved, denied, stale, dispatch, or failure |
| Gateway-policy approval | Same inbox, labeled COMMAND |
Same decision contract | Held command dispatches or is rejected |
| Thread open/switch | Root Threads, then visible task row | OK switches when needed and opens its actions; EXIT keeps current | Opened, switched, stale, offline, or error |
| Macro | Action detail includes step count | EXIT cancels only before start; a pending step may be denied | Step n/m, waiting approval, aggregate result |
| Launch project | Dashboard-defined launch preset only | Hold OK when a device-safe preset contract exists | Creating thread, starting turn, new active thread |
| Direct terminal bytes | Not listed on CrowPanel | Dashboard only; five keys cannot edit arbitrary bytes | Device may show resulting thread activity only |
Launch presets and device-side command-following require compact gateway contracts beyond the currently documented endpoints. Until those exist, firmware must show the corresponding control as disabled with a reason rather than fabricate behavior.
The controller edits only bounded choices: active thread, gateway profile, approval decision,
Send/Retry/Discard, and owner-defined enumerated action parameters. Prompt text, shell commands,
terminal bytes, URLs, credentials, model names, macro steps, thread names, and project definitions
remain dashboard edits. Their device detail screen says EDIT IN DASHBOARD.
+-------+-----------------------+
| MENU |[A] AGENT CTRL [LIVE] |
| |-----------------------|
| ^ |[T] active task ACTIVE|
| OK |[G] 1 env / 1 dev SYS|
| v |[S] completed READY|
| |-----------------------|
| BACK |OK:STATUS MENU:ACTIONS |
+-------+-----------------------+
The active thread is always visible on Home. If no thread is selected, the first row says
SELECT A THREAD; task-bound controls become disabled with THREAD. Home's other rows carry the
system/device count and latest command outcome instead of leaving the lower body blank. OK opens
Status and MENU opens Actions. If unclaimed, Home is replaced by setup/claim. If revoked, recovery
instructions take over the whole body.
A list presents at most three rows. > is the cursor, ACTIVE marks the active thread, x marks a
disabled control, and the header shows page/count. THREAD identifies missing task context;
CONFIRM identifies an action that opens a local review before dispatch. Opening a disabled
control shows its gateway reason and never submits it.
+-------+-----------------------+
| MENU |[>] ACTIONS 2/7 |
| |-----------------------|
| ^ |> Continue task |
| OK | Run tests RISK|
| v | Release check x|
| |-----------------------|
| HOME |ROTATE:MOVE |
+-------+-----------------------+
Action detail shows the owner label, type, target thread, and risk/approval behavior. Prompts, shell text, secrets, macro steps, and raw T3 payloads remain gateway-side. Routine actions require one OK from detail. Shell, stop, interrupt, reset, firmware apply, and high-risk approvals use a dedicated confirmation screen and the 1.5-second OK hold.
The approval flow is list -> detail -> decision -> result. Detail shows the action, target,
origin, risk, and a short consequence. Decision is an explicit three-row choice: APPROVE, DENY,
or KEEP PENDING. High-risk Approve requires hold OK. Deny uses an ordinary OK after selection;
EXIT always keeps the request pending. A gateway 404 after a decision is rendered as STALE / NO LONGER PENDING, not a generic HTTP failure.
- Continue shows the active thread, then dispatches on OK.
- Interrupt is offered only for a running turn and requires hold OK. It interrupts that turn; it does not stop the entire session.
- Stop requires hold OK from the menu or the global OK+EXIT chord. It is idempotent.
- Direct terminal character entry is not offered on this five-key device. It remains a dashboard
capability, even when an advanced device profile grants
terminal_input.
Audio is push-to-talk: hold OK to record, release to finish, then choose SEND, RECORD AGAIN, or
DISCARD before upload. Image capture shows CAPTURE, then metadata-only review with SEND,
RETAKE, or DISCARD; the e-paper panel is not a useful camera preview. These review steps are
target behavior. Builds without the matching hardware feature keep the action visible only when the
gateway deliberately sends it disabled with a reason.
All actions map to the same device vocabulary:
ACCEPTED -> WAIT | DISPATCHED
DISPATCHED -> RUN -> DONE | ERROR | INTERRUPTED
WAIT -> APPROVED | DENIED | STALE
An output screen leads with a plain-language result, then the thread/action, status, age, and short
failure cause. Dial up/down pages through additional compact detail. OK refreshes when a status URL
is available; EXIT dismisses the view without cancelling remote work. Raw JSON, bearer secrets, and
complete commands do not belong on the panel. A macro may show STEP 2/4; denying a later step does
not imply rollback of steps already dispatched.
- Gateway: list profiles, open a candidate, probe, then commit only on success. Failure names
KEPT PREVIOUS URL. - Firmware: show current/target version and static milestones:
AVAILABLE,DOWNLOADING,VERIFYING,WRITING,REBOOTING,VERIFIED,FAILED, orROLLED BACK. - Network: show SSID, IP, RSSI, and gateway route without exposing credentials.
- Identity: show label and shortened device ID; unclaimed units show the cached claim code and expiration instructions.
- Reset: name the data erased, require hold OK, ignore keys while erasing, then enter setup.
- Revoked: show
ACCESS REMOVEDandHOLD EXIT 10S TO RESET; do not loop onHTTP 401.
Thread browsing is a first-class product flow, not a “cycle to next” shortcut. It is constrained to the environment bound by the owner, and to the project selected inside it when there is one. Environment and project browsing follow the same shape one level up: list, move a cursor, confirm on a row, POST the exact id, update the active marker only after success. See Environment, project, and thread API for the wire format.
- Open
MENU -> THREADS. The device rendersLOADING THREADSand callsGET /v1/device/threads. - Seed the cursor from the returned
threadId/selectedvalue. If the active thread is absent, select the first row but show noACTIVEmetadata. - Render three titles per page.
>is the cursor andACTIVEis the current target. Dial moves the cursor without changingruntimeConfig.threadId. - OK opens a thread detail/confirmation screen. It shows the title,
CURRENTorSWITCH TARGET, and a shortened ID when duplicate/truncated titles would be ambiguous. - OK on the current thread returns without a request. OK on another thread sends
POST /v1/device/config/threadwith only its exact ID. EXIT cancels with no change. - On success, update the in-memory active thread, show
SWITCHED, and return to Home after the user dismisses the result. All subsequent actions resolve against the new target. - A switch never migrates, interrupts, or stops commands already dispatched to the former thread.
+-------+-----------------------+
| MENU |[T] THREADS 1/6 |
| |-----------------------|
| ^ |> release-check IDLE|
| OK | agent-ctrl ACTIVE|
| v | auth hardening RUN|
| |-----------------------|
| BACK |ROTATE:MOVE |
+-------+-----------------------+
Thread-state outcomes:
| Condition | Screen behavior |
|---|---|
No bound environment (409) |
NO ENVIRONMENT / ASSIGN IN DASHBOARD; no list |
| Empty list | NO THREADS / START ONE IN DASHBOARD |
| Offline or timeout | Keep current thread; show THREAD LIST UNAVAILABLE and retry affordance |
Stale/foreign selection (404) |
Keep current thread, show THREAD CHANGED, then refresh list |
| Successful switch | Show title plus SWITCHED; persist only the server-confirmed ID |
| Duplicate title | Append a short stable ID on detail; never guess by title |
| Long title | Truncate list label with ~; preserve full bounded title across detail pages |
The endpoint returns a compact thread status plus selected. Status is derived from the latest
turn/session with active work taking precedence over a stale stopped session; on the selected row
running may be refined into an agent verb (see "Agent verbs on status" below). Firmware may display
that bounded value, but must still treat the top-level threadId as the compatibility source of
truth when talking to an older gateway. Renaming, creating, deleting, or editing a thread remains a
dashboard operation.
- A full update takes roughly a second or more and consumes panel life; network polls must not force a draw when the visible screen model is unchanged.
- The glass retains the last image without power. Never use a spinner, blinking cursor, countdown, marquee, or continuously changing elapsed time.
- Coalesce list movement and use milestone screens for network, media, macro, and OTA progress.
- Keep text inside the 250-by-122 visible contract even though the driver buffer is 250 by 128.
- Prefer sparse outlines and text over large black fills. Periodically perform a clean full refresh according to driver testing rather than depending on decorative inversions.
- A stale e-paper image after power loss is expected. Boot must replace it with a clear
BOOTor restored status screen as soon as the panel is ready. - If rendering fails or BUSY exceeds a bounded timeout, continue safe network/device operation and report diagnostics; a panel fault must not trap the input loop forever.
Manufacturing or local development creates an unclaimed device:
POST /v1/factory/devices
authorization: Bearer FACTORY_TOKEN
content-type: application/json{
"label": "Agent Controller",
"profile": "agent-controller"
}The gateway returns:
{
"device": {
"id": "dev_...",
"claimed": false
},
"secret": "FLASH_THIS_TO_DEVICE",
"claimCode": "ABCDE-23456"
}Write device.id and secret into the unit's NVS (agentctl namespace, keys dev_id and
dev_secret) — POST /v1/factory/batches returns a ready-made nvsSeed CSV per device for
nvs_partition_gen.py, so a line flashes one signed application image per batch and varies only the
data partition. Print or display claimCode for the customer. The generated controller_config.h
remains for bench builds, where DeviceStore seeds NVS from it on a blank unit.
The customer signs in to the platform and claims the device:
POST /v1/devices/claim
authorization: Bearer PLATFORM_TOKEN
content-type: application/json{
"claimCode": "ABCDE-23456",
"label": "Desk controller"
}Before claim, the device can only call heartbeat. After claim, it can read display state, upload media, and submit intents.
An authenticated unclaimed device asks for its setup code here. The endpoint does not rotate on
every call — a device requests one on its first 403, seconds after boot, and rotating there would
invalidate the code printed on the box before the owner ever read it.
POST /v1/device/setup-code
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{ "rotate": false }Two outcomes:
| Condition | Status | setup.rotated |
setup.claimCode |
|---|---|---|---|
Existing code still unexpired, rotate false |
200 |
false |
null — display the code cached in NVS |
No code, expired, or rotate: true |
201 |
true |
the new plaintext code, returned exactly once |
Because codes are stored as hashes, the plaintext cannot be handed back a second time. The device
caches the code it was issued and only needs this endpoint when it holds none or the gateway reports
the cached one as expired. {"rotate": true} is the "I lost the card, give me a fresh one" path, and
belongs on an on-device menu item rather than a timer.
claimCodeExpiresAt accompanies both outcomes. Codes expire 30 days from issue; an expired code is
refused at claim time rather than silently treated as unknown.
Example response:
{
"device": {
"id": "dev_...",
"claimed": false
},
"setup": {
"claimed": false,
"rotated": true,
"claimCode": "ABCDE-23456",
"claimCodeExpiresAt": "2026-09-06T09:48:18.923Z",
"instructions": "Sign in to the Agent Controller dashboard and claim this device with the displayed code."
},
"claimCode": "ABCDE-23456",
"claimCodeExpiresAt": "2026-09-06T09:48:18.923Z"
}Only a rotation retires an older printed or displayed code. Claimed devices receive
setup.claimed: true and no claim code.
The platform exposes supported policy profiles at:
GET /v1/device-profilesManufacturing, claim, and owner-managed updates store one profile per device. The gateway enforces that profile on every intent:
agent-controller: normal hardware profile for prompts, media, status, approvals, agent questions, session control, and policy-screened shell input.read-only: status only.power-controller: high-trust profile for web clients or advanced devices; dangerous shell input still requires approval.
When a device is resold, replaced, or moved to another account, the current owner can reset it for transfer:
POST /v1/devices/dev_.../transfer-reset
authorization: Bearer PLATFORM_TOKEN
content-type: application/jsonThe gateway unclaims the device, rotates the hardware secret, generates a new claimCode, clears runtime config/status, and removes it from the previous owner's inventory. The returned secret must be installed on the physical controller before the next customer claims it. The previous secret fails immediately.
Every device request sends:
x-device-id: dev_...
x-device-secret: ...
The device secret is a bearer secret. Production firmware should store it in ESP32 NVS or secure storage, support rotation, and avoid logging it over serial.
The initial firmware uses this loop:
- Read identity, gateway URL, and Wi-Fi credentials from NVS (namespace
agentctl). With no Wi-Fi stored, raise theagent-ctl-XXXXSoftAP setup portal athttp://192.168.4.1and wait; with credentials stored, join with a 20 s timeout and three retries before the portal re-raises. Nothing in this path blocks indefinitely — see device-setup-flow.md. - Send
POST /v1/device/heartbeat. - If claimed-only routes return
403, render the cached claim code, callingPOST /v1/device/setup-codeonly when none is cached or the gateway reports it expired. - Fetch legacy
GET /v1/device/config, then fetchGET /v1/device/controls. A successful v2 response becomes authoritative; a404or501leaves the legacy menu active. - Cache the last valid v2 control layout in RAM, poll it every 30 seconds, and acknowledge its revision after applying it. A transient error does not erase the cached layout.
- Poll
GET /v1/device/displayevery few seconds for status text. Under v2 its legacymenufield does not replace the controls cache. - Poll
GET /v1/device/firmwareperiodically for signed update metadata. Anautomaticresponse follows the unattended policy path; aconfirmresponse is cached, announced on the panel, and installed only after the local user presses OK. - Render status text and the selected control's label and enabled state to e-ink.
- Use the dial up/down keys to select a control and the dial confirm key to run it.
- On
401, stop treating the gateway as reachable and show therevokedscreen: the credential was revoked or transfer-reset, and the recovery is on-device.
Holding EXIT for 10 seconds wipes Wi-Fi credentials, the config cache, and the cached claim code, keeps the device identity, and re-enters provisioning. This is the recovery path for a revoked device, a moved household, or a resale.
Holding OK+EXIT together for 1.5 seconds invokes the reserved system_stop action. The chord
uses direct pin reads and consumes both key edges, so it neither runs the selected control nor
starts the EXIT-only 10-second reset. The gateway still enforces the device's session-control
policy. An already-stopped session should be returned as a successful, idempotent result.
Polling is intentional for the first hardware version. It is easier to recover after sleep, WiFi roaming, captive networks, and Tailscale/VPS deploys than a persistent event stream.
The gateway rate limits heartbeat, read, and write paths separately. Firmware should respect 429 and retry-after responses by backing off before retrying.
Heartbeat requests should include the latest device diagnostics when available:
{
"protocolVersion": 2,
"firmwareVersion": "0.1.7",
"hardwareModel": "e213-esp32-s3r8",
"features": ["display", "buttons", "ota", "microphone", "camera"],
"limits": {
"menuItems": 8,
"labelCharacters": 18,
"mediaUploadBytes": 2097152
},
"ipAddress": "192.168.4.20",
"wifiRssi": -61,
"freeHeap": 184320,
"uptimeMs": 120000,
"batteryMv": 4100,
"batteryPercent": 87
}The firmware sends protocol version, the build's actual feature set, its limits, firmware version,
hardware model, IP address, Wi-Fi RSSI, free heap, and uptime. microphone and camera only appear
in capture-enabled builds. Battery fields are optional until the board power path is finalized.
Gateway device responses include computed presence metadata. A device is considered online when the latest activity timestamp, either lastSeenAt or status.lastHeartbeatAt, is within 90 seconds. Firmware does not need to calculate this; phone and web clients should prefer the server-provided presence.state.
The gateway owns action contents, targets, authorization, and policy evaluation. The controller only receives stable IDs and short display metadata:
GET /v1/device/controls
x-device-id: dev_...
x-device-secret: ...{
"revision": 12,
"controls": [
{
"id": "system_status",
"actionId": "system_status",
"label": "Status",
"kind": "status",
"enabled": true
},
{
"id": "control_run_tests",
"actionId": "action_run_tests",
"label": "Run tests",
"kind": "remote_action",
"enabled": true
},
{
"id": "control_photo_review",
"actionId": "action_photo_review",
"label": "Review photo",
"kind": "capture_image",
"mediaKind": "image",
"enabled": false,
"reason": "This controller has no camera"
},
{
"id": "system_stop",
"actionId": "system_stop",
"label": "Stop run",
"kind": "stop",
"enabled": true
}
]
}Supported kind values are:
| Kind | Firmware behavior |
|---|---|
status |
Execute system_status; the gateway fetches T3 and returns the selected task/session screen |
remote_action |
Execute actionId through the generic action endpoint |
capture_audio |
Record and upload audio, then execute actionId with mediaUploadId |
capture_image |
Capture and upload a JPEG, then execute actionId with mediaUploadId |
stop |
Execute the assigned action ID, defaulting to system_stop |
reset |
Open the local factory-reset confirmation; no remote action |
A remote_action may alternatively carry mediaKind: "audio" or "image"; the firmware treats
that as the corresponding capture kind. Unknown kinds are retained for display but forced disabled.
Controls missing id, label, or kind are ignored. The firmware caps the applied layout at the
advertised limits.menuItems; the gateway should resolve compatibility and fit before sending it.
An enabled: false control renders with an x marker and displays reason when selected. It is
never submitted.
After applying a valid response, the controller acknowledges it:
POST /v1/device/controls/ack
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{
"revision": 12,
"protocolVersion": 2,
"appliedCount": 4
}Only revision is required by the gateway; the other fields provide useful diagnostics. A failed
acknowledgement is retried after the next successful controls poll.
POST /v1/device/actions/action_run_tests/run
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{}The path segment is percent encoded by firmware. For a media action, the body is:
{ "mediaUploadId": "media_..." }The gateway must authenticate the device, verify the action is assigned to it (system actions are
resolved by the gateway), resolve target environment/thread, evaluate policy, dispatch, and audit
the action ID plus resolved intent. The firmware recognizes a command status in
command.status, run.status, or top-level status; expected values include completed,
dispatched, and approval_required. Any other 2xx response is displayed as accepted. 403
renders as policy-blocked and 404 as unavailable.
Action definitions and secrets must not be returned by the controls endpoint. In particular, shell text, prompt text, macro steps, and media prompt templates stay at the gateway.
Capture remains a two-request flow:
- Upload bytes to
POST /v1/device/mediaand readmedia.idfrom the response. - Execute the selected action ID with
{ "mediaUploadId": "media_..." }.
The selected action, rather than a hardcoded audio_prompt or camera_prompt, decides what the
gateway sends to T3. Protocol-v1 menu entries keep the old intent submission behavior.
Old gateways expose the string menu through /v1/device/config and /v1/device/display. Until a
valid v2 controls response arrives, this firmware continues to interpret those legacy strings and
submit /v1/device/intents. Once v2 activates, later display/config polls cannot overwrite it.
Gateway 404 or 501 from /v1/device/controls leaves v1 active. A transient controls error after
activation retains the in-memory v2 layout.
GET /v1/device/display
x-device-id: dev_...
x-device-secret: ...Example response:
{
"display": {
"title": "Desk controller",
"state": "ready",
"line1": "1 env / 2 devices",
"line2": "dispatched: agent_prompt",
"counts": {
"environments": 1,
"devices": 2,
"onlineDevices": 1,
"offlineDevices": 1,
"media": 0,
"macros": 1,
"commands": 12,
"audit": 18
},
"latestAction": "command.dispatched",
"menu": ["status", "prompt", "shell", "macro", "media", "stop"],
"device": {
"id": "dev_...",
"profile": "agent-controller",
"lastSeenAt": "2026-06-14T21:37:43.155Z",
"presence": {
"state": "online",
"online": true,
"staleAfterMs": 90000
}
}
}
}The ESP32 should treat unknown fields as optional and unknown menu entries as no-ops or status requests.
The platform controls device defaults after the customer claims the hardware:
GET /v1/device/config
x-device-id: dev_...
x-device-secret: ...Example response:
{
"deviceId": "dev_...",
"config": {
"environmentId": "env_...",
"threadId": "thread_...",
"gatewayAccessMode": "online",
"gatewayUrl": "https://controller.example.com",
"defaultPrompt": "Continue the current task, inspect progress, and run relevant tests.",
"shellCommand": "npm test",
"menu": ["status", "prompt", "shell", "macro", "media", "stop"]
}
}The firmware scaffold fetches this at boot and periodically. The compiled ENVIRONMENT_ID, THREAD_ID, DEFAULT_AGENT_PROMPT, and DEFAULT_SHELL_COMMAND values are only fallbacks for development or offline bring-up.
gatewayAccessMode describes how the controller reaches Agent Controller: local, tailscale, or
online. gatewayUrl is the corresponding device-reachable URL. A controller does not normally
join a Tailnet itself; Tailscale mode assumes a routed LAN/subnet path to the gateway. Online mode
requires HTTPS. Before persisting a changed URL, firmware authenticates against
GET /v1/device/config on the candidate endpoint. A failed probe leaves the last known-good URL in
NVS, preventing a bad remote setting from stranding the unit.
A controller browses three levels: the environment (which paired T3 host), the project (the folder inside it), and the thread. Each level is scoped by the one above it, and every id is checked server-side — through the claiming owner's own scope for environments, and against the bound environment's live snapshot for projects and threads. A device still cannot change its profile or widen its menu.
device.config carries environmentId, projectId, and threadId. projectId is
null by default and means "the whole environment": firmware that predates project
selection keeps seeing every thread the bound environment holds.
GET /v1/device/environments
x-device-id: dev_...
x-device-secret: ...{
"environmentId": "env_bound",
"environmentsTruncated": false,
"environments": [
{
"id": "env_bound",
"label": "Workshop mac",
"status": "reachable",
"tokenExpired": false,
"selected": true,
"health": {
"transport": "connector",
"freshness": "live",
"connector": "online",
"t3": "ready",
"provider": "ready",
"observedAt": "2026-08-27T18:44:12.000Z",
"action": "READY"
}
}
]
}Only environments owned by the account that claimed the device are listed. The response is capped
at eight rows; environmentsTruncated tells the controller to send the owner to the console for the
rest. Labels are capped at 64 characters. baseUrl, scopes, raw health errors, connector versions,
provider/model names, pairing state, and anything token-bearing stay in the console realm.
tokenExpired is retained because it is an immediately actionable dead end.
health is a compact observation, not a second source of environment truth:
| Field | Values |
|---|---|
transport |
direct, connector |
freshness |
live, stale, unknown |
connector |
not_applicable, enrolled, waiting, online, reconnecting, sleeping, offline, revoked, incompatible, unknown |
t3 |
ready, starting, stopped, auth_failed, incompatible, error, unknown |
provider |
ready, auth_required, model_unavailable, error, unknown |
observedAt |
Latest applicable ISO-8601 health/heartbeat/catalogue observation, or null |
action |
A fixed, at-most-24-character display instruction |
The action is deterministic. Token repair wins first, followed by connector state, T3 state,
provider state, and stale freshness. Thus an offline or sleeping connector is never presented as a
provider-only failure based on an old catalogue. Shared firmware uses the provided action and
applies the same priority as a compatibility fallback. READY requires both explicit T3 readiness
and an explicitly authenticated provider with at least one model; a model list alone is not proof
of authentication.
Unlike every other environment-scoped route this one answers 200 with no environment bound — a
controller with none is exactly the one that needs the list.
POST /v1/device/config/environment
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{ "environmentId": "env_spare" }environmentId is the only field read. It is resolved through the owner's scope, so an
id belonging to another account — or to nothing — is a 404 and nothing changes. A
blank or missing id is a 400. Changing to a different environment clears
projectId and threadId, because those ids only meant something inside the
environment being left; re-binding the environment already in place keeps both. An
expired access token does not block the binding: the listing already reports it and the
routes that actually reach T3 return 409 token_expired on their own. The audit entry
is recorded with actorType: "device".
GET /v1/device/projects
x-device-id: dev_...
x-device-secret: ...{
"environmentId": "env_bound",
"projectId": "proj_beta",
"projects": [
{ "id": "proj_alpha", "title": "Alpha folder", "threadCount": 2, "selected": false },
{ "id": "proj_beta", "title": "Beta folder", "threadCount": 1, "selected": true }
]
}Projects come from the same GET /api/orchestration/snapshot as threads, so listing them
costs no extra call. threadCount is what makes the list usable at five keys: it says
which folder has anything in it before the owner pages into an empty one.
POST /v1/device/config/project
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{ "projectId": "proj_alpha" }projectId is the only field read and is validated against the live snapshot, so an
unknown or foreign project is a 404. Selecting a project whose folder does not contain
the currently selected thread clears threadId — a thread the list no longer offers must
not keep driving every dispatch. A thread already inside the chosen folder is untouched.
Both project endpoints return 409 when the owner bound no environment, and 502 with
details.code: "t3_unreachable" when the bound host cannot be reached.
A device may change which thread it drives, but only within the environment its owner
bound in device.config.environmentId — and, once a project is selected, only within
that folder. The owner keeps the boundary that matters; the hardware gets the autonomy
that is useful at a five-key bezel.
GET /v1/device/threads
x-device-id: dev_...
x-device-secret: ...{
"clientRequestId": "dev:991fd3c2-9248c000-97ce21ae-fbad0091",
"environmentId": "env_...",
"projectId": null,
"threadId": "thread_current",
"threads": [
{ "id": "thread_a", "title": "Alpha", "status": "stopped", "selected": true },
{ "id": "thread_b", "title": "Beta", "status": "running", "selected": false }
]
}The display payload's compressSnapshot() reduces threads to a count, so this is the only
device-facing source of real thread identity. status is a compact latest-turn/session value;
active running work takes precedence over a stale stopped session. selected duplicates the
top-level threadId on each row for simple renderers. Older firmware may ignore both optional
fields.
The selected row's running may be refined into one of four agent verbs describing what the
agent is actually doing. The other six words (starting, streaming, completed, error,
stopped, idle) are never replaced, and unselected rows always keep running.
| Verb | Emitted when, in the live turn's work log |
|---|---|
searching |
the newest tool row is a read or a search (web_search itemType, data.kind read/search, or a Grep/Find/Read file title) |
solving |
the newest row is a turn.plan.updated, or a command execution (command_execution itemType / data.kind: "execute" / Terminal) |
weaving |
the newest row is a file edit and the turn has touched two or more distinct paths |
shaping |
the newest row is a file change with data.kind of write, move, or delete |
The evidence is thread.activities[] from T3's hydrated
GET /api/orchestration/threads/:threadId?turnLimit=1; the bodiless
GET /api/orchestration/snapshot cannot answer this. Every step is best-effort: a T3 without that
route, a slow host, a settled turn, an unattributable activity, or a tool the gateway cannot name
all leave the row at plain running. A verb is never emitted on a guess — see src/agentVerb.mjs.
POST /v1/device/config/thread
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{ "threadId": "thread_b" }threadId is the only field read; anything else in the body is ignored — including
environmentId, which has its own endpoint above. The id is validated against the live
snapshot of the bound environment, and against device.config.projectId when one is set,
so an unknown, foreign, or out-of-folder thread is a 404 and nothing changes. A device with no bound environment gets 409 from both endpoints.
If the environment is bound but its T3 orchestration endpoint cannot be reached, the gateway
returns 502 with details.code: "t3_unreachable". CrowPanel renders Start T3 Code and changes
the empty-list OK action to Retry; it never erases the last configured thread merely because the
live list is temporarily offline.
The resulting audit entry is recorded with actorType: "device", not "user".
The dedicated CrowPanel Threads screen now fetches and browses this list without mutating context;
OK on a visible row calls the POST endpoint and updates the active marker only after success. On a
failed or empty fetch, OK retries the list request instead of becoming a no-op. A
protocol-v1 thread control retains its direct next-thread cycling fallback.
Listing and selecting are not enough. A project with no threads leaves a controller with nothing to point at, and the only way out used to be the web console — which is the one place the owner is not standing when they pick the device up.
POST /v1/device/threads
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{}{
"environmentId": "env_bound",
"projectId": "proj_empty",
"threadId": "thread_9f0c...",
"thread": {
"id": "thread_9f0c...",
"title": "24 Aug 19:32 · Hosyond Touch screen",
"status": "idle",
"selected": true
},
"config": {
"environmentId": "env_bound",
"projectId": "proj_empty",
"threadId": "thread_9f0c..."
},
"command": { "id": "cmd_...", "status": "completed" }
}What the firmware sends. An empty body is a complete request. The only field read is an
optional title; the device names neither the project, nor the environment, nor the owner —
the thread is always created in device.config.projectId inside device.config.environmentId,
and every id in the response is one the gateway chose.
What the firmware gets back. 201, with thread shaped exactly like a row from
GET /v1/device/threads — splice it into the list already on screen rather than re-fetching.
config is the device's updated config: the create also selects. That is deliberate. The
device asked for a thread because it had none, and the alternative — a follow-up
POST /v1/device/config/thread — validates against the live snapshot, while T3 answers a dispatch
as soon as the event is appended and its projection catches up afterwards. That follow-up call can
therefore 404 on a thread that certainly exists. Binding here removes a race the firmware has no
way to resolve.
The title. A keyboard-less board is not expected to supply one, so it is optional:
| Source | Result |
|---|---|
title in the body |
trimmed, whitespace-collapsed, capped at 72 characters |
| omitted, blank, or not a string | "<D Mon HH:MM> · <device label>", e.g. 24 Aug 19:32 · Hosyond Touch screen; an unlabelled unit becomes Controller <short id> |
Either way the gateway makes the name unique — against the environment's snapshot titles and
against titles it minted in the last ten minutes — by appending (2), (3) … A firmware that
later has words to offer (a voice transcript, say) should send them: T3 only auto-retitles a thread
whose title is its own default "New thread", so whatever name this call produces is the name the
thread keeps.
Errors.
| Status | Meaning | What the screen should say |
|---|---|---|
403 |
The device profile does not grant thread_create (a read-only device), or a user role, environment, network or time-window rule refused it. details.policy names the dimension and rule. |
"Not allowed on this device" |
404 |
The bound project is gone from the environment. | Re-open the project picker |
409 |
No environment bound, no project bound, or no usable provider model (details.code: "no_model_selection") |
"Pick a folder first" / "No model configured" |
502 |
details.code: "t3_unreachable" — snapshot read or dispatch failed |
"Start T3 Code", offer Retry |
A refused create changes nothing: the thread the device was already on stays selected.
What the device does not choose. The model. thread.create requires a modelSelection, and
the gateway supplies it from the project's own defaultModelSelection, falling back to a
harness derived from the environment's snapshot. A bezel has no business picking a model, and
409 no_model_selection is the honest answer when neither source has one.
GET /v1/device/thread-output?page=0&after=2026-08-08T20%3A00%3A00.000Z
x-device-id: dev_...
x-device-secret: ...{
"thread": { "id": "thread_current", "title": "Firmware navigation" },
"work": {
"version": 1,
"source": "t3-task-activities",
"total": 3,
"active": 2,
"queued": 0,
"working": 1,
"waiting": 1,
"completed": 1,
"failed": 0,
"stopped": 0,
"backgroundLiveness": "working",
"truncated": false,
"omitted": 0
},
"response": {
"messageId": "message_...",
"state": "complete",
"page": 0,
"pageCount": 3,
"lines": ["Implemented the response", "reader and verified the", "signed firmware build."],
"truncated": false,
"updatedAt": "2026-08-08T20:00:06.000Z"
},
"suggestions": [
{ "actionId": "action_tests", "label": "Run tests", "kind": "remote_action", "requiresConfirmation": true }
]
}The device never chooses a thread id for this route; the gateway reads the owner/device-selected
config.threadId. Text is stripped of hidden metadata, normalized, wrapped to 31 printable
characters, and returned one three-line page at a time, with at most 12 pages. after prevents a
newly dispatched action from briefly showing the previous assistant response. Until a newer
assistant message exists the state is waiting; streaming responses are streaming and are polled
on the normal five-second display cadence.
work is a content-free aggregate of T3's structured task.* activity. It is independently capped
at 64 retained task IDs and includes only status counts, liveness, and truncation — never task IDs,
titles, roles, models, paths, summaries, errors, output, token usage, or parent links. When there is
no newer assistant response and structured work is active, the existing three response lines show
N active task(s), completed/failed counts, and the EXIT hint. Every line remains within the same
31-character budget, so current firmware gains truthful parallel-work status without rendering a
dense tree. The full evidence-linked hierarchy remains in the web/PWA inspector.
The gateway may add a hidden instruction to a device-originated T3 turn asking the model to append
<!--AC_FOLLOWUPS:["action_id"]-->. This is a recommendation, never authority. The gateway parses
at most two ids and returns only ids that still resolve to enabled, assigned, non-system actions in
the Controller Details layout. Invented, deleted, disabled, duplicated, Stop, Status, and Reset ids
are discarded. OK from the response opens the follow-up list; OK on a suggestion still opens the
ordinary local confirmation and then passes through the same gateway policy/approval pipeline.
Devices can fetch saved macros for the claimed account:
GET /v1/device/macros
x-device-id: dev_...
x-device-secret: ...Run a saved macro through the same policy and approval pipeline as other device intents:
POST /v1/device/macros/macro_.../run
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{}The protocol-v1 fallback maps the macro menu item to the first saved macro returned by the
gateway. Protocol v2 assigns the intended macro's action ID directly and does not list macros from
firmware.
Devices can list pending approval-required commands for the claimed account:
GET /v1/device/approvals
x-device-id: dev_...
x-device-secret: ...Approve or reject a pending command from hardware:
POST /v1/device/approvals/cmd_.../approve
x-device-id: dev_...
x-device-secret: ...
content-type: application/jsonPOST /v1/device/approvals/cmd_.../reject
x-device-id: dev_...
x-device-secret: ...
content-type: application/jsonIf approve or reject is included in the configured device menu, the current firmware acts on the first pending command in the approval queue.
The same response carries two more keys — providerApprovals (the agent asking permission) and
userInputRequests (the agent asking a question) — under separate keys with separate answer
routes. See docs/api.md for the full payload; the
short version for firmware:
- Each entry in
userInputRequestscarriesanswerable. When it is true, renderpromptand theoptionsarray (two to four short labels) and let the owner press one; answer withPOST /v1/device/user-input/:requestIdand body{"answers": {"<questionId>": "<the label pressed>"}}, echoingquestionIdfrom the poll verbatim. - When it is false, render
title/promptand thehintstring ("Answer this in the console.") and offer nothing to press. A free-text question, a multi-select, or a multi-question form all land here. Showing the question anyway is the point: a controller that says "Working" for twenty minutes while the agent waits on a question is the failure this replaces. canAnswerUserInputis false for aread-onlyprofile; render the requests, offer nothing.
POST /v1/device/intents
x-device-id: dev_...
x-device-secret: ...
content-type: application/jsonStatus intent:
{
"clientRequestId": "dev:d18cc98f-05a6127b-fac88742-1a970c11",
"environmentId": "env_...",
"intent": {
"type": "status"
}
}Prompt intent:
{
"clientRequestId": "dev:51c701c3-37f72315-a72c7640-6987acee",
"environmentId": "env_...",
"threadId": "thread_...",
"intent": {
"type": "agent_prompt",
"text": "Continue the current task, inspect progress, and run relevant tests."
}
}Every mutating agent call carries a fresh clientRequestId, except a retry whose HTTP outcome was
unknown: that retry must reuse the same id and exact body. Shared firmware writes one pending id and
a short non-content fingerprint to NVS before opening the socket, clears both after any positive HTTP
status, and reuses them after a timeout or reboot. Prompt, transcript, shell, media, and path content
must never be copied into that journal. Saved-action/macro runs and device thread creation follow the
same rule. The gateway answers an in-flight duplicate with 202/recovery: "processing", a settled
duplicate with the original command, and conflicting reuse with 409; none is permission to invent a
new id and blindly retry the effect.
Stop intent:
{
"environmentId": "env_...",
"threadId": "thread_...",
"intent": {
"type": "session_control",
"action": "stop"
}
}Shell intent:
{
"environmentId": "env_...",
"threadId": "thread_...",
"intent": {
"type": "shell_input",
"command": "npm test"
}
}Shell input is policy-screened by the gateway and converted into an approval-required T3 Code turn, rather than writing directly to a terminal. High-risk commands return a command with status approval_required; the device should show that the request is waiting for user approval instead of retrying it.
Every command also has a compact status timeline available to the signed-in owner:
GET /v1/commands/cmd_.../events
authorization: Bearer PLATFORM_TOKENThe timeline is intended for web/phone support views, not for the low-bandwidth ESP32 polling loop.
Hardware and phone/web clients use the same media model. The ESP32 camera or mic should upload a short capture first:
For a controller without the desired input, claimed firmware may instead create a phone companion
with POST /v1/device/companion-handoffs. The request selects record_audio or capture_image and
uses the device's current environment/thread unless explicit owned values are supplied. The response
contains a locally encoded QR payload for /#/media?handoff=... and a compact waiting state. The
device may poll GET /v1/device/companion-handoffs/:id and cancel with DELETE using its own realm.
It can never read or cancel another device's handoff. The code expires after five minutes, is
single-use, and carries no prompt, transcript, device credential, or T3 token. Firmware should show
only Waiting for phone, Claimed, Completed, Expired, or Cancelled; retry mints a new code.
QR rendering on each physical display remains board-specific and must be verified on that panel.
Bluetooth earbuds pair with the phone and are selected through the browser's audio-input list. This does not add Bluetooth HFP or LE Audio support to ESP32-S3 firmware.
POST /v1/device/media
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{
"kind": "audio",
"contentType": "audio/webm",
"dataBase64": "BASE64_BYTES",
"originalName": "capture.webm",
"transcript": "Optional transcript from the phone, device, or transcription worker."
}Then reference the returned media.id:
{
"environmentId": "env_...",
"threadId": "thread_...",
"intent": {
"type": "camera_prompt",
"mediaUploadId": "media_...",
"prompt": "Use this image as context for the current task."
}
}For audio prompts, the gateway uses the transcript supplied on the intent first, then the transcript stored on the audio media upload. Without either transcript, the audio is still stored and referenced as media context.
Audio media records expose processing.transcriptionStatus; phone/web clients can call POST /v1/media/media_.../transcribe to run the configured transcription provider before dispatching an audio prompt. The development gateway supports TRANSCRIPTION_PROVIDER=mock for deterministic local testing.
For production, prefer small still images and short compressed audio clips. The current default maximum is 2 MB.
The controller reports best-effort OTA state to:
POST /v1/device/firmware/status
x-device-id: dev_...
x-device-secret: ...
content-type: application/json{
"state": "downloading",
"version": "0.1.7",
"targetVersion": "0.2.0",
"detail": "download started",
"progress": 0
}state is one of available, downloading, installing, rebooting, verified, failed, or
rolled_back.
targetVersion, detail, and progress are optional. The running version is always included.
Firmware reports verified only after the new pending image successfully reaches the gateway and
ESP-IDF accepts esp_ota_mark_app_valid_cancel_rollback(). If verification fails, the image remains
pending and the bootloader can roll back on the next reset. Telemetry failure never bypasses image
signature, size, or SHA-256 verification and does not prevent rollback.
The firmware manifest response also carries installation:
automaticwhen the release is mandatory, the dashboard queued a desired version, or the controller policy is automatic. The controller verifies and applies immediately.confirmfor manual/notify policy with a newer compatible release. The panel showsOTA//UPDATE, EXIT defers without writing flash, and OK starts the same signature, size, SHA-256, partition-write, health-check, and rollback pipeline. A deferred release remains available from the Actions list asUpdate <version>.
The Actions list always includes Firmware / CHECK, which runs the same authenticated poll on
demand and reports current, unavailable, or update-ready state. When a cached update exists the row
becomes Update <version> / READY and opens the confirmation without another network round trip.
The confirm-capable build advertises heartbeat feature ota_confirm. This compatibility gate is
required: older firmware interpreted every updateAvailable: true response as unattended install
permission, so the gateway continues returning manual_or_notify/false to those builds.
An owner may pin a desired version through a staged release rollout. The device protocol does not receive cohort membership, percentages, evidence references, or fleet records; it sees only the same capability-scoped signed manifest after the cloud reconciler verifies ownership, hardware, channel, protocol, and required features. A rollback pin may point to a validated older signed release. It still uses the identical signature, size, SHA-256, inactive-slot, reboot, and health confirmation path. Fleet state is never account truth on the controller or in NVS.
The scaffold lives in:
firmware/CrowPanel-ESP32-2.13-E-paper
CrowPanel ESP32 2.13" E-Paper HMI Display, 250x122 landscape mono, ESP32-S3-WROOM-1 N8R8 (8 MB flash, 8 MB octal PSRAM). Wiki · vendor source.
| Function | GPIO |
|---|---|
| E-paper SCK / MOSI | 12 / 11 |
| E-paper RST / DC / CS / BUSY | 10 / 13 / 14 / 9 |
| Panel power enable | 7 (drive HIGH before init) |
| Dial up / down / confirm | 6 / 4 / 5 |
| MENU / EXIT | 2 / 1 |
| Power LED | 19 |
| Expansion header | 40, 41 |
All keys are active low with external pull-ups. There is no rotary encoder on this board.
Native USB is unavailable — GPIO19/20 are the ESP32-S3 USB pins and GPIO19 drives the power LED,
so the board programs over a USB-to-UART bridge and ARDUINO_USB_CDC_ON_BOOT must stay 0.
Elecrow lists two possible panel controllers for this SKU, SSD1680Z and JD79661, and ships a
separate driver for each. The units we have are JD79661, confirmed on hardware by compiling
both vendor drivers verbatim: the SSD1680 one hangs on the first busy-wait, the JD79661 one drives
the panel. GxEPD2 speaks only SSD1680 and assumes BUSY is active high, whereas this controller
idles BUSY high — so GxEPD2 blocks forever and the display never comes up. The firmware therefore
vendors Elecrow's driver at firmware/CrowPanel-ESP32-2.13-E-paper/lib/ElecrowEPD/ instead of
using GxEPD2.
Two traps in that library, both of which cost real bring-up time:
EPD_ALL_Fill()is dead SSD1680 code and does nothing on this panel. UseEPD_Clear(), which primes both RAM planes and loads the waveform LUTs. This controller has no OTP waveform, so a refresh without LUTs returns in milliseconds having changed nothing.- Every busy-wait is an unbounded
whileloop with no timeout.
The HARDWARE_MODEL string stays e213-esp32-s3r8 (2.13" e-paper, ESP32-S3 with 8 MB PSRAM).
It is the firmware manifest matching key, so changing it orphans fielded devices from their
release channel.
Bring-up flow:
cd firmware/CrowPanel-ESP32-2.13-E-paper
cp include/controller_config.example.h include/controller_config.hEdit include/controller_config.h, then build/upload with PlatformIO:
pio run
pio run --target upload
pio device monitorThe committed scaffold currently includes:
- WiFi connection.
- Device heartbeat.
- Protocol-v2 hardware manifest, controls polling/cache/revision acknowledgement, and generic saved-action execution with protocol-v1 fallback.
- Gateway-managed runtime config.
- Firmware update manifest polling.
- Optional OTA image download, SHA-256 verification, and apply using the ESP32 OTA partition API.
- Display polling.
- E-ink rendering through the vendored JD79661 driver and shared 250-by-122 Agent Controller UI module with original one-bit glyphs, a hardware-aligned control rail, content regions, and unchanged-frame suppression.
- Home, three-row Actions, Threads, and Gateways lists, plus compact Detail/result screens. MENU opens Actions, EXIT returns one level, and Home OK requests status.
- Bounded thread list/status parsing and explicit selected-row switching within the owner-bound environment. Protocol-v1 direct cycling remains as fallback.
- Control-ID dispatch plus a reserved 1.5-second OK+EXIT stop chord; legacy button-to-intent submission remains available for old gateways.
Before shipping customer hardware:
- Verify the panel controller variant (SSD1680Z vs JD79661) on a production sample before committing to GxEPD2.
- Decide the camera and microphone modules, pins, and capture format. The only spare IO on this board is the two-pin expansion header (40, 41), so a camera or mic almost certainly means a different board or a carrier.
- Pin the VPS TLS certificate or ship a CA bundle instead of
setInsecure(). - Test OTA image download, SHA-256 verification, rollback, and staged update application on real hardware.
- Replace the prototype shared-key HMAC manifest signature with asymmetric signatures before broad production rollout.
- Store device secrets in ESP32 NVS with a rotation path.
- Connect the manufacturing scripts to the final factory flashing station and label/QR printer.
- Add rate limits and per-device command quotas at the gateway.
- Direct terminal write now exists at the gateway as the
terminal_inputintent, gated on a custom profile, an environment paired withterminal:operate, and an always-required approval. It is still not offered on this five-key device and has not been exercised against a live T3 terminal.