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(withe2ee_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
- An LLM builds it — the
carter-mcpserver lets Claude design and live-push layouts conversationally. See Live Editing & the MCP Authoring Loop. - Any other language — the wire protocol is plain JSON over WebSocket; implement it directly with Building a Server in Any Language (Raw Protocol).
Next
- Copy-paste starting points: Recipe: Telemetry Dashboard, Recipe: Interactive Controls.
- The full control list: Control Catalog; the layout shape: Layout Schema.
- The frames underneath it all: Message Reference.