# 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](https://standard.mode.place/guides/render-themes/).) 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.

:::note[What you're working with]
A theme is a **record** on an account, like a Bluesky post — you read and write it with `@atproto/api`. Field shapes are in the [Schema reference](https://standard.mode.place/reference/schema/); how apps turn your record into a rendered look is the [Mode Contract](https://standard.mode.place/contract/); how to name the concept your theme serves is [Common expressions](https://standard.mode.place/reference/expressions/).
:::

:::caution[Pre-v1 — the lexicons are not yet published]
Everything in this guide works today — a PDS happily stores records for a lexicon it doesn't know. But the `place.mode.standard.*` schemas are still **in review and unpublished**, gathering **community feedback before v1** — so field shapes can still change, and records you publish now may need migrating. If that trade-off matters to you, watch or weigh in on the [lexicons repo](https://tangled.org/mode.place/lexicons) first.
:::

You need an AT Protocol account; the app doing the publishing acts on your behalf through [AT Protocol OAuth](https://atproto.com/specs/oauth).

```ts
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 session
if (!restored) await oauth.signIn("alice.example"); // redirects out; init() finishes it on return
const 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](https://standard.mode.place/reference/schema/).)

:::caution[Why step 2 matters — your host probably won't validate]
A PDS validates a record only against lexicons it knows, and most hosts only know the official families (`app.bsky.*`, `chat.bsky.*`) — a `place.mode.standard.*` record is written through **as-is**, even if it's malformed or missing intents. (Passing `validate: true` to `putRecord` doesn't help on a host that can't resolve this lexicon; it errors as unknown rather than validating.) An invalid record that slips through won't break consumers — a conforming app ignores an incomplete aspect and falls through to the next source ([Mode Contract, Rule 6](https://standard.mode.place/contract/#rule-6--aspects-are-optional-but-each-is-complete)) — but it means your look silently doesn't show. That's why the workflow above runs the generated validator before every `putRecord`. Catch it on your side, loudly.
:::

:::note[Where the schemas canonically live]
Your vendored JSON — and the module you generate from it — are snapshots of records **on the network**: each lexicon is published on the authority's account (collection `com.atproto.lexicon.schema`, record key = the NSID), and the DNS TXT record `_lexicon.standard.mode.place` establishes which account that is — see the [Colophon](https://standard.mode.place/colophon/). Vendoring is the conventional way to consume them — like any dependency, and since the standard grows additively, a snapshot never invalidates. Re-sync from the repo or the network and re-generate whenever you want the source of truth.
:::

:::note[Why copy instead of alias?]
The expression is the record key, so a theme serves exactly one concept — and a repo can hold only one theme per key, which is what makes `dark` (and `default`) **unambiguous by construction**. A look that answers to several markers is several self-contained records carrying the same values; keeping them in sync is your build step's job, not the protocol's. Nothing references anything, so nothing can dangle — and if a build drops an intent, your pre-publish check (the caution above) catches it on your side, instead of quietly breaking apps downstream.
:::

## Next steps

- **[Render themes in your app](https://standard.mode.place/guides/render-themes/)** — the other side: how apps ingest looks like yours.
- **[Common expressions](https://standard.mode.place/reference/expressions/)** — naming the concept your theme serves.
- **[Schema reference](https://standard.mode.place/reference/schema/)** — every field of the record you just wrote.
- **[Verification](https://standard.mode.place/guides/verification/)** — publishing as a brand or domain? Prove the records are yours.