Open-source project
bluesky-social/ozone avatar
bluesky-social/ozone

Ozone is the moderator console that Bluesky itself runs on

web interface for labeling content in atproto / Bluesky

543 stars90 forksTypeScriptNOASSERTION

At a glance

What is it?
Ozone is a Next.js web application that talks directly to an Ozone labeling service, and it is the surface where moderators triage reports, take down posts and accounts, create labels and email users. It is also self-hostable, which is the interesting part for anyone who wants to run their own atproto moderation stack.
Who is it for?
Adopt Ozone if you run a moderation service for an atproto application and want a working console rather than raw XRPC calls, and if you are willing to keep it in step with the `@atproto/api` version you deploy against. Do not adopt it as a general purpose admin panel, since the vocabulary, the endpoints and the data model are specific to atproto labelling.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A console for a labelling service, not for a database

Ozone is described as a web interface for labelling content in atproto and Bluesky, and the README's own framing is a labelling service for Bluesky and other atproto apps.

The architectural line in the README matters, because it explains why the project is structured the way it is. Ozone UI is a Next.js web application which talks directly to an Ozone labelling service, and it generally requires moderator or administrator privileges to function.

So there are two halves. The service owns the state and the authority: which content is labelled, which accounts are suspended, which labels exist and what they mean. The UI is a client of that service, and it holds no authority of its own. Every action you take in the interface is an XRPC call to a backend that decides whether you are allowed to make it.

That separation is why the interface can be replaced, self-hosted, or pointed at a different service, and why the README points at atproto.com as the homepage rather than a Bluesky-specific site. Ozone is meant to serve other atproto applications, not only Bluesky.

The feature list shows what a moderation console actually does day to day: viewing, triaging, escalating and actioning moderation reports; takedowns and suspension of content and accounts; creating and modifying labels on content and accounts; viewing invite trees and disabling invite generation; sending moderation emails based on templates; and browsing app.bsky profiles and post threads, including some taken-down content.

That last item is worth pausing on. The console can display content that has been taken down, which means a moderator sees the item they are adjudicating even when it is no longer public. A moderation tool that could not see the reported content would be close to useless.

The interface also has a quick-action modal on Ctrl-K, which is the sort of detail that says the designers assumed moderators doing this all day.

The project is TypeScript, the last push was on 2026-10-20, and the releases are v0.1.105 from 2026-09-11, v0.1.104 from 2026-04-15 and v0.1.103 from 2026-01-27, so it is still on a zero major version.

Labels, takedowns and the invite tree

The vocabulary here is worth understanding before the tooling, because it is specific to atproto rather than to moderation in general.

A label is an annotation attached to content or an account, and the console can both create and modify them. In the Bluesky network, labels are what drive visibility decisions in the client applications: a takedown label on a post means the client should not display it, and a label on an account affects what that account's posts look like elsewhere. So creating a label is not a note you leave for colleagues, it is an instruction that every client in the network acts on.

Takedowns and suspensions are the two heavier operations. A takedown applies to content, a suspension applies to an account, and both are reversible in principle, which is why the interface offers label modification rather than only creation.

The invite tree is a distinct feature and a distinct concept. Bluesky accounts can be created through invitation codes, and each account belongs to a tree of the inviter. The console can view those trees and disable invite generation, which is the control you need when a batch of accounts is causing abuse and the invitations behind them are the mechanism.

Moderation emails round out the workflow. The console can send them based on templates, which is how a takedown notice or an account suspension notice reaches a user in a consistent form. Templates, rather than free text, are what makes that at scale.

Then there is the browsing capability: viewing app.bsky profiles and post threads, including some taken-down content. In practice that means a moderator can open the thread a reported post sits in, read the surrounding conversation and see whether the report is about the single item or about a pattern, and then act.

Put together, the flow the interface supports is: a report arrives, you view the reported content and its context, you escalate it if it needs another pair of eyes, you apply a label or a takedown, and you send the user the notice. Every step in that chain is in the feature list.

Self-hosting it, and what the container does

Self-hosting is a first class path rather than an afterthought. The README says you can self-host Ozone to run your own moderation service, and to start with HOSTING.md, which produces a Dockerized setup for hosting the Ozone UI and backend together. HACKING.md covers local development requirements, including development with a local PDS instance and working with unreleased changes to the `@atproto/api` package.

The Dockerfile in the repository shows how the two halves are built and run:

dockerfile
FROM node:22.23.2-alpine3.23 AS build

WORKDIR /usr/src/ozone

RUN corepack enable

COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn
COPY . .
RUN yarn build
RUN rm -rf node_modules .next/cache
RUN mv service/package.json package.json && mv service/yarn.lock yarn.lock
RUN yarn

# final stage

FROM node:22.23.2-alpine3.23

RUN apk add --update dumb-init
ENV TZ=Etc/UTC

WORKDIR /usr/src/ozone
COPY --from=build /usr/src/ozone /usr/src/ozone
RUN chown -R node:node .

ENTRYPOINT ["dumb-init", "--"]
EXPOSE 3000
ENV OZONE_PORT=3000
ENV NODE_ENV=production
USER node
CMD ["node", "./service"]

Several decisions in those thirty lines are worth naming.

The image runs Node 22 on Alpine, even though the package manifest asks for Node 20.x in its engines field, and the Makefile has an nvm-setup target that installs Node 20. So the declared development version and the shipped runtime version differ, which is a normal choice for a container but worth knowing if you hit a behaviour difference.

The build installs dependencies twice. The first `yarn` run happens before the source is copied, so the layer cache survives source changes, and then the source is copied and the application is built. After the build, `node_modules` and the Next cache are removed to keep the image smaller, and the service's own manifest is moved into place so that a second install produces the backend's dependencies.

That last step is the one that explains the structure: the repository holds the UI and the service as two dependency sets, and the image ends up with the UI built into `.next` and the service's node_modules alongside it.

At run time the container uses `dumb-init` as PID 1 through an ENTRYPOINT, which is the standard way to get signal handling and zombie reaping in a Node container, sets the timezone to UTC explicitly, exposes port 3000, sets `OZONE_PORT` and `NODE_ENV=production`, drops to the unprivileged `node` user after chowning the working directory, and starts the service with `node ./service`.

So the image contains both the UI and the backend, and the entrypoint runs the backend, which in turn serves the interface.

The frontend stack and the command palette

The dependency list describes a serious internal tool rather than a demo, and a few entries are worth calling out.

The framework is Next.js 15.5.24 with React 19.2.5, and the atproto libraries are pinned at a specific set: `@atproto/api` at 0.28.1, `@atproto/oauth-client-browser`, `@atproto/oauth-types` and `@atproto/xrpc` for the RPC layer, plus `@atproto/syntax` for parsing the protocol's text formats. The `oauth-client-browser` entry matters because it means the console authenticates to its own service through the OAuth flow rather than with a hand rolled token, which is the right way for a browser application to talk to an atproto service.

Data fetching goes through `@tanstack/react-query`, which is a sensible choice for a console where most screens are lists that need refetching.

Two libraries tell you what the interface is like to use. `kbar` is a command palette library, and that pairs with the quick-action modal on Ctrl-K mentioned in the feature list, so the console has a keyboard-driven mode for the actions a moderator performs many times a day. And `react-tetris` is in the dependency list, which is a small piece of whimsy in a moderation tool.

The rest of the stack is ordinary and competent. Tailwind for styling with `tailwind-merge` and `react-tailwindcss-select`, `@headlessui/react` and `@heroicons/react` for the accessible primitives and the icon set, `recharts` for the reporting charts, `@uiw/react-md-editor` with `remark` and `remark-html` for writing moderation email templates in markdown, `hls.js` for video playback in threads, `yet-another-react-lightbox` for media in reports, `react-dropzone` for evidence uploads, `react-toastify` for notifications, `lande` for localisation with `bcp-47-match` for locale matching, and `date-fns` for the timestamps.

The `bcp-47-match` entry is a detail that shows care. A moderation console serving a global user base has to display and match locale tags correctly, and getting that right is fiddly enough that a dedicated library is worth having.

The tooling is modern and pinned. The `engines` field asks for Node 20.x, the package manager is `[email protected]` with a `.yarnrc.yml` at the root, and the linter is `eslint-config-next` at 15.2.4 with the config in `.eslintrc.json` and Prettier settings in `.prettierrc`. The Tailwind config, PostCSS config and TypeScript config are all at the root, and there is an `environment.d.ts` for the ambient types.

Development, tests and the atproto toggle script

The scripts section of the package manifest is short and says a lot about how the project is worked on:

json
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "vitest run",
    "type-check": "tsc --noEmit",
    "cypress": "cypress open",
    "e2e:run": "$(yarn bin)/cypress run --browser chrome",
    "link-atproto": "node scripts/toggle-atproto.js --link",
    "unlink-atproto": "node scripts/toggle-atproto.js --unlink"
  },

There are three test layers here. `vitest run` is the unit and component suite, `tsc --noEmit` is the type check run as a separate command, and Cypress covers the end to end path with an interactive `cypress open` for a human and `e2e:run` for the automated run against Chrome. The configuration files are at the root as `vitest.config.ts` and `cypress.config.ts`, and the end to end specs live in `cypress/`.

The last two scripts are the interesting ones. `link-atproto` and `unlink-atproto` call a script that toggles the `@atproto/api` dependency between the published package and a local checkout. That is the mechanism behind the README's note about working with unreleased changes to the `@atproto/api` package, and it is the sort of tool that only matters in a project that has to track a fast moving protocol implementation.

The Makefile is a convenience wrapper, and it opens with a note worth reading:

make
.PHONY: build
build: ## Compile all modules
	yarn build

.PHONY: test
test: ## Run tests
	yarn type-check

.PHONY: run-dev-server
run-dev-server: ## Run a "development environment" shell
	yarn dev

The note in the help output says dependencies between commands are not automatic, for example you must run deps and build first, and after any changes. So the Makefile is a set of independent entry points, not a pipeline, which is a deliberate choice that keeps each target predictable at the cost of ordering discipline.

The `deps` target runs `yarn install --frozen-lockfile`, which is the setting you want in any reproducible build. And `nvm-setup` uses NVM to install and activate Node 20 and then installs yarn globally, which tells you the expected local development version.

The repository layout follows the same split: `app/` for the Next.js routes, `components/` for the React components, `service/` for the backend, `lib/` for shared code, `docs/` for the user guide and the interface screenshot, `public/` and `styles/` for assets, and `scripts/` for the tooling including the atproto toggle.

Contribution rules that are stricter than most

The contributions section is the longest part of the README, and it reads like a moderation team's own policy applied to its own repository.

The project accepts contributions but prioritises high quality issues and pull requests, and says that adhering to the guidelines ensures a more timely review. Then it lists rules, and they are unusually blunt. The maintainers may not respond to your issue or pull request. They may close an issue or pull request without much feedback. They may lock discussions or contributions if their attention is getting flooded. And they do not provide support for build issues.

That last rule is the sharpest one, and it is a real constraint for anyone planning to run this themselves: you are on your own with the build, and the documentation is the support channel.

The guidelines are practical. Check for existing issues before filing a new one. Open an issue and give time for discussion before submitting a pull request. If the pull request includes a lexicon change, get sign off on the lexicon change before doing the implementation, which is a smart rule, because a lexicon change alters the protocol vocabulary and the implementation is wasted if the vocabulary is rejected.

Issues are scoped to bugs and feature requests related to the TypeScript implementation of atproto and related services, with high level discussions pointed at the discussion forum in the main atproto repository instead.

And there is a list of pull request types to stay away from: refactoring large parts of the codebase, adding entirely new features without prior discussion, changing the tooling or frameworks used without prior discussion, and introducing new unnecessary dependencies. Every one of those four is a cost the maintainers did not ask to absorb.

The closing paragraph gives the reason, and it is a good one: the team serves a wide community of users, and their day to day involves constantly asking which top priority is the top priority. The contribution policy is a direct consequence of that triage problem, and it explains why a large well written pull request can still be closed without feedback.

Security issues have their own path: an email to [email protected], which is automatically copied to the entire team, with a promise of a prompt response.

Dual licensing, and what self-hosting actually costs

The licence section is precise, and it is one of the clearer statements of a dual-licence arrangement you will find.

The project is dual-licensed under MIT and Apache 2.0 terms, with both licence files in the repository. Downstream projects and end users may choose either licence individually, or both together, at their discretion. The stated motivation for the dual licensing is the additional software patent assurance provided by Apache 2.0.

So this is a licence choice for downstream users rather than an obligation, and the reason is patents rather than copyright. The optionality is the point: a commercial user who wants the Apache 2.0 patent terms takes them, and a user who prefers MIT takes that.

Bluesky Social PBC has committed to a software patent non-aggression pledge, and the README links the original announcement. That commitment is separate from the code licence and is about the organisation's behaviour rather than the repository's terms, which is worth keeping distinct in your reading of it.

Now the cost side of self-hosting, which the README underplays and you should not. The HOSTING.md path gives you a Dockerized setup for the Ozone UI and backend together, and the Dockerfile builds both into one image, so the deployment is straightforward. But the service is the authoritative half: the labels, the takedowns, the suspensions, the invite trees and the moderator accounts all live there, and the interface is only useful in front of a service you control and that your network's clients will honour.

In practice that means running an atproto service stack, not just a web application. The README points at HACKING.md for local development with a local PDS instance, which tells you the size of the system you are standing up, and the client applications in your network have to be configured to respect your service's labels for a self-hosted label to have any effect.

So the realistic audience for self-hosting Ozone is an atproto application operator who has decided to run their own moderation stack, not someone who wants a nicer admin panel for an existing app.

Editorial conclusion

Adopt Ozone if you run a moderation service for an atproto application and want a working console rather than raw XRPC calls, and if you are willing to keep it in step with the `@atproto/api` version you deploy against. Do not adopt it as a general purpose admin panel, since the vocabulary, the endpoints and the data model are specific to atproto labelling. Verify first which atproto API version your service exposes against the `@atproto/api` version the console pins, that your service account has the moderator or administrator privileges the interface requires, that the labels you create are ones the clients in your network actually render, and that the licence choice, MIT or Apache 2.0 at your discretion, matches what your downstream users expect.

Frequently asked questions

What is Ozone?

Ozone is a labelling service for Bluesky and other atproto apps, with a Next.js web interface that talks directly to the service. It supports viewing, triaging, escalating and actioning moderation reports, takedowns and suspension of content and accounts, creating and modifying labels, viewing invite trees and disabling invite generation, sending moderation emails from templates, and browsing app.bsky profiles and post threads including some taken-down content.

Can I self-host Ozone?

Yes. The README says you can self-host Ozone to run your own moderation service, starting with HOSTING.md, which produces a Dockerized setup for hosting the Ozone UI and backend together. The Dockerfile builds both into one image running on Node 22 on Alpine, exposing port 3000 and starting the service with `node ./service` as an unprivileged user.

What licence is Ozone under?

It is dual-licensed under MIT and Apache 2.0, and downstream projects and end users may choose either individually or both together at their discretion. The stated motivation is the additional software patent assurance provided by Apache 2.0, and Bluesky Social PBC has also committed to a software patent non-aggression pledge.

How do you run the Ozone tests?

There are separate commands for each layer: `yarn test` runs Vitest, `yarn type-check` runs the TypeScript compiler with `--noEmit`, and Cypress covers the end to end path with `cypress open` for interactive use and `e2e:run` to run against Chrome. The root Makefile wraps these, and its note reminds you that dependencies between targets are not automatic.

What does the Ozone project ask of contributors?

The README lists blunt rules: the maintainers may not respond to an issue or pull request, may close one without much feedback, may lock discussions if their attention is flooded, and do not provide support for build issues. Guidelines include checking for existing issues, opening an issue and giving time for discussion before a pull request, getting sign off before implementing a lexicon change, and staying away from large refactors, un-discussed features, tooling changes and unnecessary dependencies.

Official sources

  1. bluesky-social/ozone on GitHub
  2. Issues
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/bluesky-social-ozone.svg)](https://hysenlabs.com/projects/bluesky-social-ozone)

Was this useful?