NeoDB Sky Bridge relays public AT Protocol records (e.g. popfeed and BookHive) into the Fediverse as NeoDB-compatible ActivityPub activities.
Any AT Protocol user may opt out by themselves (verified via atproto OAuth).
Skybridge is an ActivityPub server: any Fediverse (or NeoDB) account can
follow a bridged user directly at https://SKYBRIDGE_DOMAIN/users/{handle}
and receive their activities as regular followers. It additionally publishes
every public post through external relays admin configures with
SKYBRIDGE_RELAYS, so peers connected to same relay see bridged content without
following individually.
Jetstream v2 (atproto firehose)β
live tail / archive replay β
or replayed fixtures β ββββββββββββββ translate ββββββββββββββ
βΌ β popfeed record β NeoDB AP object β
filter popfeed collections ββββΊβ (one Note per author+work, with β
resolve DID β bridged Person β β Status/Rating/Comment relatedWith β
(mint RSA keypair on sight) β β the work) in Create/Update/Delete β
β βββββββββββββββββββ¬ββββββββββββββββββββ
βΌ βΌ
persist to SQLite enqueue delivery
(archive + dedup) ββ fanout: author-signed activity β
configured relays + followers
(signed HTTP, retry/backoff)
The activity object is a Mastodon-compatible Note so generic servers render
it. NeoDB catalog semantics ride alongside, matching NeoDB's wire format
(verified against its takahe/ap_handlers.py + catalog/sites/fedi.py
ingest code):
tagcarries exactly one typed catalog ref (Movie/TVShow/TVSeason/Edition/Game/Album/Podcast) withhref/name/imageβ this is how a NeoDB peer locates the work; posts without one are dropped.relatedWithholds the mark facets βStatus(shelf mark),Rating,Commentβ each with the requiredid/href/attributedTo/published/updatedenvelope and awithRegardTolink to the catalog item.- The catalog item at
https://<domain>/catalog/<type>/<id>is served in NeoDB's ItemSchema shape (type= catalog type,id= the URL itself,display_title,cover_image_url) withexternal_resources(imdb / tmdb / igdb-slug / steam / musicbrainz URLs) plus top-levelimdb/isbn, so peers merge it with items they already know instead of minting duplicates.
Deletes emit a Delete referencing a Tombstone. We emit Notes only β
never Article/titled Review objects.
| popfeed record | becomes |
|---|---|
social.popfeed.feed.review |
Note + Rating (0-10) and, when there is review text, an untitled Comment withRegardTo the work (never a titled Review/Article); containsSpoilers sets sensitive + a CW summary |
social.popfeed.feed.list |
archived only (no AP emission): stored for listUri resolution and future NeoDB Collection mapping |
social.popfeed.feed.listItem |
on a shelf-type list: Note + a Status mark (status derived from listType, including compound types like watched_movies) withRegardTo the work β folded into the review's Note when the same author reviewed the same work (see below). On a status-less list: archived only (collection membership is not bridged) |
social.popfeed.actor.profile |
no Note: refreshes the bridged actor's display name/avatar and emits an Update(Person) directly to that author's followers (never relayed as an Announce); not archived β it's identity metadata, not content |
Jetstream identity events (handle changes) take the same path: the actor's
handle is updated and an Update(Person) goes to its followers. Neither an
identity event nor a profile edit ever mints an actor β we bridge people
because of what they post.
Because a handle is also the actor URL (/users/<handle>), a rename would
strand every id already federated. Two rules keep them alive:
- The retired handle is kept as an alias.
/users/<old>(plus its inbox, outbox, followers and WebFinger record) resolves to the same account and301s to the live name; already-published post URLs under it keep dereferencing. - Object ids are never recomputed from the current handle. An
UpdateorDeletenames the id peers actually received, whatever handle minted it.
handle.invalid (reported when a handle stops resolving back to its DID) is
not treated as a rename: every account in that state reports the same value,
so the actor keeps the last name we know it by until a real one arrives.
A handle points at one DID at a time, so when a name moves to another account
the previous holder is pushed onto its synthetic <did-tail>.did handle.
Leaving two rows on one name would let one account's URL, WebFinger record and
HTTP signature key_id resolve to the other's.
Known limit: a renamed actor is not announced with a Move, so remote servers
keep following the old id (which still works) instead of migrating to the new
one.
Bluesky publishes two "speech is not reach" toggles that a bridge is expected to honour. Both are read off the atproto account and never set on this side.
Hide from algorithmic recommendations. The
app.bsky.actor.contentVisibilityDeclaration record (rkey self, field
hideFromAlgorithmicRecommendations; a missing record means false, per the
lexicon). It becomes discoverable: false on the bridged Person β which on
Mastodon drops the account from the profile directory, from follow
recommendations and from trends, and on NeoDB also withholds consent to being
featured in someone's collection (FEP-7aa9). Watched on Jetstream, so a toggle
publishes an Update(Person) to that author's followers straight away.
The flag is written under both discoverable and toot:discoverable,
because the two receivers read it in incompatible ways: Mastodon never compacts
a fetched actor and reads the raw key, while NeoDB/takahe compacts against the
document's own context and then reads toot:discoverable. Our @context
declares the toot prefix but not the discoverable alias, which is what
keeps the two keys from folding into one array. Nothing is emitted when the
preference is unset: both receivers already default to permissive, and
volunteering discoverable: true would opt every bridged author into
directories nobody asked for.
Hide from logged-out users. The !no-unauthenticated self-label on the
app.bsky.actor.profile record. ActivityPub cannot say "signed-in readers
only" while staying publicly federated, so this is honoured as far as it can
be, and no further:
- Our own web pages carry
noindex, nofollow, drop their link-preview tags and their schema.org JSON-LD, and show identity only β no post list on the profile, no source record on the post page./archive, its detail pages and the catalog item listings hide the author's records entirely. The one thing that still counts them is a catalog item's rating average, which names and quotes nobody (see Endpoints). The AP representation at the same URLs is untouched β a peer that follows the author is exactly the audience the label still allows. - Posts are addressed unlisted (
to: [followers],cc: [as:Public]), which keeps them out of the public, local, federated and hashtag timelines and out of trends. Delivery is unaffected: neodb-relay redistributes ontoorcc, and takahe files acc-public post as unlisted rather than dropping it. - The actor also gets
indexable: false.
Not honoured: a permalink on a Mastodon-family peer stays readable by anyone,
signed in or not. Note that NeoDB peers already hide every remote author's
marks from logged-out visitors, since they create remote identities with
anonymous_viewable=False.
This label is not watched on Jetstream (it rides on app.bsky.actor.profile,
which we deliberately do not tail). It is read when an actor is minted and
re-read whenever the actor is refreshed, so a toggle lands on the next refresh
rather than at once. Neither preference is ever cleared by a refresh β a
failed fetch and a preference turned off both arrive as an empty record, and
the two must not be confused when one of them means "publish this person more
widely again". Clearing has exact signals of its own: the declaration's own
Jetstream commit (a delete and a false both mean false), and a profile record
that comes back without the label.
Signing in resyncs the account. Completing the atproto OAuth flow on
/manage proves control of the DID, so the callback re-reads the whole account
from the network before showing the status page: handle, display name, avatar
and both preferences. Anything that moved is published to the author's
followers as an Update(Person), and the status card shows which preferences
are currently being carried.
This is the one path allowed to turn a preference off, because the account
holder is present and expects the settings they hold right now to apply. It
still refuses to confuse an absent record with a failed fetch, so each flag is
lowered only when the read it came from answered β its own read, not a
sibling's. The three reads are three separate requests, and one succeeding
says nothing about another that timed out. atproto reports a missing record as
400 {"error": "RecordNotFound"}, which is an answer; nothing coming back at
all is not. Two things it will never do β mint an actor for a DID we do not bridge
(signing in is not activity), and touch an account that opted out (its records
were retracted, so nobody should hear about its actor again). A handle is
applied only when the DID document actually resolved one, so an unreachable
PLC cannot rename a live actor onto its <did-tail>.did placeholder.
One popfeed action ("watched + rated") writes a review and a listItem; the
bridge emits ONE AP Note per (author, work) carrying Status + Rating +
Comment together. The Note id is anchored on whichever record publishes
first (/users/<handle>/posts/<rkey> β rkeys are immutable, unlike work
identifiers); any later change to either record re-derives the combined Note
and sends an Update with the same id (rewatches included). Deleting the
anchoring record Deletes the Note (the surviving partner re-publishes under
its own rkey on its next event); deleting the partner just re-derives the
Note.
listType verbs map to the NeoDB shelf statuses wishlist / progress /
complete / dropped (do / doing / done / dropped per media type: watch, play,
read, listen); unrecognized listTypes fall back to plain list membership.
creativeWorkType maps to NeoDB categories: movieβmovie, tv_show/
tv_seasonβtv, video_gameβgame, bookβbook, music/album/epβmusic,
podcastβpodcast (anything else falls back to item).
Works are deduplicated across records by any shared external identifier
(imdb/tmdb/igdb/steam/isbn/musicbrainz), so a review and a listItem carrying
different identifier subsets point at the same catalog entry.
Known but not bridged:
social.popfeed.feed.post(legacy)social.popfeed.feed.reaction(emoji reactions; maybe laterLike/EmojiReactin AP)- per-episode
watchedEpisodesarray on tv listItems. app.bsky.actor.profileon Jetstream (deliberately not watched β that would stream every profile edit network-wide); it's instead re-fetched whenever asocial.popfeed.actor.profileevent arrives, both as a name/avatar fallback and to re-read the!no-unauthenticatedlabel.
app.bsky.actor.contentVisibilityDeclaration is watched network-wide (see
Visibility preferences above): unlike a profile edit it is a rare, low-volume
record, and like one it only ever updates an actor we already bridge.
uv run python -m skybridge discover keeps this list honest: it subscribes to
social.popfeed.* / buzz.bookhive.* (Jetstream v2 accepts namespace
wildcards) and reports every collection seen, flagging the ones we don't
bridge. Ingestion itself still asks for the explicit list β a wildcard
subscription would also pull in buzz.bookhive.catalogBook, whose records
carry multi-KB author biographies we have no use for.
BookHive is a separate atproto app β
a decentralized Goodreads. Unlike popfeed, which splits a user action across a
review and a listItem, one BookHive action lives in a single
buzz.bookhive.book record that already carries the shelf status, star rating
and review together, so it bridges to ONE AP Note through the same
non-paired path (no pair-merging). The work type is book (AP Edition), so a
BookHive book and a popfeed book that share an ISBN merge into one catalog
entry.
| BookHive record | becomes |
|---|---|
buzz.bookhive.book |
a single Note carrying, as available: a Status mark (status β wishlist / progress / complete / dropped, from wantToRead / reading / finished / abandoned), a Rating (stars, 1-10), and β when there is review text β an untitled Comment withRegardTo the work. A status-only shelf-add (no stars/review) still bridges, leading with a reading verb ("Wants to read", "Reading", β¦) |
Book identity comes from the record's identifiers (isbn13 β isbn10 β
goodreadsId β hiveId; hiveId is always present, so a work always mints). The
catalog item exposes the isbn and a Goodreads external_resource so NeoDB
peers merge it with editions they already know.
Known but not bridged:
buzz.bookhive.buzz(comments/replies on a book; like popfeed reactions)buzz.bookhive.hiveBook/buzz.bookhive.catalogBook(the app's own catalog entries, not user activity)- the
coverblob (a PDS blob, not a URL): no poster is derived yet, so the Note relies on the catalog-item tag for imagery
Jetstream v2 reports atproto account state, which the bridge acts on. Only a
deleted account is permanent: everything bridged from it is retracted, the
same way an opt-out is, but without recording an opt-out β the DID is gone,
not making a standing choice, and a stored opt-out would wrongly suppress it
if it ever returned.
Every other inactive status (deactivated, suspended, takendown) is
treated as reversible: ingestion stops for that account and nothing is
retracted, so someone who deactivates for a week and comes back finds their
federated history intact. Ingestion resumes when the account goes active
again.
The live socket only reaches back a bounded window (36 hours on Bluesky's
instances). Jetstream v2 additionally serves its whole archive over HTTP, so
history can be imported without crawling every PDS. This is what
SKYBRIDGE_JETSTREAM_API_KEY is for β nothing else needs it.
An import is designed to run on a live server:
- It cannot overwrite newer data. Each record keeps the highest
seqapplied to it, and an import is bounded above by the live ingest cursor, so an archived event can never regress a record the live tail has moved past. The same mark makes Jetstream's at-least-once redelivery idempotent. - It honours opt-out. Opted-out DIDs are skipped, and because the import runs inside the server process an opt-out mid-import can cancel it. An out-of-process import could not be stopped.
- It does not deliver by default. Replaying history would flood every
subscriber with
Createactivities, so imported records are archived silently unless delivery is explicitly requested. - It is resumable. Progress is persisted per segment, so a metering
429or a restart continues rather than re-downloading.
Cost is dominated by how much of the archive gets fetched, and two things keep that down for a rare collection:
- The planner works from bloom filters, so it over-selects. Where it asks for a
whole segment (
mode: "segment"), the importer instead Range-fetches the file header and the segment's collection index, which names the blocks holding each NSID, and downloads only those β orders of magnitude less than the whole file. A segment whose index can't be read falls back to the full download. - Blocks are fetched with bounded concurrency and applied in index order, since the run is a chain of round trips rather than a bandwidth problem.
Run an estimate first, and read it carefully: the plan's planner_entries
counts the planner's own work units, not records, and understates the
records recovered by a wide margin. estimated_bytes is the number to judge.
Start, watch and cancel an import from the admin panel on /manage (visible
to accounts listed in SKYBRIDGE_ADMINS after signing in), or queue one from
the CLI with python -m skybridge import.
| Env var | Default | Meaning |
|---|---|---|
SKYBRIDGE_DOMAIN |
localhost:8000 |
Public host of this relay (the single source of identity) |
SKYBRIDGE_SCHEME |
https (http for localhost) |
URL scheme |
SKYBRIDGE_DATA |
./data |
Folder for all mutable state (skybridge.db, relay_key.pem); under compose it is the host folder bind-mounted to the container's /data |
SKYBRIDGE_PORT |
8000 |
Host port docker compose publishes the server on (compose-only) |
SKYBRIDGE_JETSTREAM |
public Jetstream v2 us-east | Jetstream WebSocket endpoint; a v1 endpoint still works, but import/discovery are v2-only |
SKYBRIDGE_JETSTREAM_API_KEY |
unset | Jetstream v2 archive key, for importing history. Not needed for normal operation β the live socket takes no key |
SKYBRIDGE_ADMINS |
unset | Comma/space-separated DIDs and/or handles that get the admin panel on /manage. Prefer DIDs: handles are transferable |
SKYBRIDGE_RELAY_KEY |
required | Service actor private key (PEM); alternatively place a PEM at $SKYBRIDGE_DATA/relay_key.pem |
SKYBRIDGE_RELAYS |
unset | Comma/space-separated relay inbox URLs to publish through (Mastodon-style); empty = pure normal-server mode |
The service actor signs outbound activities with an RSA key that you must
provide β either as SKYBRIDGE_RELAY_KEY in .env (compose supports
quoted multi-line values) or as a PEM file at $SKYBRIDGE_DATA/relay_key.pem.
Startup fails if neither is present. To generate one:
printf 'SKYBRIDGE_RELAY_KEY="%s"\n' \
"$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048)" >> .envKeep the key safe and back it up β losing it changes the server's ActivityPub
identity, and peers that cached the old public key will reject signatures
until they re-fetch the actor.
| SKYBRIDGE_INGEST | unset | set to 1 to start live ingestion inside serve |
| SKYBRIDGE_BACKFILL_LIMIT | 1000 | max records fetched per user-triggered "Import recent activity" run (total; reviews and shelf items are budgeted before archive-only lists) |
| SKYBRIDGE_BACKFILL_DAYS | 7 | only records written within the last N days (by TID rkey, falling back to createdAt) are re-published by an import |
| SKYBRIDGE_LOG | INFO | log level |
| SKYBRIDGE_SENTRY_DSN | unset | optional; enables Sentry error reporting and a atproto.record_ingested counter metric with collection/operation attributes |
Requires uv.
uv sync # create .venv and install runtime + dev deps from uv.lockThe CLI has six subcommands (run them via uv run):
# Serve the ActivityPub endpoints + dashboard (set SKYBRIDGE_INGEST=1 to also
# stream live popfeed activity in the same process).
SKYBRIDGE_DOMAIN=bridge.example.social uv run python -m skybridge serve --port 8000
# Stream live popfeed activity from Jetstream (Ctrl-C to stop; --limit N to bound).
SKYBRIDGE_DOMAIN=bridge.example.social uv run python -m skybridge ingest
# Seed from a single DID's existing popfeed records (--days N to only replay
# recent ones, --limit to cap the fetch; default SKYBRIDGE_BACKFILL_LIMIT).
# Signed-in users can trigger the same thing from /manage via the "Import
# recent activity" button. Avoid running with --deliver while users may be
# opting out live: opt-out can only cancel imports inside the server process.
uv run python -m skybridge backfill did:plc:i6k6scfcdaup4e2va33nkprb
# Replay a captured JSONL fixture through the full pipeline (offline).
uv run python -m skybridge replay fixtures/jetstream_sample.jsonl --reset
# Survey which collections are being published under the bridged namespaces.
uv run python -m skybridge discover --seconds 120
# Import history from the Jetstream v2 archive. ALWAYS estimate first β the
# archive is billed by bytes downloaded, and a rare collection plans far more
# of it than the matching-record count suggests.
uv run python -m skybridge import --dry-run
uv run python -m skybridge import # queues it; the server runs itcp .env.example .env # set SKYBRIDGE_DOMAIN (+ SKYBRIDGE_INGEST=1 to go live)
printf 'SKYBRIDGE_RELAY_KEY="%s"\n' \
"$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048)" >> .env
docker compose up -d # serves on :8000; state lives in ./data (bind mount)The container runs as uid 1000; on Linux make sure the data folder is
writable by it (mkdir -p data && chown 1000 data).
Every push to main runs the checks and publishes multi-arch
(amd64/arm64) images to Docker Hub as neodb/skybridge (latest + commit sha tags)
- Discovery:
/.well-known/webfinger,/.well-known/nodeinfo,/nodeinfo/2.1 - Service actor:
GET /actor, shared inboxPOST /inbox,POST /actor/inboxβ follow bridged users directly instead; a Follow of the service actor now gets aReject(see relays above for the Mastodon-style alternative) - Bridged actors:
GET /users/{handle}(+/inbox/outbox/followers) β inboundLike/Undo(Like)on a local post is stored and forwarded to configured relays - Objects:
GET /users/{handle}/posts/{rkey}(Note / Tombstone),GET /catalog/{type}/{id}(catalog work) - UI / stats:
GET /(dashboard),GET /archive,GET /archive/{at_uri}(original record vs. translated AP side-by-side),GET /catalog,GET /stats
Each of these URLs serves ActivityPub to a peer (Accept: application/activity+json)
and HTML to a browser. The HTML views cross-link: the profile and catalog item
pages list the 100 most recently bridged posts, /archive links each published
row to its post page, and a post page names both its ActivityPub id and the
at:// record behind it.
The HTML also carries schema.org JSON-LD, saying what the ActivityPub document
at the same URL says in NeoDB's vocabulary, in the one search engines and
unfurlers read. A post carrying a rating or review text embeds a Review; a
catalog item embeds the work itself (Movie / Book / VideoGame / β¦, with
its identifier URLs as sameAs), plus the reviews it lists and an
aggregateRating, which is also shown on the page. Spoiler-marked review text
is left out of both, as it is out of the link-preview tags.
The average counts every rating bridged for the item, including from
authors carrying !no-unauthenticated, whose posts are not listed on the page
and whose reviews are not embedded. The label hides an author's posts, not the
existence of a score: an average names nobody and quotes nobody. A retracted
record does drop out of it β an opt-out tombstones the data rather than hiding
it.
- Manage (self-service opt-out / import):
GET /manage(sign-in form; the account view once signed in),POST /manage(starts the sign-in),GET /oauth/client-metadata.json,GET /oauth/callback(opens the session), thenPOST /manage/opt-out,POST /manage/opt-in,POST /manage/import,POST /manage/signoutfrom the account view β users prove control of their account via AT Protocol OAuth against their own authorization server (PAR + PKCE + DPoP); no passwords ever touch the relay, tokens are discarded right after the identity check, and the signed-in session is a short-lived in-memory cookie. "Import recent activity" backfills the account's existing records (capped atSKYBRIDGE_BACKFILL_LIMIT, re-publishing only the lastSKYBRIDGE_BACKFILL_DAYSdays) in the background, one import per account at a time, disabled while opted out. Known limit: content re-imported after an opt-out -> opt-in round trip is re-published under its original object ids, which peers that cache tombstones may reject
make sure CI is clean before commit
uv run ruff check .
uv run ruff format .
uv run ty check
uv run pytest