carterkit
Build and drive CAR-TER layouts from Python.
The control docs are the library. Every control's schema, fields, and examples are parsed at runtime from the ControlDocs markdown bundled inside the package — the exact same docs the CAR-TER app renders — so the catalog never drifts from the definitions.
pip install carterkit
Quickstart
pip install carterkit
1. Make a hub credential. In CAR-TER on the phone open Hubs -> Add Hub
(Connect+) and save the JSON it shows as secrets/hub.json.
2. Scaffold and run a starter project
carterkit new demo
mkdir -p demo/secrets && mv secrets/hub.json demo/secrets/ # wherever you saved it
cd demo
python3 hello.py
A gauge updates every 2 s on the phone and the button resets it. secrets/ is
git-ignored; hello.py is the file to edit (carterkit repl hello.py gives you a
live prompt on the same hub).
No Connect+? Use the LAN Studio path. The embedded relay binds 127.0.0.1 by
default, so a phone cannot reach its QR. Bind the LAN explicitly:
import asyncio
from carterkit import Layout
with Layout("Hello", cols=4, rows=3) as ui:
level = ui.gauge("level", label="Level", min=0, max=100, listen="level")
async def main():
async with ui.serve(host="0.0.0.0") as hub: # 0.0.0.0: reachable from the phone
print("pair the app with:", hub.qr_json())
await hub.wait_for_device()
await hub.push_layout()
await level.push(42)
await asyncio.sleep(60)
asyncio.run(main())
The pairing QR then carries your LAN address and the shared key.
Build a layout
Controls are methods on the layout, ids are positional, tabs and groups are context managers, and bindings fold into kwargs. Each control method returns a handle you can use as a binding target or patch later. Unknown control types and bad enum values raise instead of silently shipping a broken layout:
from carterkit import Layout
with Layout("Dashboard", cols=4, rows=4) as ui:
ui.connect("ws://192.168.1.50:8765", channel="home")
with ui.tab("Main", icon="gauge"):
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
listen="cpu", when={"msg_type": "metrics"})
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
ui.button("refresh", label="Refresh", send="refresh")
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
ui.save("dashboard.json") # the composed layout, ready to push/load
Binding sugar: listen=/when=/event= build a sync, and send=/payload= build
an action — send="refresh" compiles to the one shape the relay actually forwards
(broadcast_request tagged msg_type: "refresh"; Hub.on demuxes it back for you).
Pass sync=[...]/action={...} (via carterkit.bind) for anything fancier. A handle
comparison (cpu > 90) becomes a real visibility condition; ==/!= stay normal
Python, so use .eq()/.neq(). help(carterkit.build.gauge) prints any control's
documentation, straight from the bundled docs.
Naming: multi-word controls are
snake_caseasLayoutmethods (ui.status_light(...),ui.log_console(...),ui.progress_ring(...)) butcamelCaseas the JSONtypeand ascarterkit.build.*functions ("statusLight",build.logConsole). Single-word controls (gauge,button) look the same either way. Grid size (cols/rows) set onLayout(...)is the default for every tab; override it per tab withui.tab("Name", rows=…).
Beyond MeshSocket — sources, sensors, and app-side features
A layout can drive itself off protocols you already run, or the phone's own hardware, with no server code — the app speaks them directly:
ui.source_mqtt("broker", "mqtt://192.168.1.10:1883") # declare a broker
ui.source_http("api", "http://192.168.1.5:8080", interval=5)
with ui.tab("Home", icon="house"):
ui.gauge("temp", label="Temp", min=0, max=40, sync=[bind.mqtt("home/temp")])
ui.toggle("fan", label="Fan", sync=[bind.mqtt("home/fan/state")],
action=bind.mqtt_publish("home/fan/set"))
ui.gauge("cpu", label="CPU", sync=[bind.http("/status", interval=5, valuePath="cpu")])
ui.compass("hdg", label="Heading", sensor="heading") # device sensor, no backend
Credentials stay out of the JSON: ui.secret(...) declares a {{secret:name}} the
phone fills in from its Keychain at request time, only for a host the declaration
allows (the layout's source hosts by default; hosts=[...] replaces that, mesh=True
allows MeshSocket action payloads). validate_layout warns (secret_host,
secret_mesh, secret_in_url, secret_not_filled) when a request would be refused.
token = ui.secret("ha_token", label="Home Assistant token", kind="token")
ui.source_http("ha", "https://ha.local:8123", headers={"Authorization": f"Bearer {token}"})
bind.mqtt / bind.mqtt_publish / bind.http / bind.http_request / bind.sensor build
the sync/action dicts; the validator checks a source: names a declared source and that
mqtt/http bindings carry a topic/path. These are marked app-direct in the contract, so a
generated bridge.py never tries to serve them.
The phone can also own the data outright: a local store (local-store.md) is typed
collections that live in the app and survive the layout, queried with plain JSON stages —
no server, no SQL:
ui.source_local("db", {"books": {"title": "string", "pages": "integer", "finished": "date"}},
namespace="reading-log",
views={"thisYear": {"from": "books", "where": {"finished": {"gte": "{{startOfYear}}"}}}})
with ui.tab("Shelf", icon="books.vertical"):
ui.label("n", label="Books", sync=[bind.local("books", aggregate="count")])
ui.chart("m", label="Per month",
sync=[bind.local("thisYear", group_by={"field": "finished", "bucket": "month"})])
ui.button("add", label="Add", action=bind.local_op("insert", "books",
set={"title": "{{value}}", "finished": "{{today}}"}))
bind.local / bind.local_op build the sync/action; the validator checks every stage
against the declared fields (unknown field, bad operator or op, limit outside 1…1000,
reserved set keys, view cycles, the shared namespace) so the phone never sees a stage
it would refuse.
Author the rest of the app's surface from Python too:
ui.publisher("heading", interval=0.25) # stream a sensor to a hub/server
ui.alert(event="broadcast", value_path="temp", operator="gt", value=30,
title="Too hot", body="Greenhouse over 30°C") # relay-watcher push rule
ui.glance(hero="temp", slots=["fan"], live_activity=True) # widgets / island / Control Center
ui.poll_group("tick", event="broadcast_request", interval=10, payload={"msg_type": "poll"})
ui.appearance(color_scheme="dark", show_header=True)
ui.dynamic_tab("inject_tab") # runtime-injected tab
ui.state(sync=True, authority="hub", acks=True) # device-held shared state + acks
Ack'd commands. With ui.state(acks=True) the app stamps each command with a
_cmd id and waits ~2 s for command_ack; a Hub serving that layout acks
automatically (CarterClient.enable_command_acks() by hand). Return True from a handler
that handled the frame; anything else stays silent so the control reverts. Every hub
also dedupes _cmd ids — a re-delivered or replayed command never runs twice and gets
the first ack again. The seen-set is saved beside the device.json credential (or
cmd_dedupe_path=), so it survives a restart; token-only hubs keep it in memory.
Prefer a declarative style? A class veneer compiles to the same layout — ids come from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat builder reads better for generated ones):
from carterkit.declare import Screen, Tab, Connect, Gauge, Button, StatusLight
class Dashboard(Screen, cols=4, rows=4):
relay = Connect("ws://192.168.1.50:8765", channel="home")
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")
Dashboard.save("dashboard.json")
Dynamic groups
Generate controls in for/if loops (auto-placed in the group's own grid), or mark a
group dynamic="event" and replace its children live at runtime. Build that replacement
payload with Fragment, then lint it against the broadcasts your server actually emits —
catching events that never arrive, missing children arrays, and off-grid/invalid
injected controls before they ship:
import carterkit
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="Song", span=(1, 4))
frag.button("play", label="Play", send="play")
# your server broadcasts frag.payload("player_state") == {"msg_type": ..., "children": [...]}
print(carterkit.format_findings(
carterkit.lint_dynamic_traffic(ui.layout, [frag.payload("player_state")])))
Prefer surgical edits? LayoutBuffer gives add_control / update_control / move_control
over a held draft; lay.buffer exposes it.
infer.build_layout(payload) generates a wired layout from a sample telemetry dict;
codegen.generate_service_stub(layout) emits a runnable Hub-based server skeleton;
theming.theme_for(...) and tune.tune_gauge(...) round out the authoring tools.
Drive the layout you just built
The layout already declares every control's wire contract, so the same object that
authored the UI also drives it — ctrl.push(value) derives the broadcast from the
control's sync binding, and @ctrl.on derives the demux from its action:
import asyncio
import random
from carterkit import Layout
with Layout("Thermostat") as ui:
with ui.tab("Main"):
temp = ui.gauge("temp", label="Temp", min=0, max=40,
listen="temp", when={"msg_type": "climate"})
target = ui.slider("target", min=10, max=30, send="set_target")
setpoint = 20.0
reading = 18.0
async def main():
global setpoint, reading
# host="0.0.0.0": the phone on your LAN must be able to reach the QR address
async with ui.serve(host="0.0.0.0") as hub: # embedded LocalRelay
print("pair the app with:", hub.qr_json())
await hub.wait_for_device()
await hub.push_layout() # routed apply-layout; echoes what rendered
@target.on
async def _(data):
global setpoint
setpoint = data["value"] # your heater control goes here
while True:
reading += (setpoint - reading) * 0.1 + random.uniform(-0.2, 0.2)
await temp.push(round(reading, 1))
await asyncio.sleep(2)
asyncio.run(main())
The same surface works cross-process off the saved JSON — the layout file is the
contract: Hub("dashboard.json").push("temp", 21.5). Dynamic groups fill with
hub.fill(group, fragment); the hub answers late joiners with the last pushed values
(control-state authority) by default.
Explore the controls (zero config)
import carterkit
carterkit.controls() # {type: schema} for every placeable control
carterkit.doc("gauge") # full parsed doc: fields, themeFields, examples
print(carterkit.doc_markdown("gauge")) # the rendered documentation prose
carterkit.examples("button") # documented example snippets
iOS surfaces from your backend
Use hub.surfaces to refresh widgets and Control Center state, send notifications
with buttons and inline replies, and start/update/end Live Activities. It derives
payloads from your layout and uses the hub's renewed Add Hub credential. Existing
@control.on handlers receive actions from widgets, Control Center and Shortcuts.
await hub.surfaces.publish({temperature: 25}, activity=True) # widgets + Control Center + island
await hub.surfaces.notify("Workshop", "Job complete", include_glance=True)
print(await hub.surfaces.state()) # what a widget pulls when iOS wakes it
publish is one request to the relay, which retains the values: a widget iOS
wakes hours later still renders something, and the relay's own floors coalesce a
chatty publisher rather than burning the app's push budget (a skipped push comes
back as suppressed, with the state merged either way). If the phone never opened
CAR-TER no token is registered: the reply then carries no_tokens: True (and one
warning is logged per layout).
Design the surfaces in the layout, in the same vocabulary as the controls:
from carterkit import tile, scene, widget, island, live, cc_toggle, cc_step
ui.glance(hero=nozzle, live=live(tier="fresh"),
widgets=[widget("temps", title="Temps", families=["systemMedium"],
scene=scene([tile("gauge", nozzle), tile("gauge", bed)],
[tile("sparkline", nozzle, span=2)]))],
island=island(compact_trailing=tile("ring", progress),
expanded=dict(bottom=scene([tile("gauge", nozzle)]))),
controls=[cc_toggle("lights", "Lights", lights),
cc_step("fan-up", "Fan +10", fan_speed, delta=10)])
Control Center has toggles and buttons only, so a slider becomes cc_step/cc_set
and a picker becomes cc_cycle; carterkit.doc("glance") has the full reference.
See the connector guide for setup, notification actions, delivery semantics, payload limits and lower-level APIs. The example previews offline by default. These additions are in this checkout; installing the existing PyPI release does not include them.
One connection story
Connection.parse(...) accepts every connection artifact in the ecosystem, and
ui.connect(...) / ui.serve(...) / Hub(...) all take it:
| You have | Pass | Works |
|---|---|---|
| nothing (LAN dev) | ui.serve() |
embedded LocalRelay, QR pairing |
| a self-hosted relay | "ws://192.168.1.50:8765" (+ token=) |
symmetric: same config for app & hub |
| Connect+ | the Add Hub JSON from the app (Hubs -> Add Hub) | token self-refresh + room E2EE automatic |
The asymmetry to know: self-hosted is symmetric (one URL + shared key both sides); on Connect+ the app joins with its own account while the hub holds the per-device credential — which is the hub's identity, so it is never embedded into a layout.
CarterClient remains the lower-level client (on/broadcast/request).
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is applied to every frame
the client sends when an e2ee_key is present, and every sealed frame it receives is
opened, replay-checked and freshness-checked (±120 s) before a handler sees it. Keys
must be exactly 32 bytes (base64). A plaintext frame arriving in an E2EE session is
dropped and counted (client.dropped["plaintext"]); relay control frames are always
plaintext and always pass. CarterClient(strict_e2ee=False) passes plaintext through
with a one-time warning per msg_type — a debugging aid for a peer that is not sealing
yet, not a supported mode. What E2EE does not do: room mode is one symmetric key
per room, so it does not tell members apart — any member can send as any other.
Send a push to every device on a Connect+ account with CarterClient.notify(...) or
the stdlib-only carterkit.notify_http(...).
Security defaults
- The embedded
LocalRelay(andHub()/ui.serve()with no connection) gets a random shared key and binds127.0.0.1; the pairing QR then honestly saysws://127.0.0.1:…, which only this machine can reach. Passhost="0.0.0.0"(orcarterkit explore --lan/carterkit relay --lan) for a phone on the LAN — the QR then carries the LAN address and the key. A keyless relay needsinsecure=True. Hub(strict_e2ee=, max_inflight=, rate_per_type=)pass through to itsCarterClient.- A device credential's
validatormust behttps. Plainhttpis accepted only for127.0.0.1/localhostwithallow_insecure_validator=True. carterkit exploreserves loopback only, refuses foreignHost/Origin, requires a per-run token on every write, and redactsconnection.token,e2eeKeyand source passwords/auth headers from/api/layoutand/api/status. The QR on the page still encodes the full pairing payload — that is what the phone scans.validate_layoutnever raises on hostile input and flagsnon_finite,too_deep,too_many_controls,bad_url,embedded_secretandlong_string.Layout.savewrites0600(a layout may carry a relay or room key).- A device credential loaded from a
device.jsonpath is rewritten (atomically,0600) when the validator rotates its refresh secret; load credentials by path, not inline, so a rotation survives a restart. - Inbound frames are dispatched under a semaphore (32) and a per-
msg_typetoken bucket (20/s, burst 40); excess frames are dropped and counted inclient.dropped.
CLI
carterkit new demo # scaffold a starter project (hello.py, secrets/, VS Code settings)
carterkit repl hello.py # live prompt with the hub connected, controls bound by id
carterkit catalog # list every control type
carterkit doc gauge # print a control's documentation
carterkit examples button # list a control's examples (--name to print one)
carterkit validate layout.json # lint a layout (exit 1 on errors)
carterkit gen layout.json # generate a runnable Hub server stub
carterkit explore layout.json # live, typed endpoint explorer (--device, --lan, --port)
carterkit share layout.json # serve a layout for 10 minutes + QR to import it
carterkit local describe ws://HOST:PORT # read/seed the paired phone's local store (describe|query|seed)
carterkit relay --port 8765 # run the bundled MeshSocket relay (keyed, loopback)
carterkit relay --lan --key s3cret # let phones on the LAN join with that shared key
carterkit pager install|uninstall|run # approve Claude Code tool calls from the phone
carterkit version # print the installed version
carterkit relay generates and prints a shared key when you don't pass --key, and
binds 127.0.0.1 unless you pass --lan. Running it open (no key) requires
--insecure and means anyone who can reach the port joins every channel.
Agent pager
carterkit pager pages your phone to approve Claude Code tool calls (fails closed).
Setup, the Agents layout and metrics: docs/pager.md.
Built on
meshsocket — the WebSocket mesh transport.
The ControlDocs are vendored from the CAR-TER app repo; refresh them with
scripts/sync-controldocs.sh.
Metadata
Release files for carterkit 0.17.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| carterkit-0.17.0.tar.gz | 528.7 kB | With provenance | Details for carterkit-0.17.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| carterkit-0.17.0-py3-none-any.whl | Python 3 | none | any | Details for carterkit-0.17.0-py3-none-any.whl |
Total release size: 1.1 MB
Release files / carterkit-0.17.0.tar.gz
| Download URL | carterkit-0.17.0.tar.gz |
|---|---|
| Size | 528.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ca5bfa51fec893972e77ee4f080113bf0900f583aa90d791293652f6e82b2c1f
|
|
BLAKE2b-256 checksum How to use checksums |
725dd7b60f9efe6be2a0670a5469f18aefb82ed4b17639af78167eb3d0806083
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logRelease files / carterkit-0.17.0-py3-none-any.whl
| Download URL | carterkit-0.17.0-py3-none-any.whl |
|---|---|
| Size | 573.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
24de8e258fd541b125bb8bdb0e01aa6d9448854235b19cdd34053167289c2b95
|
|
BLAKE2b-256 checksum How to use checksums |
e4d3350583d89e8879a609feb6f17f116ea95fcb7abc3fa710744ede5927b20e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logSecurity for carterkit 0.17.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for carterkit-0.17.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.