# Build your website with your agent

> Your newsletter deserves a home that looks like you wrote it, not like a template chose for you. Describe it to your agent and it writes the pages: plain HTML, CSS and JavaScript you own outright, hosted by Commune on your own domain, with your articles, your archive and your subscribe button already wired in. There is no editor and no builder, which also means there is no undo, so keep the site in a repository of your own.

How: with your agent (One conversation), or with the API (About 30 minutes).

What you will have:

- A live website on your own domain, or on a Commune subdomain until you have one.
- An article list and an article page that serve every issue you publish, including the ones you have not written yet.
- A subscribe button that signs a reader up in one click without leaving your page.
- Pages you can hand to any agent, or edit by hand, because they are just files.

**Warning:** Commune keeps the page you last wrote and nothing else. There is no version history and no operation that returns a previous page, so keep your site in a git repository and write pages out of it. That repository is the only history there is, and it is what turns an overwrite from a loss into a diff.

There are 2 ways to do this. Pick one: with your agent, or with the API.

## With your agent

Connect your agent once, then describe the site. It reads Commune's own reference for the page kinds and the URL grammar before it writes anything, so the first attempt is a working site rather than a round of refusals, and it writes every page as a draft you can look at before it goes live.

Connect once: add the MCP server at `https://api.usecommune.com/mcp` to your agent and approve the consent screen ([how](https://usecommune.dev/guides/mcp)). Then ask:

```text
Build a website for my Commune newsletter, in a new git repo in this folder.

Three pages: an article list at /, an article page at /posts/:article, and an about page at /about. Quiet and typographic, one column, serif headings, works on a phone. Use my newsletter's own articles, not placeholders.

Put them up as drafts first and give me the preview links. Once I say yes, publish them and point blog.example.org at the site. Commit after each page.
```

The tools it uses:

- `build_my_website`: Writes one page: its kind, its path and its whole document. `newsletter` is the article list, `article` is a URL pattern that serves every issue, and `page` is any fixed path. It answers with a signed `preview_url` for a draft, which is the only way to look at one.
- `what_is_on_my_website`: Everything the site is made of: each page's kind, path, state and weight, plus every hostname with its verification, its routing and which Commune origin answers for it. The tool for "is it live" and for "my domain is not loading", which are the same question from two ends.
- `commune_write`: Everything build_my_website does not: publishing and unpublishing a page, editing one with the whole new document, minting the publishable key the page reads data with, and pointing a hostname at the site with `mode` set to `site`.
- `commune_read`: Reads a page's HTML back, for an agent that has lost its local copy, and reads your articles so the pages it writes talk about real issues.

What comes back, for example:

**Your site is up.** Three pages, all published:

| Page | Address | Serves |
|---|---|---|
| Article list | https://blog.example.org | your 42 issues, newest first |
| Article | /posts/:article | every issue, including future ones |
| About | /about | one page |

- **blog.example.org** is verified, routing and in `site` mode, so the name and avatar in Commune now link here.
- I minted a publishable key (`cmn_pk_7Qa2Lk9x…`) and put it in the article list's script tag. It is read-only and safe in page source.
- Committed as `a1c93f0`. If you want yesterday's homepage back, that is `git revert` and one more write.

Worth asking next:

- "Add a page for my paid archive, linking only the issues that are public."
- "The article page is too wide on a phone. Make the measure 34rem and bump the line height."
- "Which page is bringing in the most subscribers?"

Tips:

- Ask for drafts first. A draft has no public address at all: the signed preview link is the only way in, so nothing half-finished is ever reachable, and publishing is one more call.
- Keep the site in a repository and tell your agent to commit after every page. Commune stores no history, so the repository is the history, and an agent that overwrote the wrong page can put the previous commit back.
- Say what you want it to feel like, not what elements to use. There is no template to fight: the agent writes the markup and the CSS, so "quiet, typographic, one column" is a more useful instruction than a list of divs.

## With the API

Four requests put a site up: look at what is there, write the article list, write the article template, and mint the key the pages read data with. A fifth points your own hostname at it. Every page is one document in one request, and there is no build step, so a page is live the moment it commits.

Before you start:

- An API key from https://usecommune.com/settings/api-keys with: content: write, settings: write. Export it as `COMMUNE_API_KEY`.
- A hostname you own, if you want the site on your own domain. Add it and prove ownership in Commune under Settings, then the last step hands it to the website origin. Without one, the site serves at `<handle>.communepages.com`.
- A git repository for the pages. Commune keeps no history of a page, so this is the only history there is.
- Some steps need more than this. Each one lists it where it starts.

Every request sends `Authorization: Bearer $COMMUNE_API_KEY` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/build-your-website/collection.json

### 1. See what is already there

The index of the site: every page with its kind, its path, its state and the weight of its document, drafts included. The documents are not in it, because a page holds up to 2MB and a site holds many.

A newsletter with no website answers with an empty list, which is the default rather than an error.

`GET /newsletters/{newsletter}/website-pages` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewslettersitepages))

```sh
curl "https://api.usecommune.com/newsletters/example-letter/website-pages" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### 2. Write the article list

`kind` is `newsletter`: the article list, at most one per newsletter, and its path is where a reader who clicks your name or avatar inside Commune arrives. Writing it needs `settings: write` as well as `content: write`, because it moves where Commune sends readers.

The document is yours, byte for byte. Commune splices the title, the Open Graph tags, the canonical URL, JSON-LD and a preloaded `commune-data` payload in as it serves the page, never as it stores it, so reading the page back returns exactly what you sent.

`<commune-article-list>` is filled from that payload, which means the list renders with no request and no key. The `data-commune-key` attribute is for what the page asks for afterwards: a second page of the archive, another article.

**There is no undo.** Commune keeps the document you last wrote and nothing else. Commit this HTML to your own repository before you send it: a second `POST` to the same path answers `409`, and a `PATCH` replaces the document with no way back.

`POST /newsletters/{newsletter}/website-pages` ([reference](https://api-reference.usecommune.dev/operation/operation-createsitepage))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/website-pages" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: site-page-list-2026-10-12" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "newsletter",
  "path": "/",
  "status": "published",
  "meta": {
    "title": "The Example Letter",
    "description": "A weekly letter about how newsletters and communities fit together."
  },
  "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/styles.css\">\n  <script type=\"module\" src=\"/.commune/commune.js\"\n          data-commune-key=\"cmn_pk_7Qa2Lk9xBv3mNd8sTr4wYc6zQh1jFp5K\"></script>\n</head>\n<body>\n  <header><h1>The Example Letter</h1></header>\n  <main>\n    <commune-article-list></commune-article-list>\n    <commune-subscribe label=\"Get it by email\"></commune-subscribe>\n  </main>\n</body>\n</html>"
}'
```

Response `201`:

```json
{
  "object": "site_page",
  "id": "2b7f9c10-4d5e-4a6b-8c7d-9e0f1a2b3c4d",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "kind": "newsletter",
  "path": "/",
  "status": "published",
  "blocked": false,
  "meta": {
    "title": "The Example Letter",
    "description": "A weekly letter about how newsletters and communities fit together."
  },
  "byte_size": 727,
  "url": "https://blog.example.org",
  "preview_url": null,
  "published_at": "2026-10-12T09:00:00Z",
  "created_at": "2026-10-12T09:00:00Z",
  "updated_at": "2026-10-12T09:00:00Z",
  "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/styles.css\">\n  <script type=\"module\" src=\"/.commune/commune.js\"\n          data-commune-key=\"cmn_pk_7Qa2Lk9xBv3mNd8sTr4wYc6zQh1jFp5K\"></script>\n</head>\n<body>\n  <header><h1>The Example Letter</h1></header>\n  <main>\n    <commune-article-list></commune-article-list>\n    <commune-subscribe label=\"Get it by email\"></commune-subscribe>\n  </main>\n</body>\n</html>"
}
```

### 3. Write one page that serves every article

`kind` is `article` and `path` is a **URL pattern**, not an address. One document serves every issue you have published and every issue you have not written yet.

A token is a whole path segment. `:article` is required; `:year`, `:month`, `:day` and `:id` are the rest, and there is nothing else: a token outside that list answers `422`, and two patterns that could both serve one URL answer `409`. A fixed path always beats a pattern, so `/about` and `/:article` coexist and `/about` is the page.

This one is written as a `draft`, which has **no public address at all**. Not a 404 page, not a placeholder: nothing is served at its path. The signed `preview_url` in the response opens that one page of that one newsletter and expires after a week.

`POST /newsletters/{newsletter}/website-pages` ([reference](https://api-reference.usecommune.dev/operation/operation-createsitepage))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/website-pages" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: site-page-article-2026-10-12" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "article",
  "path": "/posts/:article",
  "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/styles.css\">\n  <script type=\"module\" src=\"/.commune/commune.js\"></script>\n</head>\n<body>\n  <main>\n    <commune-article></commune-article>\n  </main>\n  <footer><a href=\"/\">All issues</a></footer>\n</body>\n</html>"
}'
```

Response `201`:

```json
{
  "object": "site_page",
  "id": "4d9b1e32-6f70-4c8d-8e9f-1a2b3c4d5e6f",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "kind": "article",
  "path": "/posts/:article",
  "status": "draft",
  "blocked": false,
  "meta": {},
  "byte_size": 414,
  "url": null,
  "preview_url": "https://blog.example.org/?preview=eyJwIjoiNGQ5YjFlMzIifQ.Zm9vYmFy",
  "published_at": null,
  "created_at": "2026-10-12T09:02:00Z",
  "updated_at": "2026-10-12T09:02:00Z",
  "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/styles.css\">\n  <script type=\"module\" src=\"/.commune/commune.js\"></script>\n</head>\n<body>\n  <main>\n    <commune-article></commune-article>\n  </main>\n  <footer><a href=\"/\">All issues</a></footer>\n</body>\n</html>"
}
```

### 4. Put it live

Live immediately: there is no deploy step and no build. `published_at` is stamped the first time and never moved again, so unpublishing and publishing a page keeps the date it first went up, and `status` is what says whether it is live now.

From here on, editing the page is the only publish it needs. A `PATCH` to a published page is live on commit.

`POST /website-pages/{page}/publish` ([reference](https://api-reference.usecommune.dev/operation/operation-publishsitepage))

```sh
curl -X POST "https://api.usecommune.com/website-pages/4d9b1e32-6f70-4c8d-8e9f-1a2b3c4d5e6f/publish" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: site-page-article-publish-2026-10-12"
```

Response `200`:

```json
{
  "object": "site_page",
  "id": "4d9b1e32-6f70-4c8d-8e9f-1a2b3c4d5e6f",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "kind": "article",
  "path": "/posts/:article",
  "status": "published",
  "blocked": false,
  "meta": {},
  "byte_size": 414,
  "url": null,
  "preview_url": null,
  "published_at": "2026-10-12T09:05:00Z",
  "created_at": "2026-10-12T09:02:00Z",
  "updated_at": "2026-10-12T09:05:00Z",
  "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/styles.css\">\n  <script type=\"module\" src=\"/.commune/commune.js\"></script>\n</head>\n<body>\n  <main>\n    <commune-article></commune-article>\n  </main>\n  <footer><a href=\"/\">All issues</a></footer>\n</body>\n</html>"
}
```

### 5. Mint the key your pages read data with

A **publishable key**, `cmn_pk_`-prefixed because it is meant to be public: it ships inside HTML anybody can read, and the prefix is what makes that recognisable to you, to a reviewer and to a secret scanner.

What makes it safe is what it can reach, which is this one newsletter's already-published articles, from this newsletter's own website origins, inside a rate limit. Never an email address, never a draft, never an article restricted to a tag, and never another newsletter.

`allowed_origins` are **extra** origins, for a development machine. Your website's own origins are always accepted, which is why leaving it empty means "the real website and nowhere else" rather than "nowhere".

**The secret is in this response and nowhere else.** Commune keeps only a digest of it. No later call returns it, including a list, so a key you have lost is a key you rotate.

`POST /newsletters/{newsletter}/publishable-keys` ([reference](https://api-reference.usecommune.dev/operation/operation-createpublishablekey))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/publishable-keys" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: publishable-key-2026-10-12" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Website",
  "allowed_origins": [
    "http://localhost:5173"
  ]
}'
```

Response `201`:

```json
{
  "object": "publishable_key",
  "id": "7081e165-92a3-4f10-9123-4d5e6f708192",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "name": "Website",
  "key_prefix": "cmn_pk_7Qa2Lk9x",
  "secret": "cmn_pk_7Qa2Lk9xBv3mNd8sTr4wYc6zQh1jFp5K",
  "allowed_origins": [
    "http://localhost:5173"
  ],
  "origins": [
    "https://blog.example.org",
    "https://example-letter.communepages.com"
  ],
  "rate_limit_per_minute": 600,
  "monthly_request_quota": null,
  "created_at": "2026-10-12T09:07:00Z",
  "last_used_at": null,
  "revoked_at": null
}
```

### 6. Point your own hostname at it

You will need:

- A hostname already added to this newsletter and verified. Adding one and proving ownership are not operations in this API.

A hostname serves one of two Commune origins and `mode` says which. `app` is the Commune newsletter page, which is where every hostname starts and where readers sign in, chat and highlight. `site` is your own pages.

**One host can carry a session or run your JavaScript, never both.** The origin is the only security boundary a browser has: code on an origin that holds a session can act as whoever is reading. That is why this is a mode rather than two features on one hostname, and why switching to `site` makes Commune clear the cookies and storage it left there.

Nothing is reprovisioned. The certificate, the ownership records and the target your DNS points at are untouched: what changes is which origin answers.

`PATCH /domains/{domain}` ([reference](https://api-reference.usecommune.dev/operation/operation-updatedomain))

```sh
curl -X PATCH "https://api.usecommune.com/domains/8f1c3a52-7b90-4d1e-9f2a-3b4c5d6e7f80" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: domain-site-mode-2026-10-12" \
  -H "Content-Type: application/json" \
  -d '{
  "mode": "site"
}'
```

Response `200`:

```json
{
  "object": "domain",
  "id": "8f1c3a52-7b90-4d1e-9f2a-3b4c5d6e7f80",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "domain": "blog.example.org",
  "mode": "site",
  "verification_status": "active",
  "ssl_status": "active",
  "dns_target": "origin.communepages.com",
  "verification_records": [],
  "verification_error": null,
  "routing_ok": true,
  "routing_checked_at": "2026-10-12T09:10:00Z",
  "verified_at": "2026-09-30T18:04:00Z",
  "last_checked_at": "2026-10-12T09:10:00Z",
  "created_at": "2026-09-30T17:58:00Z"
}
```

### 7. Read the data yourself, if the elements are not what you want

`commune.js` is headless. The elements above are a convenience; the data is the contract, and every page Commune serves already has it. `commune.articles.list()` answers from the page when the page can answer, which on your article list is the first call and every call that fits inside it, so the common case costs no request at all.

Chat and highlights on a website are **not in v1**. They need a visitor token, which identifies the person rather than the site, and the publishable key reads published articles only. `commune.thread()` and `commune.highlights()` refuse and say so.

The whole SDK you are likely to need:

```html
<script type="module" src="/.commune/commune.js"
        data-commune-key="cmn_pk_7Qa2Lk9x..."></script>
<script type="module">
  // Already in the page. No request, no key needed.
  const { articles, total, from } = await commune.articles.list();
  console.log(from, total);          // "page" 42

  // The article this page was served with.
  const article = await commune.article();
  document.querySelector("#body").innerHTML = article.body_html;

  // Past what the page carries: one request, with the key.
  const older = await commune.articles.list({ limit: 20, offset: 20 });

  // From a click, so the browser knows a person asked.
  document.querySelector("#join").addEventListener("click", async () => {
    const { outcome } = await commune.subscribe();
    if (outcome === "subscribed") location.hash = "#thanks";
  });
</script>
```

### All together

The whole thing as one program: write both pages out of a local directory, publish them, mint the key. Run it from the repository that holds your site, and commit before you run it.

```js
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const API = "https://api.usecommune.com";
const NEWSLETTER = "example-letter";

async function call(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.COMMUNE_API_KEY}`,
      "Commune-Version": process.env.COMMUNE_VERSION,
      "Idempotency-Key": randomUUID(),
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  const payload = await res.json();
  if (!res.ok) throw new Error(`${method} ${path}: ${payload.error?.message ?? res.status}`);
  return payload;
}

// One file per page, so the repository is the source and Commune is the host.
const pages = [
  { kind: "newsletter", path: "/", file: "index.html" },
  { kind: "article", path: "/posts/:article", file: "article.html" },
  { kind: "page", path: "/about", file: "about.html" },
];

for (const page of pages) {
  const html = await readFile(new URL(page.file, import.meta.url), "utf8");
  const written = await call("POST", `/newsletters/${NEWSLETTER}/website-pages`, {
    kind: page.kind,
    path: page.path,
    html,
  });
  // Written as a draft, so look at it before anybody else can.
  console.log(page.path, "->", written.preview_url);
  await call("POST", `/website-pages/${written.id}/publish`);
}

const key = await call("POST", `/newsletters/${NEWSLETTER}/publishable-keys`, {
  name: "Website",
});
// The only response that carries it. Put it in your pages' script tag.
console.log("publishable key:", key.secret);
```
