Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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) and POST /coop/action (Coop’s action shape).
  • Accepts com.atproto.moderation.createReport from 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:

  1. 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.
  2. A label emitter: it holds the labeler signing key, mints signed com.atproto.label records, and publishes them over subscribeLabels and queryLabels.

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.

Ozonetroposphere
Moderation tool / queueBuilt inCoop (the tool troposphere works with)
Ingest content for reviewBluesky’s infra feeds itYes (Jetstream worker)
Report intake (createReport)Built in (tools.ozone.*)Yes; enriched and forwarded to Coop
Signs and serves labelsYesYes
subscribeLabels firehoseYesYes
queryLabelsYesYes
Takedowns on your own PDSYesYes (POST /admin/enforce)
Data model you must adoptOzone’sNone; plain HTTP APIs
Drive it from your own toolingNot the primary pathThe 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 bare did:) or a record (an at:// URI).
  • cid: optional, pins the label to a specific version of the record.
  • val: the label value, a short string such as spam or nudity.
  • 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:web under your own domain for full self-hosting, or did:plc for 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. troposphere emits 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 spam label 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.

  1. Create a labeler identity at did:web:labeler.yourapp.example and publish its DID document and app.bsky.labeler.service declaration with your label values. See Publishing your labeler identity.
  2. Deploy troposphere with that DID and a signing key, a Postgres, and (for enforcement) PDS_URL/PDS_ADMIN_PASSWORD pointing at your PDS. See Getting started.
  3. Point it at your backend. Set REPORT_FORWARD_URL to your backend so inbound reports arrive there. Have your backend call POST /admin/labels to publish a label and POST /admin/enforce to take content down.
  4. Label your own records. A decision to label at://did:.../com.yourapp.post/abc is one call; the collection being com.yourapp.post changes nothing.
  5. Consume labels in your AppView. Subscribe to did:web:labeler.yourapp.example over subscribeLabels, hydrate labels onto your com.yourapp.post views, 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
VariableMeaning
LABELER_DIDYour labeler’s DID (did:web:... or did:plc:...).
LABELER_SIGNING_KEYThe 64-char hex key from npm run keygen.
ADMIN_TOKENBearer token your backend presents to the emit API.
DATABASE_URLPostgres connection string.
PORTHTTP 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:

  1. Advertise the #atproto_label verification method (the publicKeyMultibase from npm run keygen).
  2. Advertise an #atproto_labeler service endpoint (type AtprotoLabeler) pointing at your server’s public URL.
  3. Publish an app.bsky.labeler.service declaration 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.

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.service record lives in a repo keyed to the labeler DID. A bare did:web with no PDS has nowhere to hold it, so the record is the one rough edge of the did:web path. Options: run a PDS for the labeler account, or rely on the DID-document service and queryLabels alone (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 record at:// URI, or a bare did: 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 exact at:// or did: 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

  1. Verifies the token. It resolves the reporter’s DID to its signing key (any did:plc or did:web, through the standard identity resolver) and checks the token’s signature, audience, and scope. A missing, wrong-audience, or invalid token is rejected.
  2. Records the report. Reason, subject (account or record), and reporter DID are persisted, and the report gets an id.
  3. 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. Set REPORT_ENRICH=false to skip it.
  4. Forwards it, if configured. When REPORT_FORWARD_URL is 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.
  5. 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: true takes down, false restores. Defaults to true.
  • 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

VariableDefaultMeaning
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_URLwss://jetstream2.us-east.bsky.network/subscribeJetstream instance.
JETSTREAM_COLLECTIONSapp.bsky.feed.post,app.bsky.actor.profileRecord types to stream.
JETSTREAM_WANTED_DIDS(all)Restrict to specific accounts.
INGEST_BATCH_SIZE50Max items per POST.
INGEST_BATCH_INTERVAL_MS1000Flush interval.
COOP_POST_TYPE / COOP_ACCOUNT_TYPEATproto-post / ATproto-accountCoop 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_DIDS restricts to your project’s accounts.
  • JETSTREAM_COLLECTIONS restricts 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 at POST /admin/enforce.
  • The action body carries the subject at:// URI (and CID) and the label value or takedown flag.
  • Reports forwarded from troposphere land in a Coop review queue.
  • Generating the labeler’s app.bsky.labeler.service declaration 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/action on 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/action accepts Coop’s CUSTOM_ACTION body as-is, so wiring is just configuring a Coop action (step 2). No adapter.
  • Reports in: REPORT_FORWARD_FORMAT=coop posts Coop’s /api/v1/report envelope with X-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-KEY for /items/async and /report),
  • the item type IDs for the post and account types (Coop assigns each type an id; the HTTP intake matches typeId against that id, not the display name, so set COOP_POST_TYPE / COOP_ACCOUNT_TYPE to the ids). The seed output prints them.
  • a label action whose callback URL is your troposphere POST /coop/action (or /label), with Authorization: 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_label key in the DID document. did:plc offers key recovery through the PLC directory; did:web rotation 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/labels API. 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 serviceEndpoint in 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 troposphere from 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 the label table, 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

  1. Deploy troposphere, point your backend’s label emission at POST /admin/labels, and set REPORT_FORWARD_URL to your backend’s report intake.
  2. Verify with the smoke test and by resolving your DID and subscribing.
  3. Move the DID’s #atproto_labeler service endpoint to the new server (or launch a new DID), which also routes inbound reports to troposphere.
  4. 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).

VariableRequiredDefaultMeaning
LABELER_DIDyesThe labeler’s DID (did:web:... or did:plc:...).
LABELER_SIGNING_KEYyes64-char hex secp256k1 private key. From npm run keygen.
ADMIN_TOKENyesBearer token for POST /admin/labels.
DATABASE_URLyesPostgres connection string.
PORTno4100HTTP port.
REPORT_FORWARD_URLnoBackend endpoint to forward inbound reports to.
REPORT_FORWARD_TOKENnoBearer token sent with forwarded reports.
REPORT_ENRICHnotrueResolve and attach reported content to forwarded reports.
REPORT_FORWARD_FORMATnorawraw (Bearer JSON) or coop (Coop /api/v1/report + X-API-KEY).
COOP_POST_TYPEnoATproto-postCoop item type id for posts (coop format).
COOP_ACCOUNT_TYPEnoATproto-accountCoop item type id for accounts (coop format).
PDS_URLnoYour PDS, for enforcement. Enables POST /admin/enforce.
PDS_ADMIN_PASSWORDnoAdmin password for PDS_URL.

Ingestion worker (npm run ingest) only:

VariableRequiredDefaultMeaning
COOP_ITEMS_URLyesCoop’s /api/v1/items/async endpoint.
COOP_ITEMS_API_KEYyesCoop org API key, sent as X-API-KEY.
JETSTREAM_URLnowss://jetstream2.us-east.bsky.network/subscribeJetstream instance.
JETSTREAM_COLLECTIONSnoapp.bsky.feed.post,app.bsky.actor.profileRecord types to stream.
JETSTREAM_WANTED_DIDSno(all)Restrict to specific accounts.
INGEST_BATCH_SIZEno50Max items per POST.
INGEST_BATCH_INTERVAL_MSno1000Flush interval.

See Ingesting content into Coop.

Endpoints

MethodPathAuthPurpose
GET/healthnoneLiveness. Returns the labeler DID.
POST/admin/labelsbearerCreate and negate labels.
POST/admin/enforcebearerTake down or restore on your PDS.
POST/coop/actionbearerCoop CUSTOM_ACTION shape: label and/or take down.
POST/labelbearerAlias of /coop/action, matching the workshop relay path.
GET/xrpc/com.atproto.label.queryLabelsnoneQuery labels by URI pattern.
GET (WS)/xrpc/com.atproto.label.subscribeLabelsnoneLabel firehose.
POST/xrpc/com.atproto.moderation.createReportservice JWTAccept 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

CommandWhat it does
npm run keygenGenerate a signing key and print the public multibase.
npm run db:initApply the Postgres schema.
npm run ingestStream Jetstream into Coop’s item intake.
npm run devRun with watch (type-stripped, no build step).
npm run buildCompile TypeScript to dist/.
npm startRun the compiled build.
npm run typecheckType-check without emitting.
npm run smokeEnd-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.