Programming CAR-TER

The fastest way to program CAR-TER is the carterkit Python library: it builds layouts, validates them, and drives a paired device over MeshSocket — and its control catalog is the documentation, so the controls you build are always in sync with the app. This page is the end-to-end tour. (Not using Python? See Building a Server in Any Language (Raw Protocol); want an LLM to author for you? See Live Editing & the MCP Authoring Loop.)

pip install carterkit

The mental model

A layout is a JSON document (tabs → grid → controls) the app renders. Your server joins a MeshSocket channel and does two things: feeds display controls data, and handles the actions input controls emit. See Architecture and Data Flow — sync & actions for the full picture; everything below is one continuous example.

1. Explore the controls (the docs are the catalog)

Every placeable control, its fields, and its examples come straight from the bundled docs — no separate schema to drift:

import carterkit

carterkit.controls()              # {type: schema} for every control
carterkit.doc("gauge")            # full parsed doc: fields, themeFields, examples
print(carterkit.doc_markdown("gauge"))   # the rendered documentation
carterkit.examples("button")      # documented example snippets

Same thing from the shell:

carterkit catalog                 # list every control type
carterkit doc gauge               # print a control's documentation
carterkit examples button --name "..."   # print one example's JSON

2. Build a layout

Controls are methods on the layout, ids are positional, and bindings fold into kwargs. Each control method returns a handle you can use as a binding target or patch later. Tabs and groups are context managers, so nesting reads the way Python nests. Bad control types and out-of-range enums raise instead of silently shipping a broken layout:

from carterkit import Layout

with Layout("Bench", cols=4, rows=4) as ui:
    ui.connect("ws://192.168.1.50:8765", channel="lab")

    with ui.tab("Main", icon="gauge"):
        # a gauge that listens for the server's `metrics` broadcasts:
        cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
                       listen="cpu", when={"msg_type": "metrics"})
        # a warning light that shows only when the gauge reads hot:
        ui.status_light("warn", visible=cpu > 90)
        # a button that asks the server to refresh:
        ui.button("refresh", label="Refresh", send="refresh", request=True)

The binding sugar covers the common cases — listen=/when=/event= build a sync, and send=/request=/payload= build an action. For anything fancier, pass sync=[...]/action={...} built with carterkit.bind directly. A handle comparison (cpu > 90) becomes a real Visibility condition; ==/!= stay normal Python, so use .eq()/.neq() for equality conditions.

Validate and save straight off the builder (schema + grid + binding lint, against the bundled catalog):

print(ui.findings())        # "✓ No issues found." or the problems
ui.save("bench.json")       # write JSON the app (or the <a href="live-editing-with-claude.html">MCP</a>) can load

Prefer a declarative style? For fixed, hand-written dashboards there's a class veneer that compiles to the exact same layout — ids come from attribute names and tabs/groups are nested classes:

```python from carterkit.declare import Screen, Tab, Connect, Gauge, Button, StatusLight

class Bench(Screen, cols=4, rows=4): relay = Connect("ws://192.168.1.50:8765", channel="lab") class Main(Tab, icon="gauge"): cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu") warn = StatusLight(visible=cpu > 90) refresh = Button(label="Refresh", send="refresh", request=True)

Bench.save("bench.json") ```

Generated/looping UIs read better flat; static ones read better declarative — same engine underneath. (The older .add(build.gauge(...), default_span=…) fluent chain still works too.)

3. Drive it (your server)

CarterClient wraps the connection. Feed controls with broadcast(msg_type, data) and handle actions with on(event, handler) — the names must match what the layout declared above (metrics / cpu, and refresh):

import asyncio, random
from carterkit import CarterClient

async def main():
    async with CarterClient(gateway_url="ws://192.168.1.50:8765", token="",
                            channel="lab", role="hub", name="bench-server") as c:

        # device → server: the Refresh button (a routed request)
        c.on("refresh", lambda data: {"ok": True})

        # server → device: push a metrics frame the gauge picks up
        while True:
            await c.broadcast("metrics", {"cpu": round(random.uniform(0, 100), 2)})
            await asyncio.sleep(1)

asyncio.run(main())

That's a complete loop: the gauge tracks cpu, and tapping Refresh calls your handler. The async with connects on entry and disconnects on exit. The five server responsibilities (and higher-rate patterns) are spelled out in Anatomy of a CAR-TER Server.

4. Reshape the UI at runtime (dynamic groups)

"Dynamic" has two senses here, both first-class:

Author-time generation — build controls in for/if loops; each is auto-placed in its group's own grid:

with ui.tab("Motors"), ui.group("Bank", span=(4, 2), cols=2, rows=4) as bank:
    for i in range(4):
        bank.slider(f"m{i}", min=0, max=255, send="set_motor", payload={"i": i})

Runtime injection — a group with dynamic="event" has its children replaced live by a broadcast tagged msg_type: "event" that carries a children array. Declare the placeholder, then build the replacement with Fragment (same control methods; its .children is the array to send):

from carterkit import Fragment

ui.group("Now Playing", span=(3, 4), cols=4, rows=3, dynamic="player_state")

frag = Fragment(cols=4, rows=3)
frag.label("title", text=track.name, span=(1, 4))
for a in track.actions:
    frag.button(a.id, label=a.label, send="play", payload={"id": a.id})
await client.broadcast("player_state", {"children": frag.children})   # or frag.payload("player_state")

Catch the classic failure modes — event never sent, missing children, off-grid or invalid injected controls — before they ship by linting the layout against the broadcasts your server actually emits:

import carterkit
msgs = [frag.payload("player_state")]          # or frames captured from your client
print(carterkit.format_findings(carterkit.lint_dynamic_traffic(ui.layout, msgs)))

Dynamic tabs and timed poll groups round out the server-driven toolkit — see Recipe: Server-Driven (Dynamic) UI.

5. Encryption & notifications

Pass e2ee_key= to CarterClient and traffic is transparently end-to-end encrypted (ChaCha20-Poly1305 + per-session salt) — broadcasts and request replies. To push a notification to every device on a Connect+ account, use c.notify(title, body) (needs validator_url/session_jwt) or the stdlib-only carterkit.notify_http(...) from a cron job. See Connection & Pairing for pairing + keys.

6. Shared rooms & being the source of truth

A room is a layout many people open at once (several phones + your hub), all on one channel. Two things change versus a 1:1 setup:

  • Group encryption. Pass room=True (with e2ee_key=) so the hub uses the symmetric room cipher every member shares, instead of the directional 1:1 one:

python CarterClient(gateway_url=..., token=..., channel="lounge", role="hub", e2ee_key=KEY, room=True)

  • State backfill. The relay stores nothing, so a device that connects after you've set a control sees stale defaults — unless a source of truth answers for the current state. Declare your hub the authority and record each control's current value:

```python async with CarterClient(..., room=True, e2ee_key=KEY) as c: c.enable_state_authority() # this hub answers "what's the state?" on join

  async def publish(control_id, msg_type, value):
      c.set_control_state(control_id, value)         # remember it for snapshots
      await c.broadcast(msg_type, {"value": value})  # live push to connected devices

  await publish("thermostat", "temp", 68)
  await publish("lamp", "lamp_state", True)
  # a phone that joins now pulls {thermostat: 68, lamp: True} immediately

```

set_control_state(control_id, value) takes the control's resolved value (the same value your sync produces after any valuePath), keyed by the layout control's id. When a joining device asks, carterkit replies with a snapshot of everything you've recorded — addressed to that device and group-encrypted like any other frame.

On the layout side, opt in with a top-level state block (see Layout Schema): sync turns on chat-history backfill + control-state pull, and authority names who is the source of truth — a role ("device"/"hub") or an exact connection name:

{ "state": { "sync": true, "authority": "hub" }, "connection": { "mode": "room", … } }

Only the authority answers; everyone else adopts what it sends. A node that is itself the authority never adopts an incoming snapshot (so two hubs can't fight). Chat history rides the same mechanism — a joiner's client asks the room and merges what peers send back.

7. Generate a starting point

carterkit can scaffold a server skeleton from an existing layout:

carterkit gen bench.json > server.py     # a runnable MeshSocket service stub
carterkit relay --port 8765              # run the bundled relay for local testing

infer.build_layout(payload) goes the other way — it generates a wired layout from a sample telemetry dict.

Other ways to author

Next