The Goal
A Bluesky client in the style of a 1997 mail reader: the timeline on the left, the selected post and its replies on the right, menus and buttons on the command bar. It exists: velo-bluesky, a sibling project built with this toolchain, is the worked example for this chapter. Read this chapter for the shape of the problem, and its source for the details.


The AT Protocol in Ten Minutes
Bluesky runs on the AT Protocol. For a client, it's an HTTP API with JSON in and out.
- Handle
- A user's name, like
alice.bsky.social. - DID
- A user's permanent ID, like
did:plc:abc123. Handles can change. DIDs don't. - PDS
- The server that holds a user's data and that the client talks to. For most users,
https://bsky.socialworks as the entry point. - XRPC
- How calls are made:
GETfor queries andPOSTfor procedures, to/xrpc/followed by the method's name, such asapp.bsky.feed.getTimeline. - Record
- Something a user creates, like a post or a like, stored in their repository under a collection such as
app.bsky.feed.post. Each has a URI (at://did/collection/key) and a content hash, the CID.
Signing in
Send the handle and an app password (made in Bluesky's settings, so the user's real password never touches the Velo):
POST http://bsky.social/xrpc/com.atproto.server.createSession HTTP/1.0
Host: bsky.social
Content-Type: application/json
Content-Length: 67
{"identifier":"alice.bsky.social","password":"abcd-efgh-ijkl-mnop"}
The response has did, handle, a short-lived accessJwt and a long-lived refreshJwt. Send the access token on every other call as Authorization: Bearer .... When a call fails with the error ExpiredToken, call com.atproto.server.refreshSession with the refresh token as the bearer, take the new pair, and repeat the call. Keep the refresh token and DID in the registry, and the next start can skip signing in.
If the user signs in with their main password and has email confirmation turned on, createSession fails with AuthFactorTokenRequired. Ask for the emailed code and send it again with "authFactorToken".
The calls you need
| Call | Method | Does |
|---|---|---|
com.atproto.server.createSession | POST | Sign in. |
com.atproto.server.refreshSession | POST | New tokens, with the refresh token. |
com.atproto.server.deleteSession | POST | Sign out, with the refresh token. |
app.bsky.feed.getTimeline?limit=10&cursor=... | GET | The Following timeline, a page at a time. |
app.bsky.feed.getFeed?feed=at://... | GET | A custom feed. |
app.bsky.feed.getListFeed?list=at://... | GET | A list's posts. |
app.bsky.actor.getPreferences | GET | The user's saved and pinned feeds, among other things. |
app.bsky.feed.getPostThread?uri=at://... | GET | A post with its parents and replies. |
com.atproto.repo.createRecord | POST | Post, reply or like. |
com.atproto.repo.deleteRecord | POST | Delete a post, or unlike. |
Pages come with a cursor: pass it back to get the next page. Each timeline item has a post with uri, cid, author (handle, displayName), record (text, createdAt), counts, and optionally an embed with pictures. A reason marks a repost, and reply gives the parent.
Posting
{"repo":"did:plc:abc123","collection":"app.bsky.feed.post",
"record":{"$type":"app.bsky.feed.post","text":"Posted from a Velo","createdAt":"2026-10-04T12:00:00.000Z"}}
A reply adds "reply":{"root":{"uri":...,"cid":...},"parent":{"uri":...,"cid":...}} to the record. A like is a record in app.bsky.feed.like with "subject":{"uri":...,"cid":...}. Unliking deletes that record, by the key at the end of its URI. Text is UTF-8 and at most 300 characters (graphemes, strictly). createdAt comes from GetSystemTime, so it's only as right as the Velo's clock.
Building It in Layers
velo-bluesky is about 6,800 lines of C. Each layer only knows about the one below it, and each was tested separately:
| Layer | Files | Chapter |
|---|---|---|
| Frame: command bar, status bar, panes, message loop | main.c | 4 |
| What each command and each finished job does | app.c | 3 |
| Custom-drawn timeline, post and picture views | feed_view.c, post_view.c, picture_view.c, draw.c | 4 |
| Sign In, Compose, Settings and About dialogs | dialogs.c, bluesky.rc | 4 |
| Network thread and job queue | worker.c | 6 |
| XRPC calls, sessions, token refresh | atproto.c | 7 |
| JSON to posts: only the fields shown | lexicon.c, model.h | 7 |
| HTTP/1.0 through the proxy | http.c | 6 |
| JSON, gzip and GIF decoders, pictures in 2 or 4 bits per pixel | json.c, inflate.c, gif.c, picture.c | 7 |
| Strings, UTF-8, times, arenas, registry | text.c, memory.c, settings.c | 5 |
The bottom layers (JSON, gzip, GIF, text) are plain C with no Windows calls, so they also compile on the Mac. velo-bluesky tests its gzip decoder and its JSON handling there with the address sanitizer. Do the same with yours: bugs are far quicker to find on the Mac than on a Velo.
Problems You'll Meet
Size
A page of 10 timeline posts is about 60 KB of JSON. At 19200 baud, about 2 KB a second, that's half a minute. Asking for gzip brings it to about 9 KB, around 6 seconds, which makes a gzip decoder (inflate.c, under 400 lines) the most valuable code in the project. Ask for small pages, show posts as they're parsed rather than at the end, and cap what you download: velo-bluesky stops a thread at 15 replies or 64 KB.
Parsing
Parse JSON into a tree in an arena, take the few fields you show into your structs, and free the tree. Don't keep the JSON. A small recursive-descent parser is enough, but limit its depth and check every length: the input comes from the network.
Pictures
velo-emu's proxy already turns pictures into small four-grey GIFs. Decode them into a CreateDIBSection bitmap at the screen's bit depth, a few rows at a time as data arrives, and draw them with BitBlt. Load pictures only for the post being read, and drop them when the user moves on.
Text
Posts are UTF-8 with emoji and punctuation the Velo can't draw. Convert to UTF-16, then replace or drop what the fonts don't have. Times arrive as ISO 8601 UTC strings: show them relative ("2h") or in local time.
Responsiveness
Everything slow is on the worker. The interface shows what it has, says what it's waiting for in the status bar, and stays usable. Every job can be cancelled, and results for a job the user has moved away from are ignored.
Try It
In a velo-bluesky checkout:
export VELO_TOOLCHAIN=~/Development/velo-toolchain
make
make cards
make cards builds a PC Card image for each CE version. Its README.md covers the fake server, the command line and testing.