# Console protocol

How a console line is written, what comes back, how log lines and levels look, and how files and screenshots cross the wire as base64.

## What it is

The console is the text command line of a Kode Dot. A host program sends one command per line. The board answers with records, one closing line and, on the side, log lines. The same lines travel over four ways in: the USB-C cable, the charging base, Bluetooth and the Kode channel on the internet.

Almost every client is a program: a bench script, `tools/kcli.py` or `tools/kble.py`. So the protocol is built for parsers. The first characters of a line say what the line is, and a host never has to guess which lines answer its command.

```text
>|name="doom.bin" size=524288 medium=sd     a record: one line of the answer
>ok                                         the terminator: the command worked
>err code=3 msg=rgb                         the terminator: it did not
!N 2026-10-28T09:12:44.585Z kaos.power ...  a log line: nobody asked for it
kode> ping                                  the prompt and the echo, for a person
```

One rule governs every value: **a string is always quoted, and nothing else ever is.** The form of a value says what the value is, in both directions, and every value has exactly one way to be typed. What the board prints can be typed back unchanged.

## How it is connected

```mermaid
flowchart LR
  subgraph ways["Ways in"]
    cable["USB-C cable"]
    dock["charging base"]
    ble["Bluetooth<br/>kdrv_ble"]
    chan["Kode channel<br/>AO_Net"]
  end
  subgraph board["On the Kode Dot"]
    rd["khal reader task"]
    log["AO_Log"]
    con["AO_Console"]
    own["owning AO"]
  end
  cable --> rd
  dock --> rd
  rd --> log
  ble --> log
  chan --> con
  log -- "CONSOLE_LINE_SIG" --> con
  con -- "request event" --> own
  own -- "answer event" --> con
  con -- "kode_log_reply" --> log
  class cable,dock,ble ext
  class chan,log,con,own ao
```

*From a byte on the wire to an answer*

Two Active Objects share the work.

- **AO_Log owns the console device** and is the only writer of it. It runs a small line editor for the cable and the base, tags each finished line with its source (cable or BLE) and publishes it as `CONSOLE_LINE_SIG`. A line off the Kode channel reaches AO_Console from AO_Net, and a line of the cron scheduler from AO_Scheduler, each with its own source.
- **AO_Console owns the parsing.** It splits the line, validates it against the command table `CMDS[]`, runs the handler and tracks the one command in flight.

A handler never touches a device: it posts to the Active Object that owns the device, and that owner answers by event.

Every reply line leaves through `kode_log_reply()`, which sends it to the same queue as the log lines. Answers and log lines reach AO_Log in the order the code produced them.

A fifth source has no wire. An entry of `cron` runs a console line on its own at a clock time (source SCHED), so nobody types it and nothing reads its answer from a port: the reply is written to the log as a notice, `sched: <reply>`. A line of this source is held to the policy of the channel (`internals/command-set`, `sdk/kaos/scheduler`).

AO_Log writes each line to the cable and the base and copies it to Bluetooth. It also copies the replies of channel commands into the ring that AO_Net drains, and it never copies a `!` line.

The four ways in differ in what they hand over:

| Way in | What reaches AO_Log |
| --- | --- |
| Cable and base | One reader task and one line editor, so this page treats them as one wired port |
| Bluetooth | Whole lines, no echo |
| Channel | One line per MQTT message |

| File | What it holds |
| --- | --- |
| `kaos/console/src/console_ao.c` | AO_Console: the table `CMDS[]`, the key validator, every handler, the command in flight and its watchdog |
| `kaos/console/src/console_grammar.c` | The tokenizer, `console_unquote()` and `console_quote()`. No hardware, tested on a computer |
| `kaos/console/src/console_fs.c` | The working directory and the path rules of the file commands |
| `kaos/log/src/log_ao.c` | AO_Log: the line editor, prompt, echo, raw mode for payloads, the copy to Bluetooth and to the channel ring |
| `kaos/core/include/kcli_proto.h` | The error codes, defined once |
| `kaos/core/src/kaos_wire.h` | The base64 encoder and the slice sizes every bulk producer shares |
| `klog/src/klog.c` | The `!` line formatter, `kode_log_reply()` and the rescue path for code that cannot wait for AO_Log |
| `khal/console/src/khal_console_esp.c` | The transport and the reader task |
| `kdrv/radio/src/kdrv_ble.c` | The Bluetooth line assembler and output ring |
| `kdrv/mirror/include/kmirror_proto.h` | The 14-byte header of the mirror stream |

## Specifications

### The command line

| Thing | Value |
| --- | --- |
| Shape | `verb key=value key="text" ...`. The first token is the verb, every other token is `key=value`. A token with no `=` answers code 2 and names the token |
| Separators | Spaces and tabs between tokens. A quoted value is one token, however many words it holds |
| End of line | `\n`. A `\r` before it is removed. The line editor ends a line at either |
| Longest line | 239 characters. A line of 240 bytes (`KODE_CONSOLE_LINE_MAX`) or more is refused whole, never cut. On the cable the answer is code 3 with `msg=line_too_long`. The Bluetooth assembler drops it and logs a warning, with no terminator |
| Most tokens | 8, verb included (`KCLI_MAX_ARGS`). A ninth token answers code 3 and names it. A command declares at most 6 keys (`KCLI_MAX_KEYS`) |
| Empty line | Ignored. It earns no answer, even while a command is in flight |
| Repeated key | Code 3, `msg` is the key. A line means one thing, so the console does not pick a value for you |
| Unknown key | Code 3, `msg` is the key |
| Missing required key | Code 2, `msg` is the key |
| Quotes | A quote opens a value only right after the `=`. A quote that never closes, or bytes right after the closing quote, answer code 3 with the key. The refusal never repeats the value, since it can be a password |
| Escapes | Inside quotes every byte means itself, except a backslash. Three pairs exist: `\"` a quote, `\\` a backslash, `\n` a newline. Any other backslash pair answers code 3 with the key, so a Windows path is written `"C:\\apps"` |
| Help | `help` prints one `cmd` record per verb with its keys as `key:type:req` or `key:type:opt`. An optional key with a default ends in `=default`, and an enum lists its members in parentheses |

The value types decide the form. A key of type `string` or `text` refuses the bare word, and every other type refuses the quoted one. Both refusals are code 3 with the key in `msg`.

| Type | Form | Accepts |
| --- | --- | --- |
| `uint` | bare | Unsigned decimal digits, inside the range of the key |
| `bool` | bare | `on` or `off` |
| `enum` | bare | One word of the key's list |
| `string` | quoted | Any non-empty text: `name="my app"` |
| `text` | quoted | Any text, the empty one included: `text=""` |
| `rgb` | bare | Three numbers from 0 to 255 separated by commas: `0,0,200` |
| `row` | either | The `arg=` of `function` and the `value=` of `config`. The row that the line names decides the form |

### The answer

A command that reaches the console gets exactly one terminator, unless the board or the link goes away first. `hcf` resets the board and sends none. A host program always has a timeout.

| Line | Meaning |
| --- | --- |
| Record | Starts with a greater-than sign and a bar, then `key=value` pairs. One line of the answer, zero or more per command. A record can be one bare word: `ping` answers the record `pong` |
| `>ok` | Success. The data arrived in the records above it |
| `>err code=<n> msg=<word>` | Failure. `msg` is one bare word: the key, the verb or the part that failed |
| Text in a record | Quoted: names, paths, SSIDs, URIs, versions, field names. Escaped with the same three pairs |
| Everything else in a record | Bare: numbers, `0x` hex, hex byte strings, words from a closed list. A bare `-` means no value and is never quoted |
| Longest reply line | 255 bytes with its newline (`KLOG_LINE_MAX` is 256 with the terminator). An over-long quoted value loses its tail, never its closing quote, and a warning comes before the record |

`>ok` has three meanings, which the kind of command tells apart. A **query** (`bat_info`) sends its data in records and then `>ok`. An **accept** (`sd_mount`, `app_erase`, `install` from a medium) says only that the request reached its owner, and the owner tells the outcome in `!` lines. A **result** (`mkdir`, `cp`, `install src="terminal"`) means the work is done. `app_run`, `install dst=os` and `hcf` reset the board, so nothing follows their answer.

| Code | Name | Meaning | `msg` |
| --- | --- | --- | --- |
| 1 | `KCLI_ERR_VERB` | Unknown verb | The verb |
| 2 | `KCLI_ERR_MISSING` | Missing required key, or a token with no `=` | The key or the token |
| 3 | `KCLI_ERR_VALUE` | Bad value: wrong type or form, out of range, unknown enum word, unknown key | The key |
| 4 | `KCLI_ERR_BUSY` | Another command is in flight, or the owner is busy. `sleep` and `power_off` say which owner will not rest and why | `busy`, `<who>: <why>`, or `not_built` for a verb whose option is not in the build (`sleep`, `power_off`, `cron`) |
| 5 | `KCLI_ERR_REJECT` | The owning Active Object refused or failed | The verb, or one word for the part that failed |
| 6 | `KCLI_ERR_TIMEOUT` | The owner never sent the closing answer. The console is idle again | The verb |

When the verb alone cannot say what failed, code 5 carries one word: `volume`, `missing`, `notdir`, `isdir`, `noparent`, `notempty`, `exists`, `iofail`, `src`, `appendline`, `slots`. Three words name the source of a line. `cable` means the command needs the USB-C cable or the base, `net` means it needs a wire and the channel carries only lines, and `sched` means a scheduled line may not run it (every payload verb, and `cron` itself). For `screenshot dst=http` the words are `http` (the api refused), `size` (the frame is over 64 KB), `channel` (the channel is down) and `stream`. `internals/command-set` lists the words of each verb.

Four answers leave the two-line scheme:

- `nfc_emulate` and `nfc_emulate_off` close with `>ok reads=<n>`.
- The NFC and RFID commands that explain a physical outcome put a quoted sentence in `msg`, such as `msg="no tag in the window"`.
- When a tag is in the window but the exchange fails, `nfc_read` and `nfc_write` put the raw `kode_status_t` of the owner in `code`.
- A failed `file_desc` answers `>err code=<n>` with no `msg`.

### Log lines

A line that starts with `!` is something the host did not ask for. Panic text and the boot log are the only output that does not follow the marker rule.

| Part | Value |
| --- | --- |
| Shape | `!<L> <when> <tag> <text>`. The text can carry `key=value` fields |
| Level `<L>` | `D` debug, `I` info, `N` notice, `W` warning, `E` error. Notice is for outcomes of requests, readings a command asked for and changes in the state of the system |
| `<when>`, clock unknown | Milliseconds since boot, such as `4585`. It wraps after about 49 days |
| `<when>`, clock known | UTC to the millisecond, such as `2026-09-03T21:09:44.585Z`. The date is always UTC, whatever zone the face shows. A parser must accept both shapes |
| Colour | Off by default. `log_color state=on` wraps each line in an ANSI colour code at the start and a reset code at the end |
| Rescue path | Code in an interrupt or in `Q_onError` writes with `esp_rom_printf`, with the same stamp, and skips AO_Log |
| ESP-IDF and LVGL lines | klog captures them and prints them in this same shape |

The global level is the setting `log` of `config`, with the words `d`, `i`, `n`, `w`, `e` and `off`. It is stored, so it survives resets, including the reset that opening the cable port causes. `log_level tag="<tag>" level=<word>` sets one tag for the current boot. Replies are never filtered: every level keeps every `>` line, so a command still closes with `>ok` or `>err`. A script that reads `!I` lines on a board left at `n` or `e` sees nothing, so it sets `value=i` first.

### The prompt and the echo

AO_Log draws `kode> ` and echoes typed characters for a person. While only a program uses the console, every `>` and `!` line starts at column 0. A parser handles two leftovers from a person's session:

- The echo of the command the host sent comes back before the answer, with a `kode> ` prefix when the prompt was drawn. Discard everything up to the first line that starts with `>` or `!`.
- A record can arrive after the erase sequence `\r` `ESC [K` (`ERASE_LINE` in `log_ao.c`), on the same line. Drop everything up to and including it.

The Bluetooth port copies complete lines only: no prompt, no echo, no erase sequence.

### Commands in flight and the watchdog

| Thing | Value |
| --- | --- |
| One at a time | A command is in flight from the moment the console starts it until its terminator. A new line gets `>err code=4 msg=busy` at once, before anything else about it is looked at. The refusal goes to the port of the new line. A line off the Kode channel is the exception: it is not refused but held by AO_Net, which offers it again when the console is free, so a command sent while another runs still runs (`internals/kode-channel`) |
| Watchdog | 5 s (`KCLI_CMD_TIMEOUT_MS`). When the owner never answers, the command ends with code 6 and the console is idle again. Late records are dropped |
| Handed to the owner | The wireless and network verbs (`wifi_scan`, `wifi_join`, `ble_scan`, `net_ping`, `http_get`, `mqtt_up` and similar) arm the watchdog and disarm it at once, since the owner keeps the deadline. They never end in code 6 |
| Sliced | `help`, `ps`, `variable_list`, `function_list` and the `_help` verbs of the chip tables send 12 records (`KAOS_WIRE_SLICE`) and wait 10 ms before the next 12. The watchdog starts again on each slice. `screenshot` and `file_get` restart it only on a slice that moved bytes |
| Extended | `sleep` and `power_off` 17 s. `screenshot dst=file` and `dst=http` 20 s. `cat` 30 s. `cp` 60 s. `appdata_ls` and `appdata_info` 1 s per row. `test` the budget of each row plus 5 s. `ir_rx` its window |
| Immediate | The terminator leaves before the handler returns. No tracking |

The slices exist because every reply line is an event from the large pool, and AO_Console outranks AO_Log. A listing sent in one step would take the whole pool (`KQP_LARGE_POOL_COUNT`, 48) before AO_Log wrote a line, and the tail with the terminator would be lost.

`console_congested()` adds back-pressure: producers wait while AO_Log has more than 24 lines queued (4 when a Bluetooth console is attached), or while the Bluetooth ring holds more than 512 bytes. The check runs for each record, not for each slice.

### Payload from the host

`install src="terminal"` and `file_put` are the only commands whose payload follows the line.

```mermaid
sequenceDiagram
  autonumber
  participant H as host
  participant B as board
  H->>B: install src="terminal" dst=os size=N crc32=C
  B->>H: >|ready chunk=4096
  loop each 4096-byte chunk
    H->>B: raw bytes
    B->>H: >|ack
  end
  H->>B: last partial chunk, no ack
  B->>H: >ok
```

*One payload transfer*

| Thing | Value |
| --- | --- |
| Go signal | The record `ready chunk=4096` (`KODE_RECV_CHUNK`). Until it arrives, input bytes belong to the line editor. An install into the apps region may send `evicted name="<project>" kept=off` records first, one per app it evicts |
| Raw window | From the ready record, the port is raw for exactly `size` bytes. The board holds the count, so a host that keeps sending finds the line editor again. `crc32` is `zlib.crc32` of the whole payload, written in decimal |
| Flow control | The host sends one chunk and waits for the record `ack`. The cable gives no flow control of its own: the USB-Serial/JTAG peripheral acknowledges at line rate and drops what its 8 KB receive ring (`CONSOLE_RX_BUF`) cannot hold. Each chunk is written to flash with the cache off, and the board acks only when it is in flash. A partial last chunk earns no ack |
| Switch to raw | The reader task notices the request at the top of its loop, discards pending input, makes raw mode active and only then reports it. The ready record is sent after that, so no byte has two meanings |
| Idle limit | 5 s (`KODE_RECV_IDLE_MS`) between chunks. The receiver sends a stall event and the console answers code 6. The watchdog of the console is off from the ready record |
| Final `>ok` | A result: the checksum matched, the image verified and the directory written. With `dst=app run=on` it is sent right before the reset that boots the app |
| Receivers | AO_AppMgr for `app`, `os` and `pending`, AO_Radio for `c5`, AO_Storage for `file_put` |
| Ports | The cable, the base and Bluetooth. `dst=c5` is cable and base only, and Bluetooth answers `msg=cable`. The channel refuses both with `msg=net` |

On Bluetooth there is no reader task to divert. The driver stops assembling lines and gathers the GATT writes into 4 KB chunks on the NimBLE task. The payload follows the command down the port it arrived on.

### Bulk data from the board

`screenshot` and `file_get` send the opposite way. No raw mode is used for output: the rescue path could write into the middle of it, Bluetooth carries only lines, and holding all other output for a transfer defeats the log. The board sends **base64 inside ordinary `>|d=` records.**

```text
screenshot
>|shot w=410 h=502 fmt=bmp16 len=411768
>|d=Qk34IQYAAAAAAIAAAAAoAAAAmgEAAPYB...
...
>ok
```

| Thing | Value |
| --- | --- |
| Header record | `shot` with `w`, `h`, `fmt` and `len` for a screenshot. `file` with `name`, `size` and `medium` for `file_get` |
| Record size | 216 base64 characters (`KAOS_WIRE_PAY_B64`), which is 162 source bytes. With its prefix a record is 220 bytes |
| Padding | Only the last record may carry `=`. A producer that reads in chunks keeps the odd bytes and puts them at the start of the next record. Join all the records and decode once |
| Size check | The `len` or `size` of the header is what the payload must come to. A host that decodes another number of bytes was cut off |
| Pace | 12 records per 10 ms at most, and slower when `console_congested()` says so. A full screenshot is about 2,542 records: about 3 s on the cable, about 22 s over Bluetooth |
| Proof of no loss | `ps` reports `log_dropped=0` and `ble_info` reports `tx_drop=0` |
| Channel | `file_get` and `screenshot dst=console` answer `msg=net`. `screenshot dst=file` and `dst=http` run from every port |

The host reads without stopping. The board never sends faster than its writer can write, but nothing protects the serial buffer of the host, and a program that pauses to parse loses bytes the board did not drop.

`screenshot dst=http` is not a transfer on this wire. The board encodes a JPEG and uploads it over HTTPS. See `internals/kode-channel`.

### The mirror stream

`mirror_start` uses the same base64 shape as a screenshot, but as a continuous stream with no terminator between frames.

```text
>|m=<base64>
>|m=<base64>
>|mabort
>|mpause
```

The records decode to one continuous buffer: a 14-byte header, then `len` JPEG bytes, then the next header. Fields are little-endian.

| Bytes | Field | Type | Meaning |
| --- | --- | --- | --- |
| 0 to 1 | `seq` | `uint16_t` | Wraps at 65535. `mirror_ack seq=` names it |
| 2 to 3 | `x` | `uint16_t` | Left edge of the rectangle, already scaled |
| 4 to 5 | `y` | `uint16_t` | Top edge, already scaled |
| 6 to 7 | `w` | `uint16_t` | Width, already scaled |
| 8 to 9 | `h` | `uint16_t` | Height, already scaled |
| 10 to 13 | `len` | `uint32_t` | JPEG bytes that follow the header |

- Only the first frame after `mirror_start` or a pause covers the whole panel. Later frames can be the rectangle that changed, so a viewer places each part at its own offset.
- **Decode only whole groups of 4 base64 characters and never pad a short tail.** Padding makes different bytes, silently. `decode_whole_groups()` in `tools/kpayload.py` keeps the 0 to 3 leftover characters for the next call, and `MirrorDecoder` in `tools/kmirror.py` is the reference.
- `>|mabort` means the frame in progress is abandoned and the next record starts a new header. The board sends it only when at least one byte of the frame has left.
- `>|mpause` means the session sleeps after 5 s with no progress. `mirror_start` or any `mirror_ack` wakes it.
- Both control records travel on the console line on every transport, the Bluetooth binary characteristic included. That characteristic carries the same header and raw JPEG bytes with no base64.

A variable followed with `variable name="<row>" watch=<ms>` also emits records after its command has ended, between the records of other commands. The rule for a host is the same: filter on the first key and drop what you did not ask for. See `internals/variables-and-functions`.

## Gotchas

> **WARNING**
>
> **Opening the cable port reboots the board.** The USB-Serial/JTAG peripheral resets the P4 when a computer attaches, and clearing DTR and RTS first does not prevent it. Anything that builds state (a Wi-Fi join, an MQTT session) must run inside one open session. A loop of separate runs tests a board that rebooted a moment ago. The charging base does not reset the board.

> **WARNING**
>
> **`>ok` is often only an accept.** For mutations such as `sd_mount`, `radio_up` or `install` from a medium, `>ok` says the owner got the request. The outcome arrives in `!` lines later. Read them, or run the command with a listening gap in `kcli.py`.

> **NOTE**
>
> **`ping` is the liveness check, `net_ping` is ICMP.** Every host tool speaks `ping`, so the network verb has its own name.

> **NOTE**
>
> **A watch outlives its command.** Records of a followed variable and the `>|m=` stream arrive between the records of any other command. Match your answer by its first key, not by position.

## Example code

The script below opens the cable port, sends `ping` and checks the answer. It skips the echo and the erase sequence, collects records until the terminator, and ends silently when the board answers `pong` and `>ok`. Replace the port name with the one of your board. Opening the port restarts the board, so a real program waits for it to boot before the first command.

```python
# ping.py - 2026-10-28 - from Kode with love
#
# Sends "ping" to a Kode Dot over the USB-C cable and checks that the console
# answers the record "pong" and then the terminator ">ok". It prints nothing
# when the check passes and stops with an assertion error when it does not.
import serial

port = serial.Serial('/dev/cu.usbmodem101', 115200, timeout=2)

# Send one command and return its terminator plus the records before it.
def command(line):
    port.write((line + '\r\n').encode())
    records = []
    while True:
        text = port.readline().decode(errors='replace').rstrip('\r\n')
        # The erase sequence can precede a line once, after the port opens.
        text = text.rsplit('\r', 1)[-1].removeprefix('\x1b[K')
        if text.startswith('>|'):
            records.append(text)
        elif text.startswith('>ok') or text.startswith('>err'):
            return text, records
        # Echo and '!' log lines fall through: they are not the answer.

terminator, records = command('ping')
assert (terminator, records) == ('>ok', ['>|pong'])
```

**TBD**: this example has not been compiled or run on a Kode Dot yet.

## In the SDK

- [`components/kaos/console`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/console): the parser, the command table and the grammar.
- [`kcli_proto.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/core/include/kcli_proto.h): the error codes.
- [`tools/kpayload.py`](https://github.com/kodediy/KodeOS/blob/main/tools/kpayload.py): `quote()`, `fields()` and the base64 decoders every host tool shares.
