Add Labels to Your App

Wire your Statusphere app to an Ozone labeler service to display and create labels.

In this tutorial, you'll connect the Statusphere app from the previous tutorial to a labeler service running Ozone. Users will see moderator-issued labels alongside statuses in the feed, and will be able to report statuses to your labeler for review.

Prerequisites

This tutorial builds on the completed Create a Social App tutorial. You should have it running before starting here.

You'll also need a running Ozone instance that you can administer. Follow the Ozone HOSTING guide to stand one up. By the end of that setup you should have:

  • A labeler DID (did:web:... or did:plc:...) with #atproto_labeler and #atproto_label entries in its DID document. This happens when you configure an account as a labeler.
  • A public service URL that responds to GET /xrpc/com.atproto.label.queryLabels?uriPatterns=*. Ozone implements this endpoint.
  • An admin login for the Ozone UI

A quick way to confirm the labeler is reachable:

curl "https://<your-ozone-host>/xrpc/com.atproto.label.queryLabels?uriPatterns=*&limit=1"

On a fresh Ozone install, this should return {"labels":[]}.

Part 1: Wiring the Labeler Into Your App

Add the labeler's DID to your environment:

# .env.local
LABELER_DID=did:plc:wwjhjtgw5ksxrzdswtvnsnjs

Then update the OAuth SCOPE constant in lib/auth/client.ts so your app can submit reports through the user's PDS:

const LABELER_DID = process.env.LABELER_DID!;

export const SCOPE = `atproto repo:xyz.statusphere.status rpc:com.atproto.moderation.createReport?aud=${LABELER_DID}#atproto_labeler`;

You'll need to log out and log back in for the new scope to take effect on existing sessions.

Part 2: Querying Labels

Labels live on the labeler service, not in any user's repo. You'll query the labeler directly each time the feed renders.

You'll be making typed XRPC calls to both the labeler (to read labels) and the user's PDS (to submit reports), so install both lexicons up front:

lex install com.atproto.label.queryLabels com.atproto.moderation.createReport
lex build --importExt=\"\" --override

Now create lib/labels.ts:

import { Client } from "@atproto/lex";
import { getHandle, getServiceEndpoint } from "@atproto/common-web";
import * as com from "@/src/lexicons/com";
import { getTap } from "@/lib/tap";

const LABELER_DID = process.env.LABELER_DID!;

export interface Label {
  src: string;
  srcHandle?: string;
  uri: string;
  val: string;
  cts: string;
  neg?: boolean;
}

let _labelerEndpoint: Promise<string> | null = null;
async function getLabelerEndpoint(): Promise<string> {
  if (!_labelerEndpoint) {
    _labelerEndpoint = (async () => {
      const didDoc = await getTap().resolveDid(LABELER_DID);
      if (!didDoc) throw new Error(`Could not resolve ${LABELER_DID}`);
      const url = getServiceEndpoint(didDoc, {
        id: "#atproto_labeler",
        type: "AtprotoLabeler",
      });
      if (!url) {
        throw new Error(`No #atproto_labeler service on ${LABELER_DID}`);
      }
      return url;
    })();
  }
  return _labelerEndpoint;
}

async function resolveSrcHandle(did: string): Promise<string | undefined> {
  try {
    const didDoc = await getTap().resolveDid(did);
    return didDoc ? getHandle(didDoc) : undefined;
  } catch {
    return undefined;
  }
}

export async function getLabelsForUris(
  uris: string[],
): Promise<Map<string, Label[]>> {
  const byUri = new Map<string, Label[]>();
  if (uris.length === 0) return byUri;

  const endpoint = await getLabelerEndpoint();
  const lexClient = new Client(endpoint);

  const { labels } = await lexClient.call(com.atproto.label.queryLabels, {
    uriPatterns: uris,
  });

  const uniqueSrcs = [...new Set(labels.map((l) => l.src))];
  const handles = await Promise.all(uniqueSrcs.map(resolveSrcHandle));
  const srcHandles = new Map(uniqueSrcs.map((src, i) => [src, handles[i]]));

  for (const label of labels) {
    if (label.neg) continue;
    const list = byUri.get(label.uri) ?? [];
    list.push({ ...label, srcHandle: srcHandles.get(label.src) });
    byUri.set(label.uri, list);
  }
  return byUri;
}

export function isHidden(labels: Label[] | undefined): boolean {
  return !!labels?.some((l) => l.val === "!hide");
}

export function isWarned(labels: Label[] | undefined): boolean {
  return !!labels?.some((l) => l.val === "!warn");
}

export function displayLabels(labels: Label[] | undefined): Label[] {
  return (labels ?? []).filter((l) => !l.val.startsWith("!"));
}

A few things worth pointing out:

  • The endpoint URL comes from the labeler's DID document. The DID is the trust anchor — its document is signed and tells you where the #atproto_labeler service actually lives. Tap caches the underlying DID resolution.
  • The lex Client is constructed against that resolved URL and used unauthenticated — queryLabels is a public endpoint.
  • Each label's src is the DID of the labeler that issued it. Those are resolved to handles (deduped per call) and attached to each Label as srcHandle, so the UI can show who labeled a status rather than just an opaque DID.

The three helpers (isHidden, isWarned, displayLabels) interpret the values. !hide and !warn are system-level labels with prescribed UI behavior; anything else is a descriptive label like spam or joke that you can render however you want.

Part 3: Capturing Record CIDs

A report subject needs both the URI and the CID of the record being reported. Tap webhook events already carry the CID alongside everything else, but the existing schema drops it. You'll extend the status table to keep it.

Add the cid column to the StatusTable interface in lib/db/index.ts:

export interface StatusTable {
  uri: string;
  cid: string; // New
  authorDid: string;
  status: string;
  createdAt: string;
  indexedAt: string;
  current: 0 | 1;
}

Add a new migration in lib/db/migrations.ts:

"003": {
  async up(db: Kysely<unknown>) {
    await db.schema
      .alterTable("status")
      .addColumn("cid", "text", (col) => col.notNull().defaultTo(""))
      .execute();
  },
  async down(db: Kysely<unknown>) {
    await db.schema.alterTable("status").dropColumn("cid").execute();
  },
},

Run pnpm migrate to apply it. The default empty string is for any pre-existing rows — new statuses will get the real CID from the webhook.

Update the insertStatus call in app/api/webhook/route.ts to pass the CID through:

await insertStatus({
  uri: uri.toString(),
  cid: evt.cid ?? "", // New
  authorDid: evt.did,
  status: record.status,
  createdAt: record.createdAt,
  indexedAt: new Date().toISOString(),
  current: 1,
});

getRecentStatuses already uses selectAll(), so the new column flows out to the feed page without further changes.

Part 4: Reporting Statuses

Create app/api/report/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { Client, type AtUriString, type CidString } from "@atproto/lex";
import { getSession } from "@/lib/auth/session";
import { getOAuthClient } from "@/lib/auth/client";
import * as com from "@/src/lexicons/com";

const LABELER_DID = process.env.LABELER_DID!;

export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { uri, cid, reason } = await request.json();
  if (!uri || typeof uri !== "string" || !cid || typeof cid !== "string") {
    return NextResponse.json(
      { error: "uri and cid are required" },
      { status: 400 },
    );
  }

  const client = await getOAuthClient();
  const oauthSession = await client.restore(session.did);
  const lexClient = new Client(oauthSession);

  await lexClient.call(
    com.atproto.moderation.createReport,
    {
      reasonType: "com.atproto.moderation.defs#reasonOther",
      reason: typeof reason === "string" ? reason : undefined,
      subject: {
        $type: "com.atproto.repo.strongRef",
        uri: uri as AtUriString,
        cid: cid as CidString,
      },
    },
    {
      service: `${LABELER_DID}#atproto_labeler`,
    },
  );

  return NextResponse.json({ success: true });
}

The route reads both uri and cid from the request body, so no record lookup is needed at report time. The service: '${LABELER_DID}#atproto_labeler' option tells the lex Client to set the atproto-proxy header on the outgoing request, so the user's PDS forwards the report to your Ozone instance.

Then add components/ReportButton.tsx:

"use client";

import { useState } from "react";

export function ReportButton({ uri, cid }: { uri: string; cid: string }) {
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);

  async function handleClick() {
    const reason = window.prompt("Reason for report (optional)") ?? undefined;
    setSending(true);
    try {
      const res = await fetch("/api/report", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ uri, cid, reason }),
      });
      if (!res.ok) {
        const body = await res.text();
        throw new Error(`Report failed (${res.status}): ${body}`);
      }
      setSent(true);
    } catch (err) {
      console.error(err);
      window.alert("Could not submit report");
    } finally {
      setSending(false);
    }
  }

  if (sent) {
    return (
      <span className="text-xs text-zinc-400 dark:text-zinc-500">reported</span>
    );
  }

  return (
    <button
      onClick={handleClick}
      disabled={sending}
      className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 disabled:opacity-50"
    >
      {sending ? "..." : "report"}
    </button>
  );
}

Part 5: Displaying Labels in the Feed

Now wire everything together. Update app/page.tsx to fetch labels for the visible statuses, drop any that are hidden, dim any that are warned, render the rest of the label values as badges, and surface the ReportButton on each row. Only the changed sections are shown below — the header, session block, and top statuses block from the previous tutorial are unchanged:

import { getSession } from "@/lib/auth/session";
import {
  getAccountStatus,
  getRecentStatuses,
  getTopStatuses,
  getAccountHandle,
} from "@/lib/db/queries";
import {
  getLabelsForUris,
  isHidden,
  isWarned,
  displayLabels,
} from "@/lib/labels";
import { LoginForm } from "@/components/LoginForm";
import { LogoutButton } from "@/components/LogoutButton";
import { StatusPicker } from "@/components/StatusPicker";
import { ReportButton } from "@/components/ReportButton";

export default async function Home() {
  const session = await getSession();
  const [statuses, topStatuses, accountStatus, accountHandle] =
    await Promise.all([
      getRecentStatuses(),
      getTopStatuses(),
      session ? getAccountStatus(session.did) : null,
      session ? getAccountHandle(session.did) : null,
    ]);

  const labelsByUri = await getLabelsForUris(statuses.map((s) => s.uri));
  const visibleStatuses = statuses.filter(
    (s) => !isHidden(labelsByUri.get(s.uri)),
  );

  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <main className="w-full max-w-md mx-auto p-8">
        {/* ... existing header / session block / top statuses unchanged ... */}

        <div className="bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 p-6">
          <h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">
            Recent
          </h3>
          <ul className="space-y-3">
            {visibleStatuses.map((s) => {
              const labels = labelsByUri.get(s.uri);
              const warn = isWarned(labels);
              const tags = displayLabels(labels);
              return (
                <li key={s.uri} className="flex items-center gap-3">
                  <span
                    className={`text-2xl ${warn ? "opacity-30" : ""}`}
                    title={warn ? "Flagged by moderator" : undefined}
                  >
                    {s.status}
                  </span>
                  <span className="text-zinc-600 dark:text-zinc-400 text-sm">
                    @{s.handle}
                  </span>
                  {tags.map((l) => (
                    <span
                      key={l.val}
                      title={`Labeled by @${l.srcHandle ?? l.src}`}
                      className="text-xs px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
                    >
                      {l.val}
                    </span>
                  ))}
                  {session && s.cid && (
                    <ReportButton uri={s.uri} cid={s.cid} />
                  )}
                  <span className="text-zinc-400 dark:text-zinc-500 text-xs ml-auto">
                    {timeAgo(s.createdAt)}
                  </span>
                </li>
              );
            })}
          </ul>
        </div>
      </main>
    </div>
  );
}

// timeAgo helper unchanged

Before testing, you should still have tap running from the previous tutorial:

tap run \
  --webhook-url=http://localhost:3000/api/webhook \
  --collection-filters=xyz.statusphere.status \
  --signal-collection=xyz.statusphere.status

Restart pnpm dev. Logged-in users should see a small "report" affordance next to other users' statuses. Submitting a report sends it through their PDS to your labeler.

Open the Ozone UI, find the report in the queue, and apply a label — either a custom value like spam, or one of the system values !warn or !hide.

Refresh the feed in your app and the label should appear (or hide the status entirely).

A thumbs-up emoji can be considered very graphic!

Conclusion

You now have an atproto app that consumes signed labels from a labeler service and lets users submit reports to it. Querying on every render works for a small app, but production-shaped consumption should use the WebSocket stream at com.atproto.label.subscribeLabels to keep a local cache up to date — that's the next step from here.

This setup also doesn't assume anything Bluesky-specific: the same Ozone instance, the same protocol endpoints, and the same client patterns work for any atproto app that wants community moderation.

Next, read more about Labels.