Publish a theme
This guide is for theme makers: you have a look, and you want it saved to your account where any conforming app can ingest it. (Building the app side? See Render themes in your app.) It uses the AT Protocol directly — there’s no SDK of ours to learn, just the official packages (@atproto/api, and @atproto/oauth-client-browser to sign in) plus a typed module you generate yourself from the standard’s schemas with @atproto/lex-cli, exactly as you would for any lexicon.
You need an AT Protocol account; the app doing the publishing acts on your behalf through AT Protocol OAuth.
import { Agent } from "@atproto/api";import { BrowserOAuthClient } from "@atproto/oauth-client-browser";// A typed module generated from the standard's schemas — vendor the JSON from the// lexicons repo at tangled.org/mode.place (lexicons/place/mode/standard/), then:// npx @atproto/lex-cli gen-api ./src/lexicon ./lexicons/place/mode/standard/*.json// The output embeds the schemas and exports a typed Record plus validators — the// same generated-validator pattern every Bluesky record type uses.import * as Theme from "./src/lexicon/types/place/mode/standard/theme";
// Sign in with AT Protocol OAuth — the user authorizes this app against their own PDS.// clientId is the URL of your hosted client-metadata document (see the OAuth spec;// during local dev a special loopback clientId works without hosting one).const oauth = await BrowserOAuthClient.load({ clientId: "https://yourapp.example/client-metadata.json", handleResolver: "https://bsky.social",});const restored = await oauth.init(); // completes the callback, or restores a saved sessionif (!restored) await oauth.signIn("alice.example"); // redirects out; init() finishes it on returnconst agent = new Agent(restored!.session); // acts as the signed-in user
// 1. Build the theme. Here the look serves "dark".const aspects = { color: { control: { background: "#0b1020", foreground: "#e6e9f5", border: "#1c2540" }, action: { primary: {/* … */}, secondary: {/* … */}, auxiliary: {/* … */} }, surface: { primary: {/* … */}, secondary: {/* … */}, auxiliary: {/* … */} }, },};
const record: Theme.Record = { $type: "place.mode.standard.theme", displayName: "Midnight Ride", // optional, presentation only — identity is the key description: "My dark look.", // optional aspects, createdAt: new Date().toISOString(),};
// 2. Validate BEFORE writing — most hosts won't do it for you (see the caution below).const valid = Theme.validateRecord(record);if (!valid.success) throw valid.error; // names the exact path that's wrong, before it's on the network
// 3. Save it. THE RECORD KEY IS THE EXPRESSION it serves — so use putRecord with an// explicit rkey (not createRecord's auto-generated tid). Exactly one record per key, per repo.await agent.com.atproto.repo.putRecord({ repo: agent.assertDid, collection: "place.mode.standard.theme", rkey: "dark", // ← the expression IS the key (unique per repo) record,});
// One look, several names? Write a self-contained COPY at each key. There's no// alias field — reuse is YOUR tooling's job (one source of truth → one record per// key). To make this same look your baseline, copy the values to the "default" key:await agent.com.atproto.repo.putRecord({ repo: agent.assertDid, collection: "place.mode.standard.theme", rkey: "default", // reserved: your baseline look record, // the copy carries the same values throughout});
// 4. Optional: declare participation. The profile is a singleton (record key "self").// It carries no theme pointer — your baseline is simply the theme at key "default"// (above). This record just marks that you're in (and anchors domain verification).await agent.com.atproto.repo.putRecord({ repo: agent.assertDid, collection: "place.mode.standard.profile", rkey: "self", record: { $type: "place.mode.standard.profile", createdAt: new Date().toISOString(), },});Your look now lives on your account and travels with you. (A theme may be color-only, typography-only, or both — but each aspect you include must be complete. See the Schema reference.)
Next steps
Section titled “Next steps”- Render themes in your app — the other side: how apps ingest looks like yours.
- Common expressions — naming the concept your theme serves.
- Schema reference — every field of the record you just wrote.
- Verification — publishing as a brand or domain? Prove the records are yours.