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 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

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.

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 inWhat reaches AO_Log
Cable and baseOne reader task and one line editor, so this page treats them as one wired port
BluetoothWhole lines, no echo
ChannelOne line per MQTT message
FileWhat it holds
kaos/console/src/console_ao.cAO_Console: the table CMDS[], the key validator, every handler, the command in flight and its watchdog
kaos/console/src/console_grammar.cThe tokenizer, console_unquote() and console_quote(). No hardware, tested on a computer
kaos/console/src/console_fs.cThe working directory and the path rules of the file commands
kaos/log/src/log_ao.cAO_Log: the line editor, prompt, echo, raw mode for payloads, the copy to Bluetooth and to the channel ring
kaos/core/include/kcli_proto.hThe error codes, defined once
kaos/core/src/kaos_wire.hThe base64 encoder and the slice sizes every bulk producer shares
klog/src/klog.cThe ! line formatter, kode_log_reply() and the rescue path for code that cannot wait for AO_Log
khal/console/src/khal_console_esp.cThe transport and the reader task
kdrv/radio/src/kdrv_ble.cThe Bluetooth line assembler and output ring
kdrv/mirror/include/kmirror_proto.hThe 14-byte header of the mirror stream

Specifications

The command line

ThingValue
Shapeverb 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
SeparatorsSpaces 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 line239 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 tokens8, 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 lineIgnored. It earns no answer, even while a command is in flight
Repeated keyCode 3, msg is the key. A line means one thing, so the console does not pick a value for you
Unknown keyCode 3, msg is the key
Missing required keyCode 2, msg is the key
QuotesA 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
EscapesInside 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"
Helphelp 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.

TypeFormAccepts
uintbareUnsigned decimal digits, inside the range of the key
boolbareon or off
enumbareOne word of the key's list
stringquotedAny non-empty text: name="my app"
textquotedAny text, the empty one included: text=""
rgbbareThree numbers from 0 to 255 separated by commas: 0,0,200
roweitherThe 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.

LineMeaning
RecordStarts 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
>okSuccess. 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 recordQuoted: names, paths, SSIDs, URIs, versions, field names. Escaped with the same three pairs
Everything else in a recordBare: numbers, 0x hex, hex byte strings, words from a closed list. A bare - means no value and is never quoted
Longest reply line255 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.

CodeNameMeaningmsg
1KCLI_ERR_VERBUnknown verbThe verb
2KCLI_ERR_MISSINGMissing required key, or a token with no =The key or the token
3KCLI_ERR_VALUEBad value: wrong type or form, out of range, unknown enum word, unknown keyThe key
4KCLI_ERR_BUSYAnother command is in flight, or the owner is busy. sleep and power_off say which owner will not rest and whybusy, <who>: <why>, or not_built for a verb whose option is not in the build (sleep, power_off, cron)
5KCLI_ERR_REJECTThe owning Active Object refused or failedThe verb, or one word for the part that failed
6KCLI_ERR_TIMEOUTThe owner never sent the closing answer. The console is idle againThe 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:

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.

PartValue
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 unknownMilliseconds since boot, such as 4585. It wraps after about 49 days
<when>, clock knownUTC 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
ColourOff 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 pathCode in an interrupt or in Q_onError writes with esp_rom_printf, with the same stamp, and skips AO_Log
ESP-IDF and LVGL linesklog 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 Bluetooth port copies complete lines only: no prompt, no echo, no erase sequence.

Commands in flight and the watchdog

ThingValue
One at a timeA 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)
Watchdog5 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 ownerThe 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
Slicedhelp, 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
Extendedsleep 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
ImmediateThe 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
One payload transfer
ThingValue
Go signalThe 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 windowFrom 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 controlThe 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 rawThe 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 limit5 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 >okA 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
ReceiversAO_AppMgr for app, os and pending, AO_Radio for c5, AO_Storage for file_put
PortsThe 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
ThingValue
Header recordshot with w, h, fmt and len for a screenshot. file with name, size and medium for file_get
Record size216 base64 characters (KAOS_WIRE_PAY_B64), which is 162 source bytes. With its prefix a record is 220 bytes
PaddingOnly 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 checkThe len or size of the header is what the payload must come to. A host that decodes another number of bytes was cut off
Pace12 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 lossps reports log_dropped=0 and ble_info reports tx_drop=0
Channelfile_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
>|mpause

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

BytesFieldTypeMeaning
0 to 1sequint16_tWraps at 65535. mirror_ack seq= names it
2 to 3xuint16_tLeft edge of the rectangle, already scaled
4 to 5yuint16_tTop edge, already scaled
6 to 7wuint16_tWidth, already scaled
8 to 9huint16_tHeight, already scaled
10 to 13lenuint32_tJPEG bytes that follow the header

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