Introduction
troposphere is the AT Protocol arm of Coop, ROOST’s
open-source trust & safety platform. Together they are a full alternative to
Ozone: troposphere ingests AT Protocol content
into Coop, signs and serves moderation labels (the standard subscribeLabels firehose and
queryLabels endpoint), routes user reports into Coop’s review queue, and enforces takedowns
on your PDS.
Ozone bundles two things: a moderation console with its own data model, and the plumbing that
signs and publishes labels. Coop is the moderation tool, where you set up your own policies and
actions and where your reviewers work; troposphere is the atproto plumbing it uses. It works
for any AT Protocol project regardless of its lexicons, PDS, or AppView.
It is built on the maintained @atproto/* primitives (@atproto/crypto,
@atproto/xrpc-server, @atproto/common). The label and enforcement endpoints are plain
authenticated HTTP, so non-Coop tooling can use them too, but Coop is what troposphere is
designed for.
About this project. troposphere and this documentation were generated by Claude Code, Anthropic’s agentic coding tool. Spot something wrong or unclear? Reach out to @julietshen.bsky.social on Bluesky, or open a pull request.
Who this is for
- A project on the AT Protocol that wants trust & safety on Coop: content ingested into a review queue, labels published under its own signing key, reports triaged, takedowns enforced.
- Any such project regardless of its stack: custom lexicons, its own PDS, its own AppView. See Building on the AT Protocol.
- Teams evaluating an alternative to running Ozone.
Coop is the moderation tool troposphere is built for. If you are not using Coop, the label and enforcement endpoints are plain authenticated HTTP, so other tooling can use them too.
What it does today
- Ingests AT Protocol content (Jetstream) into Coop’s item intake (
npm run ingest). - Signs labels with a secp256k1 key, verifiable against the labeler’s DID.
- Serves
com.atproto.label.queryLabels(public HTTP). - Serves
com.atproto.label.subscribeLabels(WebSocket firehose) with cursor backfill and a live tail. - Exposes
POST /admin/labels(create/negate) andPOST /coop/action(Coop’s action shape). - Accepts
com.atproto.moderation.createReportfrom any AT Protocol AppView, enriches it, and forwards it to Coop’s review queue. - Enforces takedowns and restores on your own PDS via
POST /admin/enforce. - Persists labels and reports in Postgres, with a monotonic sequence for firehose cursors.
What it does not do
troposphere is the atproto plumbing, not the moderation tool. It does not decide what to
label or take down. Coop does that: Coop is the tool where you set up your own policies, rules,
and actions and where your reviewers work, and it calls troposphere’s APIs to carry a decision
out. Coop does not come with its own policies or reviewers; those are yours. Identity
provisioning helpers and multi-tenant operation are on the roadmap.
The fastest way to see it work is Getting started.
How it compares to Ozone
Ozone is the moderation system Bluesky builds and runs. It is two things in one deployment:
- A moderation console and backend: a review queue, report handling, moderator
accounts and roles, event history, takedowns, and a web UI, backed by its own database
and the
tools.ozone.*lexicons. - A label emitter: it holds the labeler signing key, mints signed
com.atproto.labelrecords, and publishes them oversubscribeLabelsandqueryLabels.
troposphere covers the AT Protocol-facing parts: pulling content in for review, publishing
labels, taking reports, and enforcing takedowns. Coop is the moderation tool. troposphere is not
just a labeler, and it is not a console; together with Coop it replaces Ozone.
| Ozone | troposphere | |
|---|---|---|
| Moderation tool / queue | Built in | Coop (the tool troposphere works with) |
| Ingest content for review | Bluesky’s infra feeds it | Yes (Jetstream worker) |
Report intake (createReport) | Built in (tools.ozone.*) | Yes; enriched and forwarded to Coop |
| Signs and serves labels | Yes | Yes |
subscribeLabels firehose | Yes | Yes |
queryLabels | Yes | Yes |
| Takedowns on your own PDS | Yes | Yes (POST /admin/enforce) |
| Data model you must adopt | Ozone’s | None; plain HTTP APIs |
| Drive it from your own tooling | Not the primary path | The primary path |
For this project, Coop plays the role Ozone’s UI would: the
review queue, reviewer roles, and event history. troposphere is the atproto plumbing Coop
uses to publish labels, receive reports, and enforce takedowns.
When to use which
Use Ozone if you want Bluesky’s moderation console and are happy to run its stack.
Use Coop + troposphere if you want to build your moderation workflow in Coop - its review queues, automated enforcement rules, matching banks, reviewer roles - and have it act on AT Protocol content. Coop is where the work happens; troposphere connects that workflow to the network, so content flows in and labels, reports, and takedowns flow out. You do not bring your own queue or rules; you build them in Coop.
(If you are not using Coop at all, troposphere’s endpoints are plain HTTP, so a classifier or your own tooling can call them directly. But Coop is what it is designed for.)
What stays the same for consumers
To an AppView or client subscribing to your labeler, there is no difference. A labeler is
identified by its DID and speaks subscribeLabels and queryLabels. Consumers do not know
or care whether Ozone or troposphere produced a label, as long as it is signed by the key
the labeler’s DID document advertises.
Concepts
A short tour of the AT Protocol pieces troposphere implements. The full specification is
at atproto.com/specs/label.
Labeler
A labeler is a service, identified by a DID, that publishes labels. Its DID document
advertises two things: a signing key (#atproto_label) and a service endpoint
(#atproto_labeler, type AtprotoLabeler) where its label endpoints live. Clients subscribe
to a labeler by its DID.
Label
A label is a small signed record attaching a value to a subject:
src: the labeler’s DID.uri: what the label is about. An account (a baredid:) or a record (anat://URI).cid: optional, pins the label to a specific version of the record.val: the label value, a short string such asspamornudity.neg: if true, this label retracts a previous one.cts: the creation timestamp.exp: optional expiry.sig: a signature over the canonical (dag-cbor) encoding of the other fields.
Because verifiers reconstruct the signed bytes from these fields, troposphere stores
timestamps verbatim so a stored label reproduces exactly what was signed.
Negation
Labels are not deleted. To retract a label you emit a new one with the same src, uri,
and val, neg set to true, and a later timestamp. Consumers treat the most recent label
for a given (src, uri, val) as authoritative.
queryLabels
com.atproto.label.queryLabels is a public HTTP endpoint. Given URI patterns it returns the
matching labels. Clients use it to hydrate labels on demand.
subscribeLabels
com.atproto.label.subscribeLabels is a WebSocket firehose. Each label is assigned a
monotonic sequence number. A consumer connects with a cursor to backfill everything since
that sequence, then stays connected for a live tail. troposphere buffers labels emitted
during backfill so there is no gap at cutover.
Reports
com.atproto.moderation.createReport lets a user report an account or record to a labeler.
Reports are proxied to the labeler’s endpoint with an inter-service auth token. troposphere
verifies that token against the reporter’s DID, stores the report, and can forward it to your
backend. See Receiving reports.
Building on the AT Protocol
This guide is for someone building their own thing on the AT Protocol: your own record types
(lexicons), maybe your own PDS, maybe your own AppView. It explains where troposphere fits and,
just as importantly, where it does not assume you are Bluesky.
The short version: troposphere is lexicon-, PDS-, and AppView-agnostic. It handles labeling,
reporting, and enforcement for your stack, whatever that stack is.
What is generic
Your own record types. A label points at an AT-URI (at://did/<collection>/<rkey>) or a
bare DID. Nothing in troposphere inspects the collection, so labeling a com.yourapp.recipe or
a site.standard.document works exactly like labeling an app.bsky.feed.post. The label
values are your own vocabulary too. You are not limited to Bluesky’s record types or label set.
Your own PDS. Report intake resolves the reporter through their DID (did:plc or did:web),
so a report proxied from any PDS authenticates. Enrichment reads the reported record from the
subject’s own PDS. Enforcement takes content down on your PDS. None of these paths call a
Bluesky AppView.
Your own AppView. Your AppView subscribes to your labeler by its DID over the standard
subscribeLabels firehose, the same way Bluesky’s AppView would. queryLabels is the same
standard endpoint. troposphere does not care who is consuming.
What you provide
- A labeler identity. A DID whose document advertises your signing key and this server. Use
did:webunder your own domain for full self-hosting, ordid:plcfor portability. See Publishing your labeler identity. - A moderation workflow, in Coop. Coop is where you build it: review queues, automated enforcement rules, matching banks, reviewer roles. Coop decides what to label or take down and calls troposphere. (Not using Coop? Any tooling that makes an authenticated HTTP request can call the endpoints instead. See Using it from a moderation backend.)
- Label handling in your AppView.
troposphereemits standard labels; your AppView has to read them and decide what to do (hide, warn, badge). That interpretation is your app’s job, as it is for any labeler.
What troposphere does not do
- It does not interpret labels for you. Emitting a
spamlabel does nothing on its own; your AppView (or Bluesky’s, if you publish to that ecosystem) decides what a label means in the UI. - It does not define custom moderation lexicons. It implements the standard
com.atproto.moderation.createReport. If you invent your own report record type, you would handle that separately. Using the standard report procedure keeps you interoperable with existing clients. - It does not resolve enforcement across many PDSes. Enforcement targets one configured PDS (yours). Multi-PDS targeting is on the roadmap.
A walkthrough for a custom stack
Say you run yourapp.example with a lexicon com.yourapp.post, your own PDS, and your own
AppView.
- Create a labeler identity at
did:web:labeler.yourapp.exampleand publish its DID document andapp.bsky.labeler.servicedeclaration with your label values. See Publishing your labeler identity. - Deploy
tropospherewith that DID and a signing key, a Postgres, and (for enforcement)PDS_URL/PDS_ADMIN_PASSWORDpointing at your PDS. See Getting started. - Point it at your backend. Set
REPORT_FORWARD_URLto your backend so inbound reports arrive there. Have your backend callPOST /admin/labelsto publish a label andPOST /admin/enforceto take content down. - Label your own records. A decision to label
at://did:.../com.yourapp.post/abcis one call; the collection beingcom.yourapp.postchanges nothing. - Consume labels in your AppView. Subscribe to
did:web:labeler.yourapp.exampleoversubscribeLabels, hydrate labels onto yourcom.yourapp.postviews, and render them however your product wants.
At no point in this does Bluesky’s infrastructure sit in the path. troposphere is the moderation
labeler for your atmosphere.
Getting started
Prerequisites
- Node 24 or newer.
- Postgres.
Install
git clone https://github.com/julietshen/troposphere
cd troposphere
npm install
1. Generate a signing key
npm run keygen
This prints three things:
LABELER_SIGNING_KEY=<64 hex chars>
# did:key: did:key:zQ3sh...
# publicKeyMultibase: zQ3sh...
Keep LABELER_SIGNING_KEY secret. The publicKeyMultibase is what you will publish in your
DID document so the network can verify your labels. See
Publishing your labeler identity.
2. Configure
Copy the example env file and fill it in:
cp .env.example .env
| Variable | Meaning |
|---|---|
LABELER_DID | Your labeler’s DID (did:web:... or did:plc:...). |
LABELER_SIGNING_KEY | The 64-char hex key from npm run keygen. |
ADMIN_TOKEN | Bearer token your backend presents to the emit API. |
DATABASE_URL | Postgres connection string. |
PORT | HTTP port (default 4100). |
3. Create the schema and run
npm run db:init
npm run dev # or: npm run build && npm start
On startup the server logs the labeler DID and the #atproto_label key it is using, as a
reminder to publish them.
4. Verify it works
Emit a label and read it back:
curl -X POST http://localhost:4100/admin/labels \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{ "subject": { "uri": "at://did:plc:example/app.bsky.feed.post/abc" }, "create": ["spam"] }'
curl "http://localhost:4100/xrpc/com.atproto.label.queryLabels?uriPatterns=at://did:plc:example/app.bsky.feed.post/abc"
Or run the bundled smoke test, which emits, queries, tails the firehose, and independently verifies the signatures:
LABELER_URL=http://localhost:4100 \
ADMIN_TOKEN=$ADMIN_TOKEN \
LABELER_DID=$LABELER_DID \
SIGNING_DID_KEY=did:key:zQ3sh... \
npm run smoke
The smoke test also exercises report intake: it mints a service JWT and confirms
createReport accepts an authed report and rejects unauthenticated or wrong-audience ones.
Next: make the network trust your labels by publishing your labeler identity.
Publishing your labeler identity
troposphere signs labels, but the network only trusts them if the labeler’s DID document
advertises the matching signing key and a service endpoint pointing at your server. This is
independent of the software: it is how atproto establishes that a given DID is a labeler.
You need to do three things:
- Advertise the
#atproto_labelverification method (thepublicKeyMultibasefromnpm run keygen). - Advertise an
#atproto_labelerservice endpoint (typeAtprotoLabeler) pointing at your server’s public URL. - Publish an
app.bsky.labeler.servicedeclaration record so clients can discover your labeler and the label values it uses.
Two DID methods are supported. Provisioning helpers are on the roadmap; for now the steps are manual and documented here.
Option A: did:web (recommended for self-hosting)
did:web ties the identity to a domain you control, with no dependency on the PLC directory.
Good for a project standing up its own labeler under its own domain.
Set LABELER_DID=did:web:labeler.example.com and host a document at
https://labeler.example.com/.well-known/did.json:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1"
],
"id": "did:web:labeler.example.com",
"verificationMethod": [
{
"id": "did:web:labeler.example.com#atproto_label",
"type": "Multikey",
"controller": "did:web:labeler.example.com",
"publicKeyMultibase": "zQ3sh..."
}
],
"service": [
{
"id": "#atproto_labeler",
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
}
]
}
The serviceEndpoint is the host of this server, with no path.
Note on the declaration record. The
app.bsky.labeler.servicerecord lives in a repo keyed to the labeler DID. A baredid:webwith no PDS has nowhere to hold it, so the record is the one rough edge of thedid:webpath. Options: run a PDS for the labeler account, or rely on the DID-document service andqueryLabelsalone (clients can still read labels, but the polished in-app subscribe experience that reads the declaration is degraded). A helper for this is on the roadmap.
Option B: did:plc (portable)
did:plc is portable across domains and has key-recovery through the PLC directory. This is
the path Bluesky’s own tooling assumes, and the labeler account is a normal PDS account, so
it has a repo for the declaration record.
Set LABELER_DID=did:plc:... and update the account’s PLC identity to add the same
#atproto_label key and #atproto_labeler service entry as above.
The declaration record
Whichever DID method you use, publish an app.bsky.labeler.service record at rkey self in
the labeler’s repo, listing the label values you use:
{
"$type": "app.bsky.labeler.service",
"policies": {
"labelValues": ["spam", "nudity"]
},
"createdAt": "2026-08-22T00:00:00.000Z"
}
You can also declare reasonTypes and subjectTypes to scope what reports the labeler
accepts. A future helper will keep this record in sync with the label values troposphere
actually emits.
Verifying
After publishing, resolve your DID and confirm the key and service are present. Labels your server emits should now verify against the DID for any consumer, and the labeler should be subscribable by its DID.
Emitting labels
Labels are created through POST /admin/labels, a bearer-authenticated endpoint Coop (or
other tooling) calls. troposphere does not decide what to label; Coop does, then tells
troposphere to publish. Coop usually calls POST /coop/action instead, which takes Coop’s
action shape directly; /admin/labels is the plain version underneath.
Authentication
Every request must carry the admin bearer token:
authorization: Bearer <ADMIN_TOKEN>
Requests without it get 401.
Request
{
"subject": { "uri": "at://did:plc:.../app.bsky.feed.post/abc", "cid": "bafy..." },
"create": ["spam"],
"negate": []
}
subject.uri(required): the recordat://URI, or a baredid:for an account.subject.cid(optional): pins a record label to a specific version.create: label values to apply.negate: label values to retract.
You may pass create, negate, or both. At least one value is required.
Labeling a record vs an account
Record label:
{ "subject": { "uri": "at://did:plc:.../app.bsky.feed.post/abc", "cid": "bafy..." }, "create": ["spam"] }
Account label (omit cid, use the bare DID):
{ "subject": { "uri": "did:plc:..." }, "create": ["scam-account"] }
Retracting a label
Negation emits a new label with neg set and a later timestamp, which is how atproto retracts:
{ "subject": { "uri": "at://did:plc:.../app.bsky.feed.post/abc" }, "negate": ["spam"] }
Response
{
"labels": [
{ "seq": 42, "val": "spam", "neg": false }
]
}
Each emitted label gets a sequence number. That number is the position in the
subscribeLabels firehose, so a backend can record it for reconciliation.
What happens under the hood
For each value, the labeler builds the canonical label object (ver 1, your DID as src,
the subject, a fresh timestamp), signs its dag-cbor encoding with the labeler key, stores it
in Postgres with the next sequence number, and pushes it to every open subscribeLabels
stream. See Serving labels.
Serving labels to the network
Consumers read labels two ways: on demand with queryLabels, and as a live stream with
subscribeLabels. Both are standard atproto endpoints served under /xrpc/.
queryLabels
GET /xrpc/com.atproto.label.queryLabels
Parameters:
uriPatterns(required): one or more patterns. An exactat://ordid:value, or a prefix ending in*. The single pattern*matches everything.sources(optional): restrict to specific labeler DIDs.limit(optional): default 50, maximum 250.cursor(optional): a sequence number to page from.
curl "http://localhost:4100/xrpc/com.atproto.label.queryLabels?uriPatterns=at://did:plc:example/app.bsky.feed.post/abc"
Response:
{
"cursor": "42",
"labels": [
{
"ver": 1,
"src": "did:web:labeler.example.com",
"uri": "at://did:plc:example/app.bsky.feed.post/abc",
"val": "spam",
"cts": "2026-08-22T00:00:00.000Z",
"sig": { "$bytes": "..." }
}
]
}
Page by passing the returned cursor back on the next request.
subscribeLabels
GET /xrpc/com.atproto.label.subscribeLabels (WebSocket)
Each label is assigned a monotonic sequence number. Connect with a cursor to backfill
everything after that sequence, then stay connected for the live tail:
ws://localhost:4100/xrpc/com.atproto.label.subscribeLabels?cursor=0
- With a cursor, the server backfills from the store, then joins you to the live stream. Labels emitted during backfill are buffered, so there is no gap at cutover.
- Without a cursor, you receive only labels emitted from the moment you connect.
Frames are the standard atproto framing. Each #labels message carries a seq and an array
of signed labels, dag-cbor encoded. The @atproto/xrpc-server Frame helper decodes them;
see scripts/smoke.mjs for a minimal consumer.
Verifying signatures
A label is trustworthy only if its signature verifies against the labeler’s
#atproto_label key. Consumers resolve the labeler DID, read that key, reconstruct the label
object without sig, dag-cbor encode it, and check the signature. The smoke test does exactly
this using verifySignature from @atproto/crypto.
Receiving reports
troposphere accepts com.atproto.moderation.createReport, the standard way a user reports
an account or record to a labeler. Reports are authenticated, stored, and optionally forwarded
to your moderation backend for triage.
Report intake is AppView-agnostic: a report proxied from any AT Protocol PDS authenticates, not only Bluesky’s.
How a report arrives
A client does not call the labeler directly. It calls its own PDS with an atproto-proxy
header naming the labeler, and the PDS proxies the request to the labeler’s endpoint, attaching
an inter-service auth token. That token proves which account is reporting.
user's client ──> user's PDS ──(atproto-proxy, signed)──> troposphere /xrpc/com.atproto.moderation.createReport
What the labeler does
- Verifies the token. It resolves the reporter’s DID to its signing key (any
did:plcordid:web, through the standard identity resolver) and checks the token’s signature, audience, and scope. A missing, wrong-audience, or invalid token is rejected. - Records the report. Reason, subject (account or record), and reporter DID are persisted, and the report gets an id.
- Enriches the subject. It resolves the reported subject through its own identity (DID to
PDS) and reads the record with
com.atproto.repo.getRecord, so the forwarded report carries the content, not just a URI. This is AppView-agnostic and best-effort: a deleted record or unreachable PDS leaves the content absent but does not fail the report. SetREPORT_ENRICH=falseto skip it. - Forwards it, if configured. When
REPORT_FORWARD_URLis set, the report (with any enrichment) is POSTed to your backend, with an optional bearer token. Best-effort: the report is already durably stored, so a backend outage does not lose it or fail the reporter’s request. - Responds with the created report, per the lexicon.
Configuring forwarding
REPORT_FORWARD_URL=https://your-backend.example/reports
REPORT_FORWARD_TOKEN= # optional bearer token sent to your backend
The forwarded body, with enrichment attached:
{
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reason": "…",
"reportedBy": "did:plc:…",
"createdAt": "2026-08-22T00:00:00.000Z",
"subject": { "$type": "com.atproto.repo.strongRef", "uri": "at://…", "cid": "…" },
"enrichment": {
"did": "did:plc:…",
"handle": "alice.example.com",
"pds": "https://pds.example.com",
"record": { "uri": "at://…", "reportedCid": "…", "cid": "…", "value": { }, "found": true }
}
}
Your backend decides what to do with it: enqueue it for review, run rules, and eventually call
POST /admin/labels to publish a label or POST /admin/enforce to take it down. The labeler
does not act on reports by itself.
That JSON is the raw format. Set REPORT_FORWARD_FORMAT=coop to instead post
Coop’s POST /api/v1/report envelope with an X-API-KEY header, so
reports land directly in a Coop review queue with no adapter.
Declaring what you accept
Clients decide which reports to send you from your app.bsky.labeler.service declaration
record. Use its reasonTypes and subjectTypes to scope what your labeler reviews. See
Publishing your labeler identity.
Enforcement (takedowns)
A label is advisory: it attaches metadata that consumers may act on. Enforcement is different.
It removes or restores content on a PDS you control. troposphere exposes both levers so a
backend can choose per case: label it, take it down, or both.
Enforcement targets the operator’s own PDS. You can only take down content on a PDS you hold admin credentials for, which is the point: you enforce on your own infrastructure. For third-party content (for example a post hosted on someone else’s PDS), labeling is the only lever anyone has.
Enabling it
Set the PDS you administer and its admin password. Until both are set, POST /admin/enforce
returns 501.
PDS_URL=https://pds.example.com
PDS_ADMIN_PASSWORD=...
Taking down a record or account
POST /admin/enforce, authenticated with the same admin bearer token as the label API.
# take down a record
curl -X POST https://labeler.example.com/admin/enforce \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{
"subject": { "uri": "at://did:plc:.../app.bsky.feed.post/abc", "cid": "bafy..." },
"takedown": true,
"ref": "report:42"
}'
# take down an account
-d '{ "subject": { "did": "did:plc:..." }, "takedown": true }'
# restore (reverse a takedown)
-d '{ "subject": { "did": "did:plc:..." }, "takedown": false }'
subject: a record (uri+cid) or an account (did).takedown:truetakes down,falserestores. Defaults totrue.ref: an optional reference string (for example your internal case id) recorded on the PDS.
What happens under the hood
The labeler calls com.atproto.admin.updateSubjectStatus on your PDS with HTTP Basic admin
auth, mapping the subject to a strongRef (record) or repoRef (account) and setting the
takedown state. A non-2xx response from the PDS is surfaced as a 502 so your backend knows the
takedown did not apply.
Label and enforce together
The two are independent calls, so a backend decision can do either or both. A common pattern: label content for consumers who subscribe to your labeler, and take it down on your own PDS so it stops being served from your infrastructure.
Ingesting content into Coop
For Coop to moderate AT Protocol content, that content has to arrive as Coop items. troposphere’s ingestion worker streams Jetstream and posts records to Coop’s item intake over HTTP, so Coop can run rules and route content to review queues. This is the proactive-detection path; user reports arrive separately (see Receiving reports).
Coop itself never connects to Jetstream and needs no atproto-specific connector. This worker does
that job and posts plain items to /items/async, which is why Coop stays generic. If your Coop
shows no atproto content, it is not because Coop lacks a firehose; it is because this worker is not
running or is scoped to different accounts.
Ingestion runs as its own process, opt-in, so a troposphere deployment that only labels does not pay for it.
Running it
COOP_ITEMS_URL=https://your-coop/api/v1/items/async \
COOP_ITEMS_API_KEY=<coop org API key> \
npm run ingest
The worker connects to Jetstream, maps each record to a Coop item, batches, and POSTs
{ items: [...] } to /api/v1/items/async with X-API-KEY. It reconnects with backoff and
maintains a live tail.
Configuration
| Variable | Default | Meaning |
|---|---|---|
COOP_ITEMS_URL | (required) | Coop’s /api/v1/items/async endpoint. |
COOP_ITEMS_API_KEY | (required) | Coop org API key, sent as X-API-KEY. |
JETSTREAM_URL | wss://jetstream2.us-east.bsky.network/subscribe | Jetstream instance. |
JETSTREAM_COLLECTIONS | app.bsky.feed.post,app.bsky.actor.profile | Record types to stream. |
JETSTREAM_WANTED_DIDS | (all) | Restrict to specific accounts. |
INGEST_BATCH_SIZE | 50 | Max items per POST. |
INGEST_BATCH_INTERVAL_MS | 1000 | Flush interval. |
COOP_POST_TYPE / COOP_ACCOUNT_TYPE | ATproto-post / ATproto-account | Coop item type ids. |
Scope your ingestion
The full firehose is enormous. Ingesting every post on the network into Coop is almost never what you want. Scope it:
JETSTREAM_WANTED_DIDSrestricts to your project’s accounts.JETSTREAM_COLLECTIONSrestricts to the record types you moderate.
For a project running its own PDS or AppView, point JETSTREAM_URL at a Jetstream fed by your
own infrastructure, or filter to your accounts.
Item types and custom lexicons
The worker maps app.bsky.feed.post to your post item type and app.bsky.actor.profile to your
account item type. Those item types must already exist in your Coop org (created there once), and
COOP_POST_TYPE / COOP_ACCOUNT_TYPE must be set to their item type IDs (the id Coop
assigns each type, not the display name). Coop’s HTTP intake matches typeId against the id and
rejects the batch otherwise. The mapping produces the same item shape a reported post gets, so
ingested and reported content look identical to Coop.
Mapping additional or custom record types (your own lexicons) to Coop item types is not built in yet; today the worker covers posts and profiles. See the roadmap.
What it does not do
Ingestion only submits items. Whether an item reaches a reviewer is up to Coop’s rules and routing (unlike reports, which Coop always enqueues). This is intentional: the item path is for proactive detection and automated enforcement, so you decide in Coop which content warrants review.
Using it from a moderation backend
troposphere is the atproto-facing half of a moderation system, and Coop
is the other half: the policies, rules, and actions you set up in Coop, and the reviewers who work
there, decide what to label or take down, then use troposphere. This page covers the general
integration shape; if you are using Coop, see
Deploying with Coop, which wires it up with no adapter.
The integration in one call
Whatever the backend, the integration is the same: when a decision is made to label a piece of content, POST the subject and label values with the admin token.
your backend ──(decision)──> POST /admin/labels ──> signed label ──> subscribeLabels / queryLabels ──> the network
Give the labeler a stable public URL, share the ADMIN_TOKEN with your backend, and keep the
signing key on the labeler only. Your backend never touches the key.
Coop
Coop is the moderation tool troposphere is built for, and it
plays the role Ozone’s UI would: your policies, review queues, reviewer roles, and event history
live in Coop. troposphere is the atproto plumbing Coop uses. troposphere ingests atproto content
into Coop (Coop itself has no firehose); Coop runs the rules you configure, routes items to review
queues, and fires the actions you set up. A Coop action issues an outbound HTTP callback, which is
the seam that points at this service.
The wiring (see Deploying with Coop):
- A Coop label action’s callback URL points at
POST /coop/action(or/admin/labels); a takedown action points atPOST /admin/enforce. - The action body carries the subject
at://URI (and CID) and the label value or takedown flag. - Reports forwarded from
troposphereland in a Coop review queue. - Generating the labeler’s
app.bsky.labeler.servicedeclaration from Coop’s configured labels is the one remaining piece (see roadmap).
The result is a full loop with no Ozone in the path: atproto content and reports flow into Coop,
a reviewer or rule decides, and troposphere publishes the label or applies the takedown.
Your own tooling
You do not need Coop. Any script or service that can make an authenticated HTTP request can use the labeler:
curl -X POST https://labeler.example.com/admin/labels \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{ "subject": { "uri": "at://..." }, "create": ["spam"] }'
A classifier that flags content, a moderation bot, or a spreadsheet-run batch job all integrate the same way.
Receiving reports
Integration also runs the other direction. When users report content to your labeler, the
labeler verifies and stores each report and forwards it to your backend at
REPORT_FORWARD_URL. Your backend triages the report and, when it decides to act, calls
POST /admin/labels. See Receiving reports.
Idempotency and reconciliation
Emitting the same label twice creates two records; the network treats the latest as current,
so duplicates are harmless but noisy. If your backend needs exactly-once semantics, record the
seq returned by each emit and reconcile against queryLabels before re-emitting.
Deploying with Coop
Coop is the moderation tool; troposphere is its AT Protocol
side. Coop is where you set up your own policies, rules, and actions and where your reviewers work
(the role Ozone’s UI plays); troposphere is the plumbing Coop uses to publish labels, receive
reports, and enforce takedowns. Coop does not come with policies or reviewers of its own, and it
holds no atproto-specific code; those are yours, and troposphere’s, respectively.
AT Protocol content ─▶ Coop (your policies, rules, actions; your reviewers)
│ decision (label / take down)
▼
troposphere ─▶ signed labels ─▶ subscribeLabels / queryLabels
▲ ─▶ takedown ─▶ your PDS
│
inbound report ───────────┘ (createReport, forwarded into a Coop queue)
What each side owns
- Coop: the tool where you configure policies, rules, and actions, review queued items, and act on them. Its actions fire outbound HTTP callbacks. The reviewers and policies are yours.
- troposphere: ingests content into Coop, signs and serves labels, accepts and forwards reports, and enforces takedowns. It holds the labeler signing key; Coop never sees it.
1. Deploy troposphere
Follow Getting started. For a Coop deployment you will typically set:
LABELER_DID=did:web:labeler.yourorg.example
LABELER_SIGNING_KEY=...
ADMIN_TOKEN=... # Coop presents this on label/enforce calls
DATABASE_URL=postgres://.../troposphere
REPORT_FORWARD_URL=... # Coop's report/item intake (see step 4)
REPORT_FORWARD_TOKEN=... # Coop API key
PDS_URL=https://your-pds # only if you enforce takedowns on your own PDS
PDS_ADMIN_PASSWORD=...
Then publish your labeler identity.
2. Labels and takedowns out: Coop action to troposphere
Coop publishes a label or takedown by firing a CUSTOM_ACTION at a reviewer’s decision (or from a
rule). Point it at troposphere’s Coop endpoint, POST /coop/action, which accepts Coop’s action
shape directly, with no adapter:
- callbackUrl:
POST /coop/actionon troposphere. - callbackUrlHeaders:
{ "Authorization": "Bearer <ADMIN_TOKEN>" }. - callbackUrlBody: the directive.
{ "create": ["spam"] }labels,{ "negate": ["spam"] }removes a label,{ "takedown": true }takes the content down. Combine them in one action if you want:{ "create": ["nsfw"], "takedown": true }.
Coop injects the item’s atproto URI as item.id; troposphere reads it as the subject and, for a
record, resolves the current CID from the record’s own PDS so the label or takedown pins to that
version. You do not compute the CID in Coop. Takedowns require PDS_URL/PDS_ADMIN_PASSWORD (your
own PDS); see Enforcement.
3. Ingestion and review
Run troposphere’s ingestion worker to pump atproto content into Coop:
COOP_ITEMS_URL=https://your-coop/api/v1/items/async \
COOP_ITEMS_API_KEY=<coop org API key> \
JETSTREAM_WANTED_DIDS=<your accounts> # scope it; the full firehose is huge
npm run ingest
Coop runs rules over the items and routes them to queues. A reviewer opens an item, sees the post and its author context, and picks an action; a label or takedown action fires the callback in step 2. None of this involves Ozone. See Ingesting content into Coop.
4. Reports in: troposphere to a Coop queue
Turn on the Coop report format and point troposphere at Coop’s report intake:
REPORT_FORWARD_FORMAT=coop
REPORT_FORWARD_URL=https://your-coop/api/v1/report
REPORT_FORWARD_TOKEN=<coop org API key> # sent as X-API-KEY
COOP_POST_TYPE=<post item type id> # the org's item type IDs, not the display names
COOP_ACCOUNT_TYPE=<account item type id>
When a user reports content to your labeler, troposphere verifies and enriches the report (see
Receiving reports), maps it onto Coop’s POST /api/v1/report envelope
(reporter as a user report keyed by the reporter DID, the reported record or account as the
reportedItem, and the reason on reportedForReason), and posts it with X-API-KEY. Coop’s
report endpoint always enqueues the item to a review queue, so the report lands in Coop alongside
everything else. No adapter needed.
Integration status
Both directions are turnkey and tested end to end:
- Labels and takedowns out:
/coop/actionaccepts Coop’sCUSTOM_ACTIONbody as-is, so wiring is just configuring a Coop action (step 2). No adapter. - Reports in:
REPORT_FORWARD_FORMAT=coopposts Coop’s/api/v1/reportenvelope withX-API-KEY(step 4). No adapter.
One thing remains, tracked as “Coop wiring” on the roadmap: the Coop-side action configuration itself (creating the label and takedown actions and attaching them to queues in Coop), and optionally generating the labeler declaration from Coop’s configured labels.
Testing locally against Coop
This walks through verifying the whole loop on your own machine: a real Coop, a real labeler, real signed labels. It assumes you have the Coop repo checked out and can run it locally (Docker, Postgres, ClickHouse, Scylla, Redis).
What you are proving
- Content flows from the network into Coop as items you can review.
- A reviewer decision in Coop produces a real signed label, verifiable against the labeler key.
- A report reaches a Coop review queue.
- A takedown is applied to a PDS.
1. Bring up Coop with the atproto item types
Coop needs two item types, ATproto-post and ATproto-account, plus an org, an API key, and a
label action. Item types are created by Coop’s seed, not over HTTP, so start from a Coop setup
that seeds them (the trustcon branch’s seed-trustcon creates the item types, queues, and a
label action). Run Coop’s backing services, backend, and client per Coop’s README, then run the
seed.
From that setup you need three things:
- the org’s API key (
X-API-KEYfor/items/asyncand/report), - the item type IDs for the post and account types (Coop assigns each type an id; the HTTP
intake matches
typeIdagainst that id, not the display name, so setCOOP_POST_TYPE/COOP_ACCOUNT_TYPEto the ids). The seed output prints them. - a label action whose callback URL is your troposphere
POST /coop/action(or/label), withAuthorization: Bearer <ADMIN_TOKEN>.
This loop has been run end to end against a real local Coop: items post to /items/async (202),
reports post to /report (201, enqueued), the reported item shows up in a Coop review queue, and
a reviewer picking a label action in the Review Console posts to troposphere and produces a signed
label that verifies against the labeler key. The seed’s Bleep/Bloop actions post to /label with
{ labelVal }, which troposphere accepts as-is.
2. Run troposphere
npm run keygen # note LABELER_SIGNING_KEY and the did:key it prints
cp .env.example .env # then fill in:
# LABELER_DID=did:web:localhost%3A4100 (or a did:plc test account)
# LABELER_SIGNING_KEY=... ADMIN_TOKEN=... DATABASE_URL=...
# REPORT_FORWARD_FORMAT=coop
# REPORT_FORWARD_URL=http://localhost:8080/api/v1/report
# REPORT_FORWARD_TOKEN=<coop org API key>
# PDS_URL=... PDS_ADMIN_PASSWORD=... (only to test takedowns)
npm run db:init
npm run server:start # or: npm run dev
3. Ingest content into Coop
Scope it to a test account so you are not pulling the whole firehose:
COOP_ITEMS_URL=http://localhost:8080/api/v1/items/async \
COOP_ITEMS_API_KEY=<coop org API key> \
JETSTREAM_WANTED_DIDS=<your test account did> \
npm run ingest
Post from that account on Bluesky and watch the item show up in Coop.
4. Label, and verify it is real
Open the reported item in Coop’s Review Console, pick the label action in the Decision panel, and
Submit. Coop fires the action to troposphere (/coop/action, or /label for a relay-style
action), which signs and stores the label. Confirm it is a real, valid label by querying it and
checking the signature (the smoke test does exactly this):
LABELER_URL=http://localhost:4100 \
ADMIN_TOKEN=... LABELER_DID=... SIGNING_DID_KEY=did:key:z... \
npm run smoke
or query directly:
curl "http://localhost:4100/xrpc/com.atproto.label.queryLabels?uriPatterns=<the at-uri>"
5. Reports and takedowns
Send a report (a client’s createReport, proxied to your labeler, or a direct call for testing)
and confirm it lands in a Coop review queue. If you set PDS_URL/PDS_ADMIN_PASSWORD, pick the
takedown action and confirm the content is taken down on that PDS.
On the labeler identity
Locally, verifying the signature against the labeler key proves the labeler is working correctly.
For a labeler that outside clients (like the Bluesky app) can subscribe to, the DID has to be
publicly resolvable, which means a did:web on a real domain serving did.json, or a did:plc.
See Publishing your labeler identity. A public deploy is the next step past this
local check.
Operations
The signing key is the identity
The labeler’s whole trust model rests on its signing key. Anyone with the key can publish
labels as your labeler. Treat LABELER_SIGNING_KEY like any high-value secret:
- Store it in your platform’s secret manager, not in the repo or an image.
- Give it only to the labeler process. Backends use the labeler over the admin API and never need the signing key.
- Have a rotation plan. Rotating means publishing a new
#atproto_labelkey in the DID document.did:plcoffers key recovery through the PLC directory;did:webrotation is a document edit.
The label store is durable by design
Emitted labels are permanent on the network: consumers persist them, and negation, not deletion, is how you retract. The Postgres store is the labeler’s record of what it has published and the source of firehose backfill. Back it up. Do not treat it as a cache you can drop, or consumers and your store will disagree about history.
Deployment shape
- One process serves both the public
/xrpc/endpoints and the/admin/labelsAPI. Put the admin API behind your network boundary so only your backend can reach it; the/xrpc/endpoints are meant to be public. - Terminate TLS in front of the service. The atproto ecosystem expects HTTPS on the default port for labeler endpoints.
- The
serviceEndpointin your DID document must be the public URL clients reach, with no path.
A note on scale
The live tail is delivered in-process: a label emitted through the admin API is pushed to the
subscribeLabels streams connected to that same process. A single instance handles this
cleanly. Running multiple instances behind a load balancer, where a label emitted on one
instance must reach subscribers on another, needs a shared notification path (for example
Postgres LISTEN/NOTIFY). That is a known extension point, not yet built.
Health
GET /health returns { "status": "ok", "did": "<labeler did>" } for load-balancer checks.
Migrating from Ozone
If you already run Ozone as your labeler, you can move label emission to troposphere while
keeping whatever moderation workflow you have. This page covers the moving parts.
The two migration shapes
Keep your Ozone DID. Point the same labeler DID at troposphere by updating the DID
document’s #atproto_labeler service endpoint to the new server, and configure
troposphere with that DID and a signing key the DID advertises. Consumers keep their
existing subscription; only the server behind the DID changes.
Use a new labeler identity. Stand up troposphere under a fresh did:web or did:plc
(see Publishing your labeler identity) and have consumers subscribe to the new
DID. Cleaner separation, but subscribers must re-subscribe.
Most operators find keeping the DID least disruptive.
The signing key
Labels are only valid if signed by a key the DID document advertises. If you keep your DID and
want continuity, the DID document must advertise the key troposphere signs with. Either
import your existing labeler key into troposphere (set LABELER_SIGNING_KEY to it), or
publish troposphere’s new key as an additional #atproto_label verification method. Never
copy the key anywhere it does not need to be.
Existing labels
Labels Ozone already published stay on the network; consumers have persisted them. There is no
automatic import of Ozone’s label history into the troposphere store today, so:
- New labels and negations flow through
tropospherefrom cutover onward. - If you need
troposphere’s store and firehose backfill to include historical labels, plan a one-time backfill from your Ozone label export into thelabeltable, preserving the original signatures and timestamps. A helper for this is not yet provided.
Reports
Both accept com.atproto.moderation.createReport. troposphere verifies the report, stores
it, and forwards it to your backend (see Receiving reports), rather
than housing a review console itself. Point the labeler DID at troposphere and reports flow
to your backend instead of Ozone’s queue.
Suggested cutover
- Deploy
troposphere, point your backend’s label emission atPOST /admin/labels, and setREPORT_FORWARD_URLto your backend’s report intake. - Verify with the smoke test and by resolving your DID and subscribing.
- Move the DID’s
#atproto_labelerservice endpoint to the new server (or launch a new DID), which also routes inbound reports totroposphere. - Keep Ozone available until any label-history backfill you need is in place.
Reference
Configuration
Set via environment (a .env file is loaded in development).
| Variable | Required | Default | Meaning |
|---|---|---|---|
LABELER_DID | yes | The labeler’s DID (did:web:... or did:plc:...). | |
LABELER_SIGNING_KEY | yes | 64-char hex secp256k1 private key. From npm run keygen. | |
ADMIN_TOKEN | yes | Bearer token for POST /admin/labels. | |
DATABASE_URL | yes | Postgres connection string. | |
PORT | no | 4100 | HTTP port. |
REPORT_FORWARD_URL | no | Backend endpoint to forward inbound reports to. | |
REPORT_FORWARD_TOKEN | no | Bearer token sent with forwarded reports. | |
REPORT_ENRICH | no | true | Resolve and attach reported content to forwarded reports. |
REPORT_FORWARD_FORMAT | no | raw | raw (Bearer JSON) or coop (Coop /api/v1/report + X-API-KEY). |
COOP_POST_TYPE | no | ATproto-post | Coop item type id for posts (coop format). |
COOP_ACCOUNT_TYPE | no | ATproto-account | Coop item type id for accounts (coop format). |
PDS_URL | no | Your PDS, for enforcement. Enables POST /admin/enforce. | |
PDS_ADMIN_PASSWORD | no | Admin password for PDS_URL. |
Ingestion worker (npm run ingest) only:
| Variable | Required | Default | Meaning |
|---|---|---|---|
COOP_ITEMS_URL | yes | Coop’s /api/v1/items/async endpoint. | |
COOP_ITEMS_API_KEY | yes | Coop org API key, sent as X-API-KEY. | |
JETSTREAM_URL | no | wss://jetstream2.us-east.bsky.network/subscribe | Jetstream instance. |
JETSTREAM_COLLECTIONS | no | app.bsky.feed.post,app.bsky.actor.profile | Record types to stream. |
JETSTREAM_WANTED_DIDS | no | (all) | Restrict to specific accounts. |
INGEST_BATCH_SIZE | no | 50 | Max items per POST. |
INGEST_BATCH_INTERVAL_MS | no | 1000 | Flush interval. |
See Ingesting content into Coop.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | none | Liveness. Returns the labeler DID. |
POST | /admin/labels | bearer | Create and negate labels. |
POST | /admin/enforce | bearer | Take down or restore on your PDS. |
POST | /coop/action | bearer | Coop CUSTOM_ACTION shape: label and/or take down. |
POST | /label | bearer | Alias of /coop/action, matching the workshop relay path. |
GET | /xrpc/com.atproto.label.queryLabels | none | Query labels by URI pattern. |
GET (WS) | /xrpc/com.atproto.label.subscribeLabels | none | Label firehose. |
POST | /xrpc/com.atproto.moderation.createReport | service JWT | Accept a report. |
POST /admin/enforce
Take down or restore a record or account on your own PDS. Bearer-authenticated with
ADMIN_TOKEN. Body: subject (a record { uri, cid } or account { did }), takedown
(true to take down, false to restore, default true), and optional ref. Requires
PDS_URL and PDS_ADMIN_PASSWORD; returns 501 otherwise. See Enforcement.
POST /coop/action
Accepts Coop’s CUSTOM_ACTION webhook body directly, so a Coop action uses troposphere with no
adapter. Bearer-authenticated with ADMIN_TOKEN. Body: item.id (the subject atproto URI or
DID) and custom with any of create / negate (label values) and takedown (boolean), plus
optional cid and ref. custom.labelVal is accepted as an alias for create, so a Coop action
wired for the old workshop relay (posting { item, custom: { labelVal } } to /label) works
unchanged. For a record without a cid, the current version is resolved from its own PDS; if that
cannot be resolved the label is still emitted, without a cid. See
Deploying with Coop.
POST /xrpc/com.atproto.moderation.createReport
Standard atproto report intake, reached by PDS proxying (not called directly). Auth is the
inter-service JWT the reporter’s PDS attaches. Input is reasonType, optional reason, and a
subject (a com.atproto.admin.defs#repoRef for an account or a com.atproto.repo.strongRef
for a record). Returns the created report with an integer id and the reportedBy DID. See
Receiving reports.
POST /admin/labels
Request:
{
"subject": { "uri": "at://... or did:...", "cid": "optional" },
"create": ["label-value"],
"negate": ["label-value"]
}
Response:
{ "labels": [ { "seq": 1, "val": "label-value", "neg": false } ] }
Errors: 401 (missing or wrong token), 400 (missing subject.uri, or neither create nor
negate provided).
Scripts
| Command | What it does |
|---|---|
npm run keygen | Generate a signing key and print the public multibase. |
npm run db:init | Apply the Postgres schema. |
npm run ingest | Stream Jetstream into Coop’s item intake. |
npm run dev | Run with watch (type-stripped, no build step). |
npm run build | Compile TypeScript to dist/. |
npm start | Run the compiled build. |
npm run typecheck | Type-check without emitting. |
npm run smoke | End-to-end check against a running instance. |
Database
Two tables. label: key columns seq (bigserial, the firehose cursor), src, uri, cid,
val, neg, cts, exp, sig. Timestamps are stored as text so a stored label reproduces
exactly the bytes that were signed. report: id (bigserial), reason_type, reason,
subject_type, subject_did, subject_uri, subject_cid, reported_by, created_at.
Enforcement is not stored; it is applied directly to the PDS.
Building the docs
This book is built with mdBook:
mdbook serve docs # live preview
mdbook build docs # output to docs/book
Roadmap
troposphere today ingests content into Coop; signs, stores, serves, and streams labels;
accepts, enriches, and forwards reports; and enforces takedowns on your own PDS. These are the
pieces not yet built, in rough priority order.
Custom lexicon ingestion
The ingestion worker maps app.bsky.feed.post and app.bsky.actor.profile. Projects with their
own record types would benefit from a configurable mapping so com.yourapp.* records ingest into
their own Coop item types.
Jetstream cursor and replay
Ingestion is a live tail today. Persisting a cursor and using Jetstream v2 replay would let a worker catch up after downtime without missing content.
Multi-PDS enforcement
Enforcement targets a single configured PDS. Operators running content across several PDSes would benefit from resolving the subject’s PDS from its DID and selecting the matching admin credential per target.
Identity provisioning helpers
Tooling to publish and maintain the labeler identity: generating the did:web document,
updating a did:plc identity, and publishing and updating the app.bsky.labeler.service
declaration record, ideally kept in sync with the label values the labeler actually emits.
Labeler declaration from Coop’s labels
The Coop integration itself is built, in both directions: POST /coop/action takes Coop’s
CUSTOM_ACTION body for labels and takedowns, and REPORT_FORWARD_FORMAT=coop posts Coop’s
/api/v1/report envelope for reports. The one troposphere-side piece left is a helper that
generates the app.bsky.labeler.service declaration from the label values configured in Coop, so
the two stay in sync. (Creating the actions and queues inside Coop is operator setup, covered in
Deploying with Coop, not troposphere code.)
Multi-tenant operation
One deployment serving several labeler identities, keyed by DID, each with its own signing key, label set, and backend routing. Today one deployment serves one labeler.
Multi-instance live tail
The live firehose is delivered in-process. Fanning out labels across multiple instances behind
a load balancer needs a shared notification path such as Postgres LISTEN/NOTIFY. See
Operations.
Ozone history backfill
A helper to import an existing Ozone labeler’s published labels into the store, preserving
signatures and timestamps, so troposphere’s firehose backfill includes history after a
migration.