Graze Turbostream
Turbostream is a live stream of new posts on Bluesky. It's built on Bluesky's Jetstream, but each post arrives with its context already filled in: the author's profile, the profiles of anyone mentioned, and the posts it replies to or quotes.
A raw Jetstream event only gives you identifiers (DIDs and AT-URIs). To make sense of it you'd normally look each one up through the Bluesky API, and cache the results. Turbostream does that work for you, so you can read the stream and use it straight away.
Connecting
There's a single WebSocket endpoint and no setup:
wss://api.graze.social/app/api/v1/turbostream/turbostream
From the command line:
websocat "wss://api.graze.social/app/api/v1/turbostream/turbostream"From JavaScript:
const ws = new WebSocket("wss://api.graze.social/app/api/v1/turbostream/turbostream");
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.pong) return; // heartbeat
console.log(msg.at_uri, msg.hydrated_metadata.user?.handle);
};What you receive
Each WebSocket message is one JSON object: a post, or a heartbeat.
Posts
{
"at_uri": "at://did:plc:qipvtyo27owt4isjah3j3dw2/app.bsky.feed.post/3lng7e4mr5c2z",
"did": "did:plc:qipvtyo27owt4isjah3j3dw2",
"time_us": 1745342977546824,
"message": { "did": "…", "time_us": 1745342977546824, "kind": "commit", "commit": { "…": "…" } },
"hydrated_metadata": {
"user": { "handle": "…", "displayName": "…", "followersCount": 0 },
"mentions": {},
"parent_post": null,
"reply_post": null,
"quote_post": null
},
"_id": "1745342977612-0"
}at_uri: the post's address.did: the author's DID.time_us: when Jetstream received the post, in microseconds.message: the original Jetstream event, unchanged. The post itself is inmessage.commit.record.hydrated_metadata: the context Graze filled in (see below)._id: this message's position in the stream.
What's filled in
user: the author's full profile, including handle, display name, avatar, bio and follower counts.mentions: everyone mentioned in the post, as a map from their DID to their profile.parent_post: the post being replied to, if the post is a reply.reply_post: the first post of the thread, if the post is a reply.quote_post: the quoted post, if the post quotes one.
Any of these is null (or, for mentions, empty) when it doesn't apply or couldn't be fetched in time. Always check before using them.
Replays and heartbeats
- On connect, you first receive the 25 most recent posts, each marked
"_replay": true. Then live posts follow. - Every 10 seconds, you receive
{"pong": true}. Use it to check the connection is alive.
You may see other fields in a message. Ignore any you don't recognise.
No resume
Turbostream has no cursor. If you disconnect, any posts published while you were away are not sent when you reconnect; you only get the latest 25 again, as a replay. If you need every post, keep the connection healthy and reconnect quickly.
Privacy
Accounts that have asked to be left out of Graze don't appear in Turbostream. Their posts aren't sent, and they're left out of mentions, parent_post, reply_post and quote_post on other people's posts.
Tips for your client
- Run a watchdog. If nothing (not even a heartbeat) arrives for about 25 seconds, close the connection and reconnect. A connection can go silent without closing.
- Reconnect with backoff. If reconnecting fails, wait a little longer before each try.
- Skip or handle replays. If you count or compare posts, filter out
_replaymessages first, or you'll double-count the 25 you already saw. - Keep up. Turbostream carries a lot of posts. Do slow work (database writes, API calls) off the receive loop, so you don't fall behind.
- Read time from the event. If
time_usat the top level isnull, usemessage.time_us.
Looking for only some posts?
Turbostream sends every new post. If you only want posts that match a topic, build a feed in Graze and use Contrails to stream just its matches.