> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://help.graze.social/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# 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

1. **Build a feed** in Graze. The feed's Rules decide which posts reach your stream.
2. **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.
3. **Find the feed's address.** Either its Bluesky link (for example `https://bsky.app/profile/example.com/feed/news`) or its AT-URI (for example `at://example.com/app.bsky.feed.generator/news`) works.
4. **Connect** to `wss://api.graze.social/app/contrail?feed=<feed address>`.

You can try it from the command line with `websocat`:

```sh
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`, `kind` and `commit`), plus an `_id` field. Save the latest `_id` you'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, or `400` if 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 `_id` from a message, such as `1760000000000-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

```js
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](https://discord.com/invite/n936Bz7Pug) or [@graze.social](https://bsky.app/profile/graze.social) on Bluesky.