# Publish a theme with code

:::tip[Don't want to write code?]
[The studio](https://standard.mode.place/studio/) does everything below in the browser: pick your values, check the record, save it to your account. See [Make a theme in the studio](https://standard.mode.place/docs/guides/make-a-theme/). This page is the same job from a script, and it's what you want if you're generating themes, building a design tool, or publishing your app's own look.
:::

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/docs/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/docs/reference/schema/); how apps turn your record into a rendered look is the [Mode Contract](https://standard.mode.place/docs/contract/); how to name the concept your theme serves is [Common expressions](https://standard.mode.place/docs/reference/expressions/).
:::

:::note[v1: the schemas are settled]
The `place.mode.standard.*` schemas are frozen at v1, so a theme you write today stays valid: evolution from here is **additive-only**, new optional fields and new aspects, never a removal or a tightened constraint. Browse the JSON on the [lexicons repo](https://tangled.org/mode.place/lexicons), or fetch it live off the network (see below).
:::

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. The schemas are published as
// com.atproto.lexicon.schema records on mode.place's own account, so fetch them from
// there (repo: did:plc:rvraq37xlwydvov7laldai6a, rkey: the NSID) and write each `value`
// to a local file, then:
//   npx @atproto/lex-cli gen-api ./src/lexicon/generated ./src/lexicon/schemas/**/*.json
// Keeping the fetched schemas and the generated code apart is worth the extra folder:
// one is the standard as published, the other is disposable output. This site does
// exactly this: see its scripts/sync-lexicons.mjs.
import * as Theme from "./src/lexicon/generated/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/docs/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/docs/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]
Each lexicon is published as a record on the authority's own account: collection `com.atproto.lexicon.schema`, record key = the NSID. The DNS TXT record `_lexicon.standard.mode.place` establishes which account that is; see the [Colophon](https://standard.mode.place/docs/colophon/). That means there's no separate repo to clone: fetch the three records with an ordinary `getRecord` call, same as fetching a theme, and write each `value` to a local file before running `gen-api` on it. Since the standard grows additively, nothing you fetch today stops working later; re-fetch and re-generate whenever you want the latest.
:::

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