brief: "kilter tutorial" audience: "dev" model: "anthropic:claude-opus-4-8" template: null
marp: true paginate: true
Building on the Kilter Board
A developer's tutorial: from the Aurora API to lighting up the wall
Building on the Kilter Board
- A hands-on developer tutorial
- API • data model • BLE control
- Rangle.io
<!-- Welcome. Today we'll go end-to-end on building software against the Kilter Board ecosystem — from talking to the cloud API all the way to lighting up holds on a physical board. This is a developer tutorial, so we'll be in code a lot. -->
What is the Kilter Board?
- A modular LED training wall for climbers
- Holds at fixed positions; LEDs mark a 'climb'
- Driven by a mobile app + cloud (Aurora Climbing platform)
- Thousands of community-created, graded climbs
<!-- The Kilter Board is an adjustable-angle climbing wall with a fixed grid of holds, each backed by an addressable LED. A 'climb' is just a set of holds lit in particular colors. The official app talks to Aurora Climbing's backend, which is the same platform behind Tension and other boards. -->
Why build on it?
- Custom training dashboards & analytics
- Alternative climb browsers and filters
- Logging, projecting, and progress tracking
- Fun: control the wall from your own code
<!-- Developers build on Kilter for analytics the official app doesn't offer, custom filtering, training logs, and the sheer joy of driving the hardware directly. The data is rich and the protocol is approachable once you know the shape of it. -->
What we'll build today
- Authenticate against the Aurora/Kilter API
- Sync the climb + hold database locally
- Decode and render a climb
- Send a climb to a real board over Bluetooth
<!-- Here's the roadmap. By the end you'll have a tiny client that logs in, mirrors the data locally, can render any climb, and can push a selected climb to the physical board's LEDs. -->
Prerequisites & tooling
- Comfortable with HTTP/REST and JSON
- Python 3.11+ (examples) or your language of choice
- SQLite + an HTTP client (requests/httpx)
- For BLE: a Bluetooth adapter + bleak (or Web Bluetooth)
<!-- You'll want basic REST and SQL familiarity. I'll use Python with requests and bleak, but the concepts port to any stack. The BLE section needs actual hardware and a Bluetooth-capable machine. -->
Ecosystem at a glance
- Mobile app → talks to Aurora cloud over HTTPS
- Cloud → stores users, climbs, holds, ascents
- App → talks to board over Bluetooth LE
- Board → just lights LEDs from a packet
<!-- Mentally split the system into two channels. The cloud channel (HTTPS) is where data lives. The hardware channel (BLE) is dumb — the board lights whatever bytes you send. We'll tackle them separately. -->
The Aurora data model
- One shared schema across Aurora boards
- Core tables: climbs, holds, placements, sets, products
- 'product' = a board layout/size variant
- Everything keyed by stable UUID/int ids
<!-- Kilter rides on the generic Aurora schema. The same tables describe every board type. Once you understand climbs, holds, and placements, you understand all of them. The 'product' concept distinguishes board sizes and layouts. -->
Key vocabulary
- Hold: a physical grip on the wall
- Placement: a hold mounted at a coordinate on a product
- Climb: an ordered set of lit placements (a route)
- Frame: the compact string encoding a climb's holds
<!-- Precision matters here. A hold is the grip; a placement is that hold installed at an (x,y) on a specific board layout. A climb references placements, and it stores them in a compact 'frames' string we'll decode later. -->
Setting up the project
- Create a virtualenv and install deps
pip install requests bleak
- Project layout: api.py, sync.py, render.py, board.py
- Keep credentials in env vars, never in code
<!-- Standard project hygiene. Four small modules keep responsibilities clean: API client, sync logic, rendering, and BLE board control. Put your username/password in environment variables. -->
Talking to the API
- Base URL: https://kilterboardapp.com/...
- JSON over HTTPS, mobile-app style endpoints
- Two endpoints do most of the work: login + sync
- Inspect traffic with a proxy if unsure
<!-- The API is the same one the mobile app uses, so it's undocumented but stable. The two endpoints you need are login and sync. When in doubt, point a debugging proxy at the app to confirm shapes — just be respectful. -->
Authentication flow
- POST username + password to the sign-in endpoint
- Receive a session token + user id
- Send token on subsequent requests
- Tokens are long-lived — cache them
<!-- Auth is a simple username/password sign-in that returns a token and your user id. You attach the token to later calls. Tokens last a while, so cache one rather than logging in on every run. -->
Login — code
- resp = requests.post(f'{BASE}/sessions', json={'username':U,'password':P})
- token = resp.json()['session']['token']
- user_id = resp.json()['session']['user_id']
- Store token + user_id for reuse
<!-- Here's the actual call. POST credentials, pull the token and user_id from the response. Persist these so you're not re-authenticating constantly. Field names may vary slightly by API version — print the response the first time. -->
The sync endpoint
- One endpoint returns deltas across many tables
- POST a map of table -> last synced timestamp
- Server returns only rows newer than your watermark
- Empty watermarks = full initial download
<!-- Sync is the clever part: a single endpoint takes per-table 'shared_syncs' watermarks and returns only what's changed. Send empty/zero watermarks the first time to pull everything, then store the new high-water marks. -->
Understanding the sync tables
- users, climbs, climb_stats, ascents
- holds, placements, sets, products, product_sizes
- Each row carries a 'last updated' timestamp
- You mirror exactly what the app caches
<!-- The payload spans the same tables the app keeps offline. climbs and placements are what we care about most for rendering; climb_stats holds grades and quality. Every table participates in the watermark scheme. -->
A local SQLite mirror
- Create tables matching the sync payload
- Upsert rows by primary key
- Persist per-table sync watermarks in a meta table
- Now you can query offline, fast
<!-- Mirror the data into SQLite. Upsert on primary key so re-syncs are idempotent. Keep a small meta table of watermarks. After the first sync, all your queries run locally and instantly. -->
Your first sync — code
- body = {'client':{'enforces_product_passwords':1}, 'GET':{'query':{'syncs':marks}}}
- data = requests.post(f'{BASE}/sync', json=body, headers=auth).json()
- for table, rows in data['PUT'].items(): upsert(table, rows)
- save_watermarks(data['PUT'].get('shared_syncs'))
<!-- Conceptually: send your watermarks, get back a PUT map of table to rows, upsert each, then save the updated shared_syncs. Wrap this in a loop until the server reports no more changes for large initial pulls. -->
Anatomy of a climb record
- uuid, name, setter, description
- layout_id / product references
- angle (board tilt) lives in climb_stats
- frames: the encoded hold list
<!-- A climb row has metadata — name, setter, description — plus a layout reference and, crucially, the frames string. Grade and angle-specific info live in climb_stats, joined by climb uuid and angle. -->
Decoding the frames string
- Format: pNNNNNrMM repeated, no separators
- 'p' + placement_id, 'r' + role_id
- Split on 'p', then on 'r'
- Yields (placement_id, role_id) pairs
<!-- The frames string concatenates tokens like p1145r15p1203r12. Each chunk is a placement id and a role id. Parse by splitting on 'p', then each piece on 'r'. That gives you every lit hold and what kind it is. -->
Mapping placements to holds — code
- import re
- pairs = re.findall(r'p(\d+)r(\d+)', frames)
- for pid, role in pairs: hold = placements[int(pid)]
- x, y = hold['x'], hold['y']
<!-- A single regex extracts the pairs. Look each placement id up in your synced placements table to get its x/y coordinate on the board, plus the LED position. The role id tells you the color/meaning. -->
Hold roles & colors
- Start holds (green)
- Hand/intermediate holds (blue)
- Foot holds (orange/yellow)
- Finish holds (purple/magenta)
<!-- Roles map to the familiar Kilter color scheme: green starts, blue hands, foot holds, and finish. The exact rgb per role lives in a placement_roles table you also sync, so don't hardcode — look it up. -->
Rendering a climb
- Use placement x/y as image coordinates
- Draw a circle per lit hold in its role color
- Overlay on a board background image
- Great sanity check before touching hardware
<!-- Rendering to an image is the best way to verify your decode. Plot each placement at its coordinate, color by role, and overlay on a photo of the board. If it looks like a real climb, your pipeline is correct. -->
Querying climbs locally — code
- SELECT c.uuid, c.name, s.difficulty, s.angle
- FROM climbs c JOIN climb_stats s ON s.climb_uuid = c.uuid
- WHERE s.angle = 40
- ORDER BY s.quality_average DESC
<!-- Now it's just SQL. Join climbs to climb_stats to get grade and quality for a given angle. Because it's local SQLite, you can build rich filters with no network latency. -->
Filtering by grade & angle
- difficulty is a numeric grade ladder
- Map difficulty -> V-scale / font via a lookup table
- Angle changes the grade — always filter on both
- Quality + ascent counts help rank results
<!-- Grades are stored as numbers on a ladder; sync also brings a difficulty_grades table to translate to V-scale or Font. Remember a climb's grade depends on angle, so always filter angle and grade together. -->
Going physical: BLE
- Board advertises as a BLE peripheral
- Find it by name prefix, connect, discover services
- Write LED data to a specific characteristic
- No pairing PIN — just connect and write
<!-- The hardware channel is Bluetooth LE. Scan for the board (it advertises a recognizable name), connect, and find the writable characteristic used for LED data. There's no real auth on the board — it lights what it's told. -->
The LED packet protocol
- Build a byte stream of (position, r, g, b) entries
- Wrap with header + checksum bytes
- Split into ~20-byte BLE chunks with markers
- Board reassembles and lights the holds
<!-- You construct a payload of LED position + color triplets, add the framing/checksum the firmware expects, then chunk it to fit BLE's small MTU with start/middle/end markers. The board reassembles and displays it. -->
Sending a climb to the board — code
<!-- Tie it together: turn your decoded pairs into LED position/color entries, encode them into BLE packets, connect with bleak, and write each chunk to the LED characteristic. The wall lights up your selected climb. -->
Putting it together: the mini client
- login() → sync() → store locally
- search() climbs by grade/angle
- render(climb) to verify
- send(climb) to the board
<!-- That's the whole app in four verbs. Authenticate, sync, search, and either render or send. Everything else is UI and polish. You now own the full pipeline from cloud to hardware. -->
Error handling & rate limits
- Handle 401 → re-authenticate and retry once
- Back off on 429 / network errors
- Don't hammer sync — respect watermarks
- Validate frames before rendering
<!-- Be a good citizen. Refresh tokens on 401, back off on rate limits, and only sync when needed thanks to watermarks. Defensive parsing of frames avoids crashes on odd data. -->
Caching & offline strategy
- First run: full sync (can be large)
- Subsequent runs: tiny delta syncs
- Cache token + watermarks on disk
- App works fully offline after first sync
<!-- The initial sync pulls a lot, but every run after is a small delta. Persist the token and watermarks so startup is fast and the app keeps working without a connection. -->
Gotchas & lessons learned
- API is unofficial — expect occasional changes
- Angle-specific grades trip people up
- BLE MTU/chunking bugs are the #1 hardware issue
- Always look up colors/grades from synced tables
<!-- The biggest surprises: the API can shift since it's undocumented, grades are angle-dependent, and most hardware problems come from incorrect BLE chunking. Avoid hardcoding anything the sync already gives you. -->
Resources & community
- Open-source clients on GitHub (boardlib & friends)
- Aurora schema notes from the community
- Climbing-dev forums and Discords
- Read responsibly — it's an unofficial API
<!-- You're not alone — there are mature open-source libraries like boardlib that wrap a lot of this, plus community schema docs. Use them to validate your understanding, and treat the API with respect. -->
Recap & next steps
- You can auth, sync, decode, render, and light up
- Next: build a training log or recommender
- Try Web Bluetooth for a browser version
- Go build something — and share it
<!-- Recap: we covered the full stack from the Aurora API to physical LEDs. From here, build a training tracker, a climb recommender, or a browser app with Web Bluetooth. Thanks — now go make something cool with your board. -->