Jetstream SDK
Jetstream is plain JSON over a WebSocket, so you never need an SDK; some of our examples use CLI tools like websocat. But you'll tend to write the same glue each time: filtering,
reconnecting, deduping, and decoding events into typed records. For that, we
provide a Jetstream SDK in TypeScript and in Go. You construct a client,
pass a filter, and loop over decoded events; it handles the rest.
The SDKs provide a single client for all three ways to consume Jetstream, behind the same loop: the live tail, replay, and snapshotting.
Install
- TypeScript
- Go
npm install @bsky/jetstream
The TypeScript SDK targets Node 22.15+, is ESM-only, and runs in the browser too. On
current Node the TypeScript examples below run as-is (node index.ts),
with no build step.
go get github.com/bluesky-social/jetstream
The Go SDK needs Go 1.26+, and lives in the same module as the Jetstream server itself.
Quickstart: the live tail
Construct a Jetstream client pointed at an instance, then read events with a server-side collection filter. Each value is a decoded event; the record is already parsed, so there's no JSON step of your own:
- TypeScript
- Go
import { Jetstream } from '@bsky/jetstream'
const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')
for await (const evt of jetstream.live({ collections: ['app.bsky.feed.post'] })) {
if (evt.kind === 'commit' && evt.commit.operation === 'create') {
console.log(evt.commit.collection, evt.commit.record)
}
}
package main
import (
"context"
"fmt"
"github.com/bluesky-social/jetstream"
)
func main() {
client, err := jetstream.Subscribe(
"jetstream.us-east.bsky.network",
jetstream.WithCollection("app.bsky.feed.post"),
)
if err != nil {
panic(err)
}
defer client.Close()
for batch, err := range client.Events(context.Background()) {
if err != nil {
continue
}
for _, evt := range batch.Events() {
if evt.Kind == jetstream.KindCommit && evt.Commit.Operation == jetstream.OpCreate {
fmt.Println(evt.Commit.Collection, evt.Commit.Record)
}
}
}
}
Go delivers events in batches rather than one at a time.
Filtering
Both clients take the same two server-side filters as the raw endpoint, so a narrow filter means less bandwidth and less work in your process:
- collections: NSIDs, or a
app.bsky.feed.*-style namespace wildcard. The server routes events by collection before they reach you. - DIDs: restrict the stream to specific accounts.
- TypeScript
- Go
for await (const evt of jetstream.live({
collections: ['app.bsky.feed.post', 'app.bsky.feed.like'],
dids: ['did:plc:ragtjsm2j2vknwkz3zp4oxrd'],
})) {
// ...only posts and likes, only from that account
}
TypeScript also takes a kinds filter, to get a
commits-only stream:
jetstream.live({ collections: ['app.bsky.feed.post'], kinds: ['commit'] })
client, err := jetstream.Subscribe(
"jetstream.us-east.bsky.network",
jetstream.WithCollections([]string{"app.bsky.feed.post", "app.bsky.feed.like"}),
jetstream.WithDIDs([]string{"did:plc:ragtjsm2j2vknwkz3zp4oxrd"}),
)
// ...only posts and likes, only from that account
A collection filter constrains commits only: identity, account, and
sync events bypass it and arrive regardless (a DID filter still applies to
them). Those events are how a consumer learns an account was
deleted and its records should go with it. See folding the
stream.
Reacting to events
Every event carries a DID, a seq (the monotonically increasing cursor), a
timestamp, and a kind of commit, identity, account, or sync. Exactly one
payload is populated, selected by the kind:
- TypeScript
- Go
for await (const evt of jetstream.live({ collections: ['app.bsky.feed.post'] })) {
switch (evt.kind) {
case 'commit':
if (evt.commit.operation === 'delete') {
console.log('deleted', evt.commit.rkey)
} else {
console.log('put', evt.commit.rkey, evt.commit.record)
}
break
case 'identity':
console.log('handle change', evt.did, evt.identity.handle)
break
...
}
}
You can combine the Jetstream SDK with Lexicons from the Bluesky SDK to validate and type records:
import { Jetstream } from '@bsky/jetstream'
import { app } from '@bsky/sdk/lexicons'
const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')
for await (const evt of jetstream.live({ collections: [app.bsky.feed.post] })) {
if (evt.kind === 'commit' && evt.commit.operation === 'create') {
console.log(evt.commit.collection, evt.commit.record.text)
}
}
for batch, err := range client.Events(ctx) {
if err != nil {
continue
}
for _, evt := range batch.Events() {
switch evt.Kind {
case jetstream.KindCommit:
if evt.Commit.Operation == jetstream.OpDelete {
fmt.Println("deleted", evt.Commit.Rkey)
} else {
fmt.Println("put", evt.Commit.Rkey, evt.Commit.Record)
}
case jetstream.KindIdentity:
fmt.Println("handle change", evt.DID, evt.Identity.Handle)
case jetstream.KindAccount:
fmt.Println("account status", evt.DID, evt.Account.Active)
case jetstream.KindSync:
fmt.Println("repo resync", evt.DID, evt.Sync.Rev)
}
}
}
Stopping and shutdown
- TypeScript
- Go
Breaking out of the loop closes the socket for you. For shutdown driven from
outside the loop, pass an AbortSignal. Aborting technically rejects the
loop with an AbortError, so catch it if a signal is your normal exit path:
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
try {
for await (const evt of jetstream.live({
collections: ['app.bsky.feed.post'],
signal: controller.signal,
})) {
// ...
}
} catch (err) {
if (controller.signal.aborted) return // expected: we asked it to stop
throw err
}