kodeOS internals
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.
>|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 personOne 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
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
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_emulateandnfc_emulate_offclose with>ok reads=<n>.- The NFC and RFID commands that explain a physical outcome put a quoted sentence in
msg, such asmsg="no tag in the window". - When a tag is in the window but the exchange fails,
nfc_readandnfc_writeput the rawkode_status_tof the owner incode. - A failed
file_descanswers>err code=<n>with nomsg.
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
\rESC [K(ERASE_LINEinlog_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.
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
| 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.
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.
>|m=<base64>
>|m=<base64>
>|mabort
>|mpauseThe 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_startor 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()intools/kpayload.pykeeps the 0 to 3 leftover characters for the next call, andMirrorDecoderintools/kmirror.pyis the reference. >|mabortmeans 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.>|mpausemeans the session sleeps after 5 s with no progress.mirror_startor anymirror_ackwakes 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
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.
# 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: the parser, the command table and the grammar.kcli_proto.h: the error codes.tools/kpayload.py:quote(),fields()and the base64 decoders every host tool shares.