The Velo CE Primer

Chapter 7Project: A Bluesky Client

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.

velo-bluesky on CE 1.0: a timeline of posts on the left, a post with a dithered picture on the right, a feed drop-down on the command bar
CE 1.0
velo-bluesky on CE 2.0
CE 2.0
velo-bluesky against its fake test server. Recipe 1's layout, with custom-drawn panes and a picture from the proxy.

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.social works as the entry point.
XRPC
How calls are made: GET for queries and POST for procedures, to /xrpc/ followed by the method's name, such as app.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

CallMethodDoes
com.atproto.server.createSessionPOSTSign in.
com.atproto.server.refreshSessionPOSTNew tokens, with the refresh token.
com.atproto.server.deleteSessionPOSTSign out, with the refresh token.
app.bsky.feed.getTimeline?limit=10&cursor=...GETThe Following timeline, a page at a time.
app.bsky.feed.getFeed?feed=at://...GETA custom feed.
app.bsky.feed.getListFeed?list=at://...GETA list's posts.
app.bsky.actor.getPreferencesGETThe user's saved and pinned feeds, among other things.
app.bsky.feed.getPostThread?uri=at://...GETA post with its parents and replies.
com.atproto.repo.createRecordPOSTPost, reply or like.
com.atproto.repo.deleteRecordPOSTDelete 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:

LayerFilesChapter
Frame: command bar, status bar, panes, message loopmain.c4
What each command and each finished job doesapp.c3
Custom-drawn timeline, post and picture viewsfeed_view.c, post_view.c, picture_view.c, draw.c4
Sign In, Compose, Settings and About dialogsdialogs.c, bluesky.rc4
Network thread and job queueworker.c6
XRPC calls, sessions, token refreshatproto.c7
JSON to posts: only the fields shownlexicon.c, model.h7
HTTP/1.0 through the proxyhttp.c6
JSON, gzip and GIF decoders, pictures in 2 or 4 bits per pixeljson.c, inflate.c, gif.c, picture.c7
Strings, UTF-8, times, arenas, registrytext.c, memory.c, settings.c5

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.