Contrails
Contrails gives developers a live stream of the posts that match a Graze feed. Instead of reading the whole Bluesky firehose and filtering it yourself, you build the filter as a feed in Graze's editor and receive only the posts that pass it, as they arrive.
It's a good fit for live dashboards, bots that react to a narrow slice of Bluesky, and moderation or analytics tools watching one topic.
Getting started
- Build a feed in Graze. The feed's Rules decide which posts reach your stream.
- Turn on Contrails. Open the feed's Settings and switch on Contrails under Feed Settings. Without this, you can still connect, but you will receive no posts.
- Find the feed's address. Either its Bluesky link (for example
https://bsky.app/profile/example.com/feed/news) or its AT-URI (for exampleat://example.com/app.bsky.feed.generator/news) works. - Connect to
wss://api.graze.social/app/contrail?feed=<feed address>.
You can try it from the command line with websocat:
websocat "wss://api.graze.social/app/contrail?feed=at://example.com/app.bsky.feed.generator/news"What you receive
Every message is a JSON object. There are five kinds:
- A matching post. The post's event exactly as Bluesky's Jetstream delivers it (with
did,time_us,kindandcommit), plus an_idfield. Save the latest_idyou've handled: it's your place in the stream. - A replayed post. The same, plus
"_replay": true. When you connect without a cursor, you first get the 25 most recent matches. On a quiet feed these can be days old, so don't treat them as new. - A heartbeat.
{"pong": true}, sent every 10 seconds. - A gap notice.
{"_gap": true, "requested_since": …, "oldest_retained": …, "stream": …}. You asked to resume from a point that is no longer kept, and the matches between those two ids are gone. - An error.
{"error": "…", "status_code": 404}if the feed can't be found, or400if your cursor isn't valid. The connection then closes.
Ignore any other fields starting with _ that you don't recognise.
Resuming where you left off
Add since to pick up after a disconnect:
wss://api.graze.social/app/contrail?feed=<feed address>&since=<last _id>
With since, there is no replay: you get every match after that point, then live posts. since accepts:
- an
_idfrom a message, such as1760000000000-0 - a time in milliseconds since 1970, such as
1760000000000 $, meaning "only new posts from now on"
Anything else is rejected with a 400 error rather than quietly ignored.
Only a short history is kept for each feed: about 3,000 matches. For a very busy feed that can be as little as 30 seconds, so reconnect quickly. If you reconnect too late, you'll get a gap notice.
Keep a watchdog running
Connections do drop, and they don't always tell you. Sometimes you get a close message, sometimes the connection just breaks, and occasionally it goes silent while still looking open.
The heartbeat is how you notice. If nothing at all arrives for about 25 seconds, close the connection and reconnect with `since` set to your last `_id`. Any message, including a heartbeat, counts.
Example in JavaScript
const FEED = "at://example.com/app.bsky.feed.generator/news";
let cursor = null;
let watchdog;
function connect() {
const params = new URLSearchParams({ feed: FEED });
if (cursor) params.set("since", cursor);
const ws = new WebSocket(`wss://api.graze.social/app/contrail?${params}`);
const resetWatchdog = () => {
clearTimeout(watchdog);
watchdog = setTimeout(() => ws.close(), 25000);
};
ws.onopen = resetWatchdog;
ws.onclose = () => {
clearTimeout(watchdog);
setTimeout(connect, 1000);
};
ws.onmessage = (event) => {
resetWatchdog();
const msg = JSON.parse(event.data);
if (msg.pong) return;
if (msg._gap) { console.warn("Some matches were missed", msg); return; }
if (msg.error) { console.error(msg); return; }
cursor = msg._id;
if (msg._replay) return; // older match sent on connect
handlePost(msg);
};
}
function handlePost(post) {
console.log(post);
}
connect();Good to know
- An open stream keeps your feed active. Graze pauses feeds that nobody has read for a day. An open Contrails connection counts as reading, so a quiet feed wakes up again, though it can take a few minutes before matches start to flow.
- What you see is what your feed matches. Every post has already passed your feed's Rules. Change the Rules in Graze and the stream follows.
Feedback
If you hit a problem or have an idea, tell us on Discord or @graze.social on Bluesky.