1. The UI-CLI manager (copy-paste layer)

examples/1-ui-cli-manager/ is a complete CLI layer on top of micronetcode: a raylib app whose entire command surface is a JSON file, and every action is reachable over a local TCP socket.

python examples/1-ui-cli-manager/app.py --headless   # run the app
printf 'set_text "speed: 12 m/s" 620 340\ndraw_circle 100 100 25\n' | ncat localhost 42069

Why it is an example and not a module

Up to 0.3.1, ui_cli_manager shipped as a second package next to micronetcode. Nobody used it as shipped: robosim drives ConnectionManager directly with its own msgpack protocol, and visual-graph-navigation forked the file to swap the argument-count dict for a schema. That is the pattern, not the accident — transport is stable, policy diverges. micronetcode owns the transport; the line format, the quoting rules, the argument typing and the help text are yours.

So ui_cli_manager.py is a single file with no __init__.py and no PyPI entry. Copy it into your project, change it, own it. It is 170 lines; the day you need different rules you edit them instead of filing an upstream issue.

The protocol is the command set

The app declares its commands in protocol.json, parsed by microspec. Each endpoint names its inputs and their dtypes (and optionally a range or an enum):

{
  "set_text": {
    "description": "Draw `text` at (x, y). Quote the text to keep spaces in one argument",
    "input":  {"text": {"dtype": "str"},
               "x":    {"dtype": "int32", "range": [0, 4000]},
               "y":    {"dtype": "int32", "range": [0, 4000]}},
    "output": {"status": {"dtype": "str"}}
  }
}

UICLIManager reads that and does all the boring work before the app sees anything — split the line (shlex, so quoted arguments stay whole), check the arity, convert each token to its dtype, range/enum-validate it. Bad input never reaches the app; it gets a reply on its own:

> set_text a 1              cmd_args=['set_text', 'a', '1']. Expected args: 3, got 2
> draw_circle 1 1 abc       Conversion error could not convert string to float: 'abc'. Arg: radius (dtype: float32). Value: abc
> set_text a 99999 0        Validation error: Out of range: 99999 not in [0, 4000]
> nope                      Unknown command 'nope'. Supported: ['help', 'exit', ...]

There is no built-in help: make it an endpoint like any other and answer it in the app — only the app knows how it wants to render its own spec.

The manager

with open(PROTOCOL_PATH, "r") as fp:
    spec = Protocol.from_dict(json.load(fp))
cli_manager = UICLIManager.with_spec(host="0.0.0.0", port=args.port, spec=spec,
                                     script_lines=script_lines, max_connections=3)
cli_manager.start()

with_spec is the whole layer in one call: host + port, the spec, a bound on connections, and an optional script of startup commands. .start() spawns the listener thread that accepts clients and answers them. --headless just hides the window (FLAG_WINDOW_HIDDEN) so the app can run on a server with no display — the CLI is the frontend there.

I/O handling sits next to the frame poll

get_cli_command() is called where rl.IsKeyPressed / rl.IsMousePressed would be: once per frame, never blocks, returns the next command if a client sent one. Every command is answered exactly once via respond(), and clients are colored per connection:

cli_cmd = cli_manager.get_cli_command()
if cli_cmd is not None:
    color = [rl.RAYWHITE, rl.RED, rl.BLUE][cli_cmd.message.client.channel.idx
                                           if cli_cmd.message.client is not None else 0]
    if cli_cmd.command == "help":
        cli_cmd.respond(f"Supported commands: {list(spec.endpoints)}")
    if cli_cmd.command == "clear":
        cli_cmd.respond("Cleared all drawing from the UI")
        data_to_draw.clear()
    if cli_cmd.command == "set_text":
        data_to_draw.append(("text", *cli_cmd.args, color))   # args are already (str, int, int)
        cli_cmd.respond(f"Added text: {data_to_draw[-1][0:-1]} to the UI.")
    ...

Two things to notice. cli_cmd.args is a tuple of converted values — no int(...) anywhere in the app, because the spec already said int32. And cli_cmd.message.client.channel.idx — the message carries which client sent it, and the client carries which channel it owns, so a multi-client reply always routes back to the right socket. That is the strict-channel contract: one outstanding command per client until you respond().

Adding a command is one protocol.json entry plus one if.

Scripted startup

--script cmds.txt feeds lines through the same parse/validate path before any TCP client is served, in order. Those replies have no socket to go to, so they are journaled on manager.script_responses as (command, reply) pairs — which makes a cmds.txt file a literal, assertable contract. That is exactly how test/ui_cli_manager/integration/scripts/calculator/ tests a whole app in one file: a protocol, a script, and the journal.

Drawing is just raylib

The command handler only appends draw items; the render section is plain raylib, unchanged from a window-only app. The exit command breaks the loop (and the app).

rl.BeginDrawing()
rl.ClearBackground(rl.BLACK)
for kind, *params, color in data_to_draw: # every item is (kind, *the command's args, color)
    if kind == "text":
        rl.DrawText(params[0].encode(), params[1], params[2], 20, color)
    elif kind == "circle":
        rl.DrawCircle(params[0], params[1], params[2], color)
    elif kind == "line":
        rl.DrawLineEx((params[0], params[1]), (params[2], params[3]), params[4], color)
rl.EndDrawing()