# Publishing to LotusMake

This is the guide an agent reads before writing a post, and the page a person reads in the
studio. The MCP tool `forge_guide`, the REST route `GET /api/v1/guide`, the public file
`/guide.md` and the studio page `/studio/guide` return this exact text. Read it once, then write. LotusMake (lotusmake.com) runs on Freedom Forge, so the tools, the key format and the skill work on any Freedom Forge instance.

You publish as the maker the key belongs to. Call `get_me` first: it names that maker, the
key's scopes and the limits below. On a team page it also names the role the key acts with
(see Teams and roles). Publishing needs an approved application; an agent cannot
apply, be approved or create a key. Those steps happen in a browser (`/agents.md`, sections 01
to 03).

## Workflow

A post is one MDX text: a frontmatter block, then Markdown with a few components. The same
text goes in and out of every tool. There is no separate title field and no HTML.

1. `create_post` with the full MDX text (frontmatter included). The response carries the
   `post_id`, `version` and `body_sha256`. Pass an `idempotency_key` so a retry never creates a
   second draft; the same key with a different text is refused with `E_IDEMPOTENCY_MISMATCH`.
2. `update_post` with the new text and the `expected_version` you last saw. A stale version is
   refused with `E_VERSION_CONFLICT`; fetch the post and merge.
3. `validate_post`. This parses the text, checks every rule below, fetches the embeds and
   stores their snapshots. It returns `ok`, `validated_version`, `errors`, `warnings`,
   `reading_minutes`, `embeds` and a short text excerpt. Fix every error, validate again.
4. `preview_post` returns a private link that expires. Open it to see the rendered post.
5. `publish_post` with `expected_version`. It requires the validated version to equal the
   current version; an edit after validation means validating again. Publishing is instant.
   Publishing the same version twice returns `already_published: true` and changes nothing.
6. `unpublish_post` takes the post down; the text and its versions are kept.

A published post stays published while it is edited: an update to its text, title, summary,
visibility or tier is public once it is saved and validated, and the next `publish_post`
re-renders the share card and records the version. Ask the person before updating a published
post.

Every error reads `E_CODE: what. Fix: how` and comes with `line:column`. Nothing is published
while an error remains. Warnings do not block.

A post under a moderation action is held: `publish_post` and `update_post` on it, and
`create_post` while the maker's new posts are held or the maker is suspended, answer
`E_MODERATION_HOLD`. The `fix` names the notice URL in the Studio, where the maker reads the
rule, replies or appeals. No tool can lift a hold.

The REST routes under `/api/v1` are the same operations with the same rules (section REST
below). The studio editor at `/studio/posts/<id>` holds the same text; Save, Validate and
Publish there are the same operations, with one difference: a person may edit and save a held
post in the Studio (publishing still waits until the action is lifted), while an agent's
`update_post` on it answers `E_MODERATION_HOLD`.

## Frontmatter

The file starts with a YAML block between two `---` lines. Only these keys are allowed.

```yaml
---
title: Training a small model on one GPU
slug: small-model-one-gpu
summary: What fits in 24 GB, what does not, and the three settings that matter.
visibility: public
tags: [small-models, fine-tuning, rtx-4090]
license: cc-by-4.0
rating: pg
fields: [ai, hardware]
gate: code
---
```

| key | required | rule |
|---|---|---|
| `title` | yes | 1 to 120 characters. The page title and the OG image. |
| `slug` | yes | lowercase letters, digits and single hyphens, up to 80 characters. The URL is `/c/<handle>/posts/<slug>` (`/posts/<slug>` for the founder's page). Immutable after the first publish. |
| `summary` | no | up to 280 characters. Shown in lists, feeds, the OG image and above the gate of a gated post. |
| `visibility` | no | `public` (default), `members`, `supporters` or `tier`. |
| `min_tier` | with `tier` | the tier slug (for example `builder`) a reader needs. Only with `visibility: tier`. |
| `tags` | no | up to eight tags in your own words: 2 to 24 characters each, lower-case letters, digits and hyphens, not starting with a hyphen (`[vllm, dgx-spark, nvfp4]`). They are lower-cased and de-duplicated, so `Vllm` and `vllm` are one tag; more than eight after that is `E_FRONTMATTER`. Readers find posts by them and the site links each one to the other posts that carry it. A tag is not a field: fields are the platform's list below, tags are free. The four pillar slugs (`ai-software`, `creator-content`, `desert-terraforming`, `physics-propulsion`) are tags like any other; the founder's pillar pages list its posts by them. The frontmatter key wins over the request's `tags`. |
| `license` | no | one of `all-rights-reserved`, `cc-by-4.0`, `cc-by-sa-4.0`, `cc-by-nc-4.0`, `cc0-1.0`, `mit`. Default: the maker's default licence (`/studio/plan`, Your page), itself `all-rights-reserved` until changed. Printed in the post footer. Only licensed posts may be mirrored by other sites. |
| `publish_at` | no | an ISO date or datetime. Recorded for later; scheduling is not available yet, so publishing is still a `publish_post` call. |
| `rating` | at publish | `g`, `pg`, `pg13`, `r` or `x` (printed `G`, `PG`, `PG-13`, `R`, `18+`), chosen by the guidelines below. A draft may lack it (warning `W_RATING_MISSING`; the draft carries the page's rating meanwhile); publishing, and saving a published post, need it here or as the request's `rating` (`E_RATING_REQUIRED`). The frontmatter key wins over the request. |
| `fields` | no | up to three field slugs from the list below, such as `[electronics, 3d-printing]`. An unknown slug is `E_FIELDS`. The frontmatter key wins over the request's `fields`. |
| `gate` | no | `none` (default) or `code`: every code block of the post is for supporters (Gating code). A request's `gate`, `gate_tier`, `fields` and `tags` are kept by a later save that omits them; send `gate: none`, `fields: []` or `tags: []` to clear them. |
| `gate_tier` | with `gate: code` | a tier slug: gated blocks are for that tier and above. Only with `gate: code` and one of your active tiers (`E_GATE_TIER`). |
| `cover` | no | an image of this page, written `/assets/<key>`, shown on cards. An import bundle may give the file name. Without it, a public post uses its first image. Anything else is `E_COVER`; an image narrower than 610 pixels is `W_COVER_SMALL`, and cards use the next choice. |
| `cover_alt` | no | what the cover shows, when the post does not say it already: 1 to 280 characters. A cover with no alt text anywhere is `E_COVER_ALT`. |
| `project` | no | the slug of one of your projects, when this post is an update on it. An unknown slug is `E_PROJECT_UNKNOWN`; a project cannot be an update (`E_PROJECT_KIND`); the project is published first (`E_PROJECT_UNPUBLISHED`). |
| `category` | no | the slug of one of the categories you set on your page (the Studio, Page, Categories; `get_me` lists them as `creator.categories`), so the post or project is listed under it on your page. One at most; a post and a project may both have one. A slug none of your categories has is `E_CATEGORY_UNKNOWN`; a category removed later leaves the post in All, and the next validate names the key. |

Unknown keys are errors. Quote a value that contains a colon.

### Ratings

Rate every post by what it shows, for the youngest reader it could reach. Anything beyond PG
needs an account to read: a signed-out reader sees G and PG, a 13 to 17 account up to PG-13 and
an adult account up to R. LotusMake does not host 18+. `/ratings` prints the same guidelines, how
a rating is corrected and how to report one.

A rating describes what is shown, whatever the medium: photographed, filmed, drawn, animated, rendered or made with AI.

| rating | guideline |
|---|---|
| `g` (G) | For everyone. Nothing a young child should not see or read: no profanity, no violence beyond the cartoonish, no sexual content, no drugs, nothing frightening. |
| `pg` (PG) | Fine for most readers, with a parent's eye for the youngest: mild language, brief or mild violence, a scary moment, a mention of alcohol or smoking. Most building, making and engineering posts are PG. |
| `pg13` (PG-13) | For 13 and older: strong language now and then, moderate violence or injury (a workshop accident, a weapons project shown safely), brief non-sexual nudity, mature themes discussed plainly, drug references. |
| `r` (R) | For adults: strong language throughout, graphic violence or injury, nudity, suggestive scenes and sexual content that is not explicit, drug use shown, intense or disturbing material. |
| `x` (18+) | Explicit sexual content, or material whose purpose is sexual arousal, in any medium, writing included. LotusMake does not host it. |

The platform's ceiling is the setting `FF_RATINGS_MAX`, R when it is unset: a rating above it is
refused at validate and at publish (`E_RATING_MAX`). On an instance that sets it to `x`, a post
rated `x` shows only to adults who turned it on, is `public` or `members`, and carries no
`gate: code` and no `<Supporters>` (`E_RATING_GATE`, List B, processor rule B1).

In the Studio editor, `visibility`, `rating`, `fields`, `gate` and `gate_tier` are plain
controls beside the text: every save, validation and publish writes them into the frontmatter,
replacing the text's own values for those keys. A new post has no rating chosen; the page's own
rating (Studio, Page) is printed under the choice as the suggestion. An agent writes the same keys
in the frontmatter, as before.

### Fields

| slug | field |
|---|---|
| `ai` | AI and machine learning |
| `software` | Software and tools |
| `hardware` | Computers and hardware |
| `electronics` | Electronics |
| `robotics` | Robotics and drones |
| `3d-printing` | 3D printing |
| `fabrication` | Machining and fabrication |
| `woodworking` | Woodworking |
| `crafts` | Crafts and textiles |
| `vehicles` | Vehicles and engines |
| `science` | Science and experiments |
| `space` | Space and propulsion |
| `energy` | Energy and power |
| `environment` | Land, water and climate |
| `bio` | Biology and medicine |
| `art` | Art and design |
| `music` | Music and audio |
| `video` | Film and video |
| `writing` | Writing and publishing |
| `games` | Games and simulation |
| `education` | Teaching and courses |
| `security` | Security and privacy |
| `crypto` | Crypto and decentralised systems |
| `food` | Food and cooking |

## Markdown

Standard Markdown with GitHub extensions: headings, paragraphs, emphasis, links, lists, task
lists, tables, blockquotes, footnotes, strikethrough, inline code and fenced code.

- Start sections at `##`. The title comes from frontmatter and is the page's one `h1`; a `#`
  heading is rendered as a section heading (`h2`) with a warning, and a body that writes one
  shifts every heading below it down a level (`##` becomes `h3`, `###` and deeper `h4`). A post
  with three or more `##` sections gets a contents rail.
- Links to other sites open in a new tab with `rel="noopener"`. Only `http`, `https` and
  `mailto` links are kept.
- Images: `![what the image shows](/assets/<key>)` where `<key>` is the value returned by
  `upload_asset`. Alt text is required. Other image sources are rejected. Accepted formats are
  jpeg, png, webp, gif and avif; every image is stored as webp, at most 2560 px wide; SVG is
  rejected.
- Raw HTML, `import`/`export`, and `{expressions}` are rejected. The platform never runs
  author code. Write `\{` and `\}` for literal braces in prose, or use a code span.
- A `<` followed by a letter starts a tag in MDX. In prose write `\<` or use a code span.

## Code blocks

Every fence needs a language. Supported: `ts`, `tsx`, `js`, `jsx`, `json`, `bash`, `python`,
`rust`, `go`, `c`, `cpp`, `cuda`, `yaml`, `toml`, `dockerfile`, `sql`, `html`, `css`, `md`,
`diff`, `text`. Common aliases work (`sh`, `py`, `rs`, `yml`, `shell`, `typescript`).

Fence options after the language:

- `title="src/bench/run.ts"` puts a filename in the block's bar.
- `ln` turns on line numbers; `ln=11` starts them at 11.

Inside the code, comments mark lines:

- `// [!code highlight]` highlights the line.
- `// [!code ++]` and `// [!code --]` mark added and removed lines (diff view).

Use the comment syntax of the language (`# [!code highlight]` in Python or shell).

````mdx
```ts title="src/bench/run.ts" ln=11
export async function run(model: string) {
  const t0 = performance.now(); // [!code highlight]
  await warm(model);
  return performance.now() - t0;
}
```
````

Renders as: a bordered block with a 44 px bar reading `src/bench/run.ts` on the left and `TS`
with a Copy control on the right; numbered lines starting at 11; the highlighted line carries a
faint tint and a left rule. Blocks without a filename and with eight lines or fewer have no bar;
the Copy control sits in the corner. Code never widens beyond the text column and never wraps on
screen.

## Components

Ten components exist. Attributes are plain quoted strings (`start="30"`, not `start={30}`),
and a bare attribute means true (`wide`). Each component stands on its own line with a blank
line before and after. Unknown components and unknown attributes are errors. The founder's page
alone has an eleventh, `<Sheet>` (below).

### GitHubCode

```mdx
<GitHubCode repo="aeon-forge/bench" path="src/bench/run.ts" ref="main" lines="12-48" />
```

| attribute | rule |
|---|---|
| `repo` | `owner/name`, required |
| `path` | path inside the repository, required |
| `ref` | branch, tag or commit; default `main` |
| `lines` | `12` or `12-48`; at most 400 lines (required for files longer than 400 lines) |

Renders as: a code block whose bar reads `src/bench/run.ts · L12–L48` on the left and `TS ·
@3f2a1c` on the right, where `@3f2a1c` links to the permalink at the exact commit. Line numbers
start at the first requested line. The code is fetched once by `validate_post`, pinned to the
commit the ref resolved to, and stored with the post. It does not update when the repository
changes; run `validate_post` with `refresh_embeds` to re-fetch.

### GitHubRepo

```mdx
<GitHubRepo repo="aeon-forge/bench" live="true" />
```

| attribute | rule |
|---|---|
| `repo` | `owner/name`, required |
| `live` | optional; adds the control `Load the latest from GitHub` |

Renders as: a bordered card with the kicker `github.com/aeon-forge/bench` and a `↗` link, the
repository name as an underlined link, the description (two lines at most) and a mono line such
as `TypeScript · MIT · updated 2026-09-30`. No stars, no avatars.

With `live`, the card carries one control, `Load the latest from GitHub`. GitHub forbids framing
its pages, so the reader's own browser asks `api.github.com` for the repository and its README on
the click and shows the stars, the description, the date of the last push and the README (headings,
paragraphs, lists, code and links; images as links; everything else as plain text). Nothing is
fetched before the click, and nothing passes through this site. When GitHub does not answer (the
rate limit is 60 requests an hour per address) the card says so and links to the repository;
without JavaScript the control is a link to the repository.

### Gist

```mdx
<Gist id="2e1f0a6b7c3d4e5f8a9b0c1d2e3f4a5b" file="run.py" />
```

| attribute | rule |
|---|---|
| `id` | the gist id from `gist.github.com/OWNER/ID`, required; the gist must be public |
| `file` | one file name; without it every file of the gist (at most 8) |

Renders as: one code block per file, titled with the file name, with `gist` in the bar linking
to the gist. Each file may have at most 400 lines. Fetched once by `validate_post`; it does not
update when the gist changes. Counts as code for `gate: code`.

### HuggingFace

```mdx
<HuggingFace space="owner/name" />
```

`space` is `owner/name` of a public Space that is running. Renders as a frame (see Embed): the
box with the name `Hugging Face` and `Load from Hugging Face`; one click loads the Space from its
own `hf.space` host.

### YouTube

```mdx
<YouTube id="dQw4w9WgXcQ" title="A talk about tensors" start="754" />
```

| attribute | rule |
|---|---|
| `id` | the 11-character video id, required |
| `title` | the accessible name of the play control, required |
| `start` | seconds, optional |

Renders as: a 16:9 frame (see Embed) with the video's thumbnail (fetched once and stored on this
site) and the control `Load from YouTube`; below it the kicker `YouTube · A talk about tensors`
with a `↗` link and, when `start` is set, `from 12:34`. One click opens the player from
`youtube-nocookie.com`. Nothing from YouTube loads until then.

### Image

```mdx
<Image src="/assets/3f2a1c…e9.webp" alt="Loss curve over 40 epochs" caption="Loss, log scale." wide />
```

| attribute | rule |
|---|---|
| `src` | an asset key from `upload_asset`, required |
| `alt` | required unless `decorative` is set |
| `caption` | up to 280 characters |
| `wide` | widens the image to the full page width; honoured only when the post has no contents rail (fewer than three `##` sections), otherwise ignored with a warning |
| `decorative` | marks a purely visual image (`alt=""`) |

Renders as: a bordered figure with the image and a mono caption beneath. The Markdown form
`![alt](/assets/<key>)` renders the same figure without a caption.

### Figure

```mdx
<Figure caption="The three settings that matter.">

| setting | value |
|---|---|
| batch | 8 |
| sequence | 2048 |
| precision | bf16 |

</Figure>
```

Renders as: a bordered figure around the content with the caption beneath. Use it for tables
and for anything that deserves a caption. The blank lines inside are required.

### Callout

```mdx
<Callout kind="warning">

Set `PYTORCH_ENABLE_MPS_FALLBACK=1` before the first run or the kernel aborts.

</Callout>
```

`kind` is `note` (default), `warning` or `tip`.

Renders as: a block with a thin left rule, the kicker `Warning` and the content. No fill, no
icon. The blank lines inside are required.

### Embed

```mdx
<Embed url="https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC" />
```

`url` is the address of the page, `https`, on a host in the table below or one of the link-card
hosts after it. A page the table knows becomes a **frame**; any other page on a listed host
becomes a **link card**.

**Nothing from a provider loads until the reader clicks.** A frame shows a box with the provider's
name (and its poster, when the provider gives one, stored on this site) and a control, `Load from
Spotify`. One click replaces the box with the provider's player in a sandboxed frame. Without
JavaScript the control is a link that opens the player in a new tab, and `↗` beside the title
opens the page. Twitch needs the site's own host name to play: the click supplies it, so the
new-tab link of a Twitch frame may refuse to play; open the page with `↗`.

`validate_post` reads the provider's title and poster (its oEmbed answer or the page's OpenGraph
tags) through the platform's proxy. When it cannot, the frame is kept with the provider's name and
no poster, with the warning `W_EMBED_META`; `refresh_embeds` tries again.

| provider | page URL | what shows |
|---|---|---|
| YouTube | `youtube.com/watch?v=ID`, `youtu.be/ID`, `youtube.com/shorts/ID`, `youtube.com/playlist?list=ID` | video or playlist, 16:9 (no cookies) |
| Vimeo | `vimeo.com/ID`, `vimeo.com/ID/HASH` (unlisted) | video, 16:9 (do-not-track) |
| Twitch | `twitch.tv/videos/ID`, `twitch.tv/CHANNEL`, `clips.twitch.tv/SLUG`, `twitch.tv/CHANNEL/clip/SLUG` | video, live channel or clip |
| Dailymotion | `dailymotion.com/video/ID`, `dai.ly/ID` | video |
| Streamable | `streamable.com/CODE` | video |
| Internet Archive | `archive.org/details/IDENTIFIER` | the item's player, with its title and cover |
| Loom | `loom.com/share/ID` | video |
| Spotify | `open.spotify.com/TYPE/ID`, TYPE one of track, album, playlist, artist, show, episode | player, compact for a track or episode |
| SoundCloud | `soundcloud.com/USER/TRACK`, `soundcloud.com/USER/sets/NAME` | player |
| Bandcamp | `BAND.bandcamp.com/album/SLUG` or `BAND.bandcamp.com/track/SLUG` | the page's own player; a link card when the page names none |
| Mixcloud | `mixcloud.com/USER/SHOW` | player |
| Apple Music | `music.apple.com/us/TYPE/NAME/ID`, TYPE album, song or playlist | player |
| Apple Podcasts | `podcasts.apple.com/us/podcast/NAME/idNUMBER` (`?i=` for one episode) | player |
| X | `x.com/USER/status/ID`, `twitter.com/USER/status/ID` | the post, do-not-track |
| Instagram | `instagram.com/TYPE/CODE`, TYPE p, reel or tv | the post |
| TikTok | `tiktok.com/@USER/video/ID` | the video |
| Facebook | `facebook.com/PAGE/posts/ID`, `facebook.com/PAGE/videos/ID` | the post or video |
| LinkedIn | `linkedin.com/posts/…-activity-ID-…` | the post |
| CodePen | `codepen.io/USER/pen/SLUG` | the pen's result |
| CodeSandbox | `codesandbox.io/s/ID`, `codesandbox.io/p/sandbox/ID` | the sandbox |
| StackBlitz | `stackblitz.com/edit/SLUG` | the project |
| Hugging Face Space | `huggingface.co/spaces/OWNER/NAME` (or `<HuggingFace>`) | the running Space |
| Sketchfab | `sketchfab.com/3d-models/SLUG-UID` | the 3D model, 4:3 |
| Scratch | `scratch.mit.edu/projects/ID` | the project, 4:3 |
| Desmos | `desmos.com/calculator/ID` | the graph, 4:3 |
| GeoGebra | `geogebra.org/m/ID` | the material, 4:3 |
| Giphy | `giphy.com/gifs/SLUG-ID` | the GIF, square |
| Figma | `figma.com/TYPE/ID/NAME`, TYPE design, file, proto or board | the file or prototype (it must be public) |
| Google Drive | `drive.google.com/file/d/ID/view` | the file's preview (shared by link) |
| Google Slides | `docs.google.com/presentation/d/e/ID/pub` (published to the web) | the deck |
| p5.js | `editor.p5js.org/USER/sketches/ID` | the sketch |

Some providers change their embed pages without notice; a frame that stops loading still has its
`↗` link. Link cards only, because the page cannot be framed or has no embed: `github.com` (use
`<GitHubRepo>`, `<GitHubCode>` or `<Gist>`), `gist.github.com`, `replit.com`,
`cad.onshape.com`, `thingiverse.com`, `printables.com`, `hackaday.io`, `tinkercad.com`,
`bsky.app`, `arxiv.org`, `en.wikipedia.org`, `huggingface.co` (other than a Space), `odysee.com`,
`wokwi.com`, and any page on a provider's host that the table does not match (a Google Docs
document, or a Slides deck that is not published to the web, is a link card). Mastodon and PeerTube servers differ per
instance and cannot be listed; use a plain link.

A link card renders as: the kicker (host and path), the page title as a link and its description,
taken from the page's OpenGraph tags at validation time. Vimeo is written `<Embed url="https://vimeo.com/76979871" />`; there is no `<Vimeo>` component.

### Supporters

````mdx
<Supporters>

The tuned settings, and the script that applies them:

```bash
./tune.sh --profile fast
```

</Supporters>
````

No attributes. Whatever it holds (text, code, components) is for supporters, in an open post
(Gating code). The blank lines inside are required; a `<Supporters>` inside another, or a
footnote defined inside one (footnotes print at the end of the open post), is `E_GATE_NESTED`.

Renders as: one inline gate in its place for a reader who is not a supporter; the content for
a supporter.

### Sheet (the founder's page only)

```mdx
<Sheet id="03b" />
```

`id` is one of `01`, `02`, `03`, `03b`, `04`, the committed engineering sheets of the four
pillars; nothing else is accepted. Self-closing, no other attribute, no children. Only the
founder's posts may use it: for any other maker it is `E_UNKNOWN_COMPONENT`.

Renders as: the committed drawing, static, in the text column; feeds show a link to the post
in its place.

### Not yet supported

Nothing at present. A name that is not a component above is rejected with `E_UNKNOWN_COMPONENT`.

## Visibility

| `visibility` | who sees the body |
|---|---|
| `public` | everyone; the post appears in the public feed in full and in the sitemap |
| `members` | anyone signed in |
| `supporters` | an active supporter of any tier (`active`, `trialing`, or `past_due` within 14 days of the period end) |
| `tier` | supporters at `min_tier` or a higher tier |

Everyone sees the title, summary, date and tags of a gated post; the body is replaced by the
tier chooser, never blurred and never cut to a teaser. The one partial text is the maker's own
choice: an open post whose code, or whose `<Supporters>` blocks, are for supporters (Gating
code). Gated posts appear in the public feed as summary plus link, and in a supporter's
private feed in full. Drafts and unpublished posts are not visible anywhere except through a
preview link. A one-time Stoke (GPU hours) is a gift to the maker and grants no access; only
tiers gate. A post rated 18+ is `public` or `members` only (`E_RATING_GATE`).

The rating comes before the visibility: whatever a post's visibility, a reader outside its
rating sees the title, the rating and one line in place of the body, and the public feed
carries a post above PG as its title and `(Rated PG-13. Read on the site.)`, and no 18+ post.

## Gating code

Leave the article open and gate only what you choose:

- `gate: code` gates every fenced code block and every `<GitHubCode>` of the post.
- `<Supporters>` … `</Supporters>` gates whatever it holds, in any post, with or without
  `gate: code`. A code block inside it belongs to the container's one gate.
- `gate_tier: <tier slug>` asks for that tier and above; without it any paid support opens
  the gated blocks (`supporters`). A gate lowered by a rule action (paid gate off) opens to
  members, as the post gate does.

What a reader sees: the open text in full, and in place of each gated block a hairline box the
height of the code (up to twelve lines), the kicker `Code for supporters` (`For supporters`
for a container), one sentence with the lowest tier price and the link to support you. A
supporter sees the blocks in place. Nothing is blurred and nothing of a gated block is sent to
a reader who cannot open it: the compiler keeps gated blocks out of the published body.

Feeds: the public feed prints `(Code for supporters. Read on the site.)` in place of a gated
code block and `(For supporters. Read on the site.)` for a container; a supporter's private
feed carries the blocks. Exports carry your full MDX.

At most 40 gated blocks per post (`E_TOO_MANY_GATED`). A post rated 18+ cannot gate anything
(`E_RATING_GATE`: List B, processor rule B1).

## Style

- Lead with the result. The summary states what the reader gets; the first section delivers it.
- Short sections with plain `##` headings; the contents rail reads them.
- One idea per code block; a filename when the code is a file; line numbers when the text
  refers to lines.
- Prefer `<GitHubCode>` to pasting a file: it stays pinned to a commit and links to the source.
- Sentences, not exclamation marks. No emoji. No em-dashes; use a comma, a colon, or a full stop.
- Alt text describes what the image shows, not that it is an image.
- Tags are the words a reader would search for: the tool, the model, the part, the method. Three to
  five specific ones beat eight vague ones; leave out what the title and the fields already say.
- Choose a licence on purpose. `all-rights-reserved` keeps every right; a Creative Commons
  licence or `mit` lets others reuse and mirror the post.

## Projects

A project is a post with goals: the same MDX text, the same validate, preview and publish, plus
one to five goals that say what the money delivers. Supporters fund a project with one-time
contributions that go straight to the maker. A contributor is anonymous unless they are signed
in and ask to be named; a named contributor shows by name and date, never by amount. An agent
never funds a project, and keys, invites and wallets are made by people in the Studio.

1. `create_project` takes the full MDX text, as `create_post` does, and `goals`: one to five,
   each `{ "amount_minor", "title", "promise" }`. `amount_minor` is the total the project has
   raised, in minor units (USD cents), when the goal is reached: `500000` is five thousand
   dollars. The amounts must rise down the list, a title is 1 to 60 characters and a promise is
   1 to 280. A bad list creates nothing. Pass an `idempotency_key`; the same key with other goals
   is refused with `E_IDEMPOTENCY_MISMATCH`.
2. `update_post`, `validate_post` and `preview_post` work on a project as on any post, and
   `kind` stays `project` for good. Say in the text what each goal delivers and who is building
   it; the page prints the goals and their promises beside the text.
3. `publish_post`. A project needs at least one goal before it is published; without one the
   answer is 400 `E_INVALID_INPUT`, "a project needs at least one goal before it is published".
   The public page is `/c/<handle>/projects/<slug>`; every project object carries that `url`.
4. `set_goals` takes a `post_id` or a `slug` and the whole list, and replaces the goals. A goal
   the raised total has reached keeps its amount and cannot be removed: the answer is 409
   `E_GOAL_REACHED`. Add a goal above it instead. Titles and promises can still be corrected.
5. `get_project` and `list_projects` say where a project stands: `raised_minor` (paid
   contributions less refunds), `contributors`, `named`, `next_goal`, `top_goal`, `closed_at`
   and, on `get_project`, every goal with its `reached_at`. A goal is reached while the raised
   total is at or above its amount, and a refund can take it back.
6. `close_project` stops funding. The page stays with its goals and contributors, nothing is
   refunded, and the api cannot reopen it. Ask the person first.

A maker can show crypto wallets on a project: a chain, a public address and, for Monero, a
view key. People add wallets in the Studio and no tool does. `get_project` lists them as
`wallets` (`id`, `chain`, `label`, `address`, `view_key_published`, `reported_minor`,
`reported_at`) and never returns a view key. `report_wallet_total` takes a `post_id` or a `slug`,
a `wallet_id` and `minor`, what the wallet has received in all in minor units (USD cents) as the
maker converted it, and replaces the last figure. The total counts toward the raised amount and
the goals, and the page says it is reported by the maker. A project holds five wallets. LotusMake
takes no share of a wallet and never sees it.

A contribution is whole dollars within the bounds `get_me` returns as `limits.project`:
`min_minor`, `max_minor`, `goal_max_minor` and the three `cells_minor` the fund page offers, all
in minor units. The bounds come from the platform settings and are never typed in this guide.
LotusMake keeps a share of each contribution, and Stripe's processing fee is passed
through; the project page says exactly what the share is for that maker before the first click,
computed from the same settings, so write no figure for it in a post. The goals' amounts are
the maker's own and may be written in the text.

## Finding posts

Readers find a post four ways, and all four read what you write in the frontmatter.

- Search. `/search?q=<words>` finds makers, projects and posts (`&type=makers` for makers alone),
  and the search field in the header of every public page opens it. Nothing is sent until the
  reader submits. A public post is found by its title, summary, tags, fields, the maker's name
  and handle and the text of its body; a post with any other visibility is found by title,
  summary, tags and fields only, and gated fragments are never indexed. The last word of a query
  also matches as a prefix from three characters, so a handle or a model name is found as it is
  typed. Results hold only what the reader's band allows. Search engines are asked not to index
  result pages.
- Fields. `/fields` lists the 24 fields with their counts; `/fields/<slug>` lists the makers,
  projects and posts in one, and `/fields/<slug>/feed.xml` is its RSS feed.
- Tags. `/tags` lists the most used tags; `/tags/<tag>` lists the posts and projects that carry
  one, with the tags that often appear beside it, and `/tags/<tag>/feed.xml` is its RSS feed. A
  tag nobody uses has no page. The old address `/posts?tag=<tag>` answers a permanent redirect
  (308) to `/tags/<tag>`.
- More like this. A post or project ends with up to five posts that share the most tags with it.
  A post without tags prints no such block.

Posts and projects rated 18+ are left out of every count and list below an adult's own toggle.
Fields and tags print under the title of every post and project and link to these pages, which
is what makes tags worth choosing with care (see Style).

## Teams and roles

A team is a maker page with people. Its kicker reads `Team`, it lists the maker pages on it under
`Makers on this team`, and each of those pages says `Part of {team}`. Posts on a team page carry
a byline: `{author}, for {team}`. The author's name is the name of their own maker page and links
to it; an author with no page of their own is not named, and the page name prints alone. The name
an account signed up with never prints. On a maker's own page the byline is the page's, as ever.

The principal is the account that answers for a page: it holds the payment account and the
payouts. A page may have no principal; its owners run it and it takes no money. Everyone else
writes on a page in one of three roles, and a page holds at most 25 people besides its principal.

| role | what it may do |
|---|---|
| Owner | everything the principal does except the money: the page, tiers, offers, supporters, tips, exports, answers to notices, every Forge key, and inviting, changing roles and removing people |
| Editor | create, update, validate, preview, publish and unpublish any post; create and edit projects; create Forge keys and manage their own; read stats |
| Contributor | create, update, validate and preview their own drafts; an editor or an owner publishes them |

An owner invites a maker from `/studio/team`: a role, an optional maker handle (then only that
maker's account can accept) and an optional note. The link is shown once and expires after 14
days; a page holds at most ten open invites. Accepting needs a signed-in account that is 18 or
older with a verified email, and the principal cannot accept an invite to their own page. A
person who leaves or is removed keeps their byline on what they published; their drafts stay with
the page. The Terms say who answers for a post: the page's owner for every post on it, and a
maker who writes on another page for what they write.

A Forge key acts with the role of the account that created it, as that role stands now. A key
keeps a scope only while that role may do what the scope stands for: `posts:write` and
`assets:write` need any role, `posts:publish` an editor or above, `stats:read` an editor or
above, and `tiers:write` an owner or above. `report_wallet_total` needs the principal. A
contributor cannot create a key, and a key whose creator is demoted to contributor, removed from
the page or terminated answers like a revoked one. A key an admin made while acting as a page
they do not run keeps the scopes it was given and writes posts with no author. A refused act is
403 `E_FORBIDDEN`: `Your role on {page} does not allow this.` On a team page `get_me` returns
`key.role`.

## The lotus badge

A maker can point people at their support page from a README, a Hugging Face model card or a
social profile with the lotus badge. The Studio's Page settings (`/studio/page`, section Lotus
badge) give it in three wordings, each dark or light, as three snippets: Markdown for GitHub and
Hugging Face, HTML, and the plain link for a profile. The pictures are the same files for every
maker, served from `https://<domain>/badges/<file>`, and each file name carries its version, so a
badge already posted never changes. The link opens the page's chooser on GPU hours,
`https://<domain>/c/<handle>/stoke?mode=hours&via=tea`; `via=tea` is the same for everyone, adds
one line to the chooser and is never counted. A page that takes no support yet links its own page
instead. Loading or clicking a badge is not counted, and the site keeps no log line for the
picture. The same section offers the square mark (`/badges/lotus-512.v1.png`) for a profile
picture.

## Limits

One table, stated once. `get_me` returns these numbers as `limits`; `/agents.md` repeats this
table. Every limit is enforced by the api, not by the client. The requests, posts and images
per key are a key's default and its ceiling: the person who creates a key sets its own numbers
inside them, and `get_me` returns those as `key` (see Connect an agent).

| limit | value |
|---|---|
| post text | 256 KiB |
| any single block | 64 KiB |
| code blocks per post | 200 |
| embeds per post | 20 |
| gated blocks per post | 40 |
| GitHubCode lines | 400 |
| inline image upload (`data_base64`) | 1 MiB |
| image upload by `source_url` or `PUT /api/assets/<filename>` | 16 MiB |
| stored image width | 2560 px |
| embed fetch time | 5 s |
| embed fetch size | 1 MiB |
| embed redirects | 3 |
| requests per key | 60 a minute by default, up to 120 (set per key) |
| posts per key | 24 a day by default, up to 48 |
| images per key | 96 a day by default, up to 200 |
| publishes per key | 10 a minute |
| list page | 100 items |
| result text | 40,000 characters |
| REST request body | 1 MiB |
| `Idempotency-Key` length | 128 characters |
| `Idempotency-Key` memory | 24 hours |
| exports per account | 1 a day |
| export link | 72 hours, one download |
| MCP JSON-RPC batch | 10 messages; each counts against requests per key |
| image uploads by `source_url` or `PUT /api/assets` | one at a time per maker |
| project goals | 5 per project |
| wallets | 5 per project |
| requests per page | 240 a minute over all its keys |
| posts per page | 96 a day over all its keys |
| images per page | 400 a day over all its keys |
| other writes per page | 300 a day over all its keys |
| export images | 80 MiB; later images are listed, not included |
| image upload body time | 10 s plus 1 s per 256 KiB, at most 55 s |
| `validate_post` time | 8 s |
| contribution | whole dollars, within get_me.limits.project |

The rows for a page count every key it ever made, revoked ones too. Past the page's minute the
answer is 429 `E_RATE_LIMITED`; past its day it is 429 with a `Retry-After` of the seconds to
00:00 UTC, and nothing is paused. The other writes are `upsert_tier`, `set_goals`,
`close_project` and `report_wallet_total`; `unpublish_post` is never counted, so a post can
always come down. An image body must arrive within its deadline, which asks for about 73 KiB a
second at 1 MiB and about 300 KiB a second at 16 MiB; a slower body answers 504 `E_TIMEOUT`. A
creator export holds every post, revision, table and csv and as many images as fit in 80 MiB,
oldest first; the rest are listed in `assets.json` as `"not_included": "size"`, and the
export is never refused for size. `validate_post` that runs past 8 s answers 504 `E_TIMEOUT`.

Embeds count `GitHubCode`, `GitHubRepo`, `Gist`, `HuggingFace`, `YouTube` and `Embed`. Embed fetches go to the
allow-listed hosts only. Lists return at most one page; page with `offset`. A response longer
than the result limit is cut; `get_post` pages long bodies with `body_offset`.

## MCP tools

| tool | what it does | scope |
|---|---|---|
| `get_me` | the maker this key belongs to (id, handle, display name, status, plan), the key's scopes, expiry, limits, pause and use today (`key`, with `key.role` on a team page), `creator.kind` (`maker` or `team`), `creator.categories` (the slugs and names of the page's categories, in its order) and `limits` (with `limits.keys` and `limits.project`) | any key |
| `forge_guide` | returns this guide | any key |
| `list_posts` | lists posts with status, version and visibility | `posts:read` |
| `get_post` | one post; `include_body` returns the MDX; carries `body_sha256` and `license` | `posts:read` |
| `create_post` | creates a draft from MDX text; `idempotency_key` | `posts:write` |
| `update_post` | replaces the text; needs `expected_version` | `posts:write` |
| `validate_post` | checks the text, fetches embeds, stores snapshots | `posts:write` |
| `preview_post` | returns a private, expiring preview link | `posts:read` |
| `publish_post` | publishes a validated post; needs `expected_version` | `posts:publish` |
| `unpublish_post` | takes a post down | `posts:publish` |
| `create_project` | creates a project draft: `create_post` with `kind: project` and 1 to 5 `goals`; `idempotency_key` | `posts:write` |
| `set_goals` | replaces a project's goals (`post_id` or `slug`, and `goals`) | `posts:write` |
| `list_projects` | lists this maker's projects with what each has raised, the next and top goal and `closed_at` | `posts:read` |
| `get_project` | one project with its goals and where it stands | `posts:read` |
| `close_project` | closes a project to contributions | `posts:publish` |
| `report_wallet_total` | records what one of a maker's project wallets has received, as the maker reports it | `posts:write` |
| `upload_asset` | stores an image and returns its `/assets/<key>` path | `assets:write` |
| `list_tiers` | lists the support tiers | `tiers:read` |
| `upsert_tier` | creates or updates a tier: USD only, `price_minor` at least 300 (cents) a month | `tiers:write` |
| `get_stats` | supporters and post counts | `stats:read` |

Every post object carries `body_sha256`, the SHA-256 of the exact MDX bytes as stored, so a
client can verify what the server holds, `license` and `kind` (`post` or `project`).
`publish_post`, `unpublish_post` and `close_project` ask the client to confirm with its user
first (`_meta` `anthropic/requiresUserInteraction`);
`get_me` and `forge_guide` are marked to load first (`anthropic/alwaysLoad`).

Resources: `forge://guide` (this text), `forge://components` (the component reference),
`forge://posts/{slug}` (a post). Prompt: `draft_post`.

## REST

The same operations over plain HTTPS at `/api/v1`, for scripts, CI and clients without MCP.
The description of the surface is `GET /api/v1/openapi.json` (OpenAPI 3.1, public).

| route | MCP tool | scope |
|---|---|---|
| `GET /api/v1/me` | `get_me` | any key |
| `GET /api/v1/guide` | `forge_guide` | any key |
| `GET /api/v1/posts` | `list_posts` | `posts:read` |
| `POST /api/v1/posts` | `create_post` | `posts:write` |
| `GET /api/v1/posts/{id}` | `get_post` (the slug works in place of the id) | `posts:read` |
| `PUT /api/v1/posts/{id}` | `update_post` | `posts:write` |
| `POST /api/v1/posts/{id}/validate` | `validate_post` | `posts:write` |
| `POST /api/v1/posts/{id}/preview` | `preview_post` | `posts:read` |
| `POST /api/v1/posts/{id}/publish` | `publish_post` | `posts:publish` |
| `POST /api/v1/posts/{id}/unpublish` | `unpublish_post` | `posts:publish` |
| `GET /api/v1/projects` | `list_projects` | `posts:read` |
| `POST /api/v1/projects` | `create_project` (`{ "body_mdx", "goals", "license" }`; `Idempotency-Key` as on posts) | `posts:write` |
| `GET /api/v1/projects/{id}` | `get_project` (the slug works in place of the id) | `posts:read` |
| `PUT /api/v1/projects/{id}/goals` | `set_goals` (`{ "goals": [...] }`, the whole list) | `posts:write` |
| `POST /api/v1/projects/{id}/close` | `close_project` | `posts:publish` |
| `PUT /api/v1/projects/{id}/wallets/{wallet_id}/total` | `report_wallet_total` (`{ "minor" }`) | `posts:write` |
| `POST /api/v1/assets` | `upload_asset` by `source_url` (`{ "source_url", "alt" }`) | `assets:write` |
| `PUT /api/assets/{filename}` | `upload_asset` with the image bytes as the body, `Content-Type: image/jpeg`, `image/png`, `image/webp`, `image/gif` or `image/avif` (`application/octet-stream` or none answers 415), `?alt=` for the alt text; `{filename}` is one path segment of letters, digits, `.`, `-` and `_`, up to 128 characters, any other name is refused | `assets:write` |
| `GET /api/v1/tiers`, `PUT /api/v1/tiers`, `PUT /api/v1/tiers/{id}` | `list_tiers`, `upsert_tier`; USD only, `price_minor` at least 300 (cents) a month, a lower price or another currency answers 400 `E_INVALID_INPUT`; at most four tiers are active at once, a fifth answers 409 `E_TIER_LIMIT` | `tiers:read`, `tiers:write` |
| `GET /api/v1/stats` | `get_stats` | `stats:read` |
| `GET /api/v1/export` | the creator export (`{ "url", "expires_at", "images_left_out" }`; `images_left_out` counts the images left out for size) | `posts:read` |

Rules:

- `Authorization: Bearer <key>` on every request. `GET`, `POST` and `PUT` only; nothing is
  deleted (unpublish is a `POST`). Request bodies are `application/json` (`Content-Type`
  required); query parameters mirror the tool arguments (`?status=`, `?limit=`, `?offset=`,
  `?include_body=`, `?body_offset=`). Unknown fields are refused. No CORS: this is not a
  browser API, and a browser `Origin` from another site is refused with 403.
- `POST /api/v1/posts` takes `{ "body_mdx": "...", "license": "..." }` (`license` optional; the
  frontmatter key wins when both are present). Send `Idempotency-Key`: a string of your choice,
  scoped to your maker. The same key with the same text returns the same post; the same key
  with a different text is refused with 409 `E_IDEMPOTENCY_MISMATCH`.
- `PUT /api/v1/posts/{id}` takes the same body. `If-Match: "<version>"` carries the version you
  last saw; when it is stale the answer is 412 `E_VERSION_CONFLICT`. The publish and unpublish
  `POST`s take the same header. Without the header the current version is used.
- Post objects carry `id`, `slug`, `title`, `kind`, `status`, `version`, `validated_version`,
  `visibility`, `license`, `body_sha256` and, when asked for, `body_mdx`. A project object adds
  `goals` and what it has raised (see Projects).
- Every success is `200` (`201` for a created post) with `cache-control: no-store`. Every failure
  is one envelope:

```json
{
  "code": "E_VERSION_CONFLICT",
  "message": "expected_version 3 is stale; the post is at version 4.",
  "fix": "Fetch the post, merge your changes and retry with the current version.",
  "details": { "expected_version": 3, "version": 4 },
  "request_id": "01JBE1V5C2N7XQ4Z9M0P8R6TWA"
}
```

| status | codes |
|---|---|
| 400 | `E_INVALID_INPUT` (schema, unknown field, bad JSON) |
| 401 | `E_INVALID_KEY`, with `WWW-Authenticate: Bearer realm="freedom-forge", error="invalid_token"` |
| 403 | `E_INSUFFICIENT_SCOPE` (the key was never given the scope in `details`; a suspended maker's write answers 409 `E_MODERATION_HOLD` instead), `E_FORBIDDEN` (on a team page, the role of the key's creator does not allow the act), `E_ORIGIN` (browser `Origin` outside the site), `E_KEYS_PAUSED` (an admin paused every key of the maker; writes only) |
| 404 | `E_NOT_FOUND` |
| 405 | `E_METHOD` |
| 409 | `E_IDEMPOTENCY_MISMATCH`, `E_NOT_VALIDATED`, `E_VALIDATION_FAILED` (the last validation reported errors), `E_SLUG_TAKEN`, `E_SLUG_LOCKED`, `E_ALREADY_PUBLISHED` (a preview of a published post), `E_NOT_PUBLISHED` (an unpublish of a post that is not published), `E_TIER_LIMIT` (a fifth active tier), `E_TIER_LOCKED` (a tier's slug or currency after supporters joined), `E_GOAL_REACHED` (a goal that was reached cannot change or be removed), `E_MODERATION_HOLD` (the notice URL is the `fix`; a suspended maker's write names the suspension notice) |
| 411 | `E_LENGTH_REQUIRED` (`PUT /api/assets/{filename}` without `Content-Length`) |
| 412 | `E_VERSION_CONFLICT` |
| 413 | `E_TOO_LARGE`, `E_BODY_TOO_LARGE` (post text over 256 KiB of UTF-8) |
| 415 | `E_UNSUPPORTED_MEDIA` |
| 422 | the content codes below (`E_FRONTMATTER`, `E_TIER_UNKNOWN`, `E_SOURCE_URL`, ...), `E_BODY_TOO_COMPLEX` (the text stacks markdown delimiters beyond any post) and `E_IMAGE_DECODE` (the image could not be processed) |
| 429 | `E_RATE_LIMITED` (the key's requests a minute, its posts or images for the day, the page's minute or day over all its keys, a second validation at once for one maker, or a write from a paused key), with `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `Retry-After` (for the day's limits, the seconds to 00:00 UTC) |
| 503 | `E_UNAVAILABLE` (also: the validation or image queue is full); retry later |
| 504 | `E_TIMEOUT` (validation past 8 s, or an image body that did not arrive in its deadline) |
| 507 | `E_QUOTA` (the maker's image storage is full; remove unused assets) |

The skill at `/skills/freedom-forge-publisher/SKILL.md` ships `scripts/ff-publish.sh`, a
bash, curl and jq script that runs the whole workflow over these routes.

## Connect an agent

Create a key at `/studio/keys`. It is shown once. Put it in the environment variable
`FORGE_KEY` (never in a file that is committed, never in a variable meant for another
service's key).
The endpoint is `https://<domain>/mcp`; it accepts POST only, with
`Authorization: Bearer <key>`.

Connecting a client is a person's step: the Claude Code command and the Claude Desktop file below
store the key itself in the client's configuration, so a person runs and writes them, never an
agent. Cursor's file holds only a reference to `FORGE_KEY`, and so does a Claude Code `.mcp.json`
whose header reads `Bearer ${FORGE_KEY}` (the skill's `references/clients.md`).

Claude Code:

```bash
claude mcp add --transport http forge https://<domain>/mcp --header "Authorization: Bearer $FORGE_KEY"
```

Cursor, in `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "forge": {
      "url": "https://<domain>/mcp",
      "headers": { "Authorization": "Bearer ${env:FORGE_KEY}" }
    }
  }
}
```

Claude Desktop, through `mcp-remote`, in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "forge": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://<domain>/mcp",
        "--transport",
        "http-only",
        "--header",
        "Authorization:${AUTH_HEADER}"
      ],
      "env": { "AUTH_HEADER": "Bearer <your key>" }
    }
  }
}
```

claude.ai custom connector: available only after go-live, when the site is reachable from the
internet. Add the connector with the URL `https://<domain>/mcp`, choose "No sign in", and set
the request header `Authorization: Bearer <your key>`. During the preview period use Claude
Code, Cursor or Claude Desktop from a machine on the LAN.

Clients without MCP use the REST routes above with the same key. A portable skill with the
workflow, the client notes and the scripts is at `/skills/freedom-forge-publisher/SKILL.md`
(ZIP: `/skills/freedom-forge-publisher/freedom-forge-publisher.zip`); the public entry point
for agents is `/agents.md`.

Call `get_me` first in every session, then `forge_guide`, then `list_tiers` if the post is
gated, then write.

Every key has limits that the person who creates it sets, inside the ceilings in the Limits
table: requests a minute, posts a day and images a day. `get_me` returns them as `key.rpm`,
`key.posts_per_day` and `key.images_per_day`, with `key.paused_at`, `key.paused_reason` and
`key.today` (`requests`, `posts`, `images` and `refused`, counted from 00:00 UTC), and the
ceilings and defaults as `limits.keys`. `create_post`, `update_post`, `publish_post` and
`create_project` (and their REST routes) spend a post; `upload_asset` and `PUT /api/assets` spend
an image. A call is counted when it is admitted, whatever it then answers. A call that would pass
the day's number is refused with 429 `E_RATE_LIMITED` and a `Retry-After` of the seconds to
00:00 UTC. Passing the day's posts also pauses the key: a paused key refuses every write with 429
and still reads, and it stays paused until the person resumes it in the Studio at
`/studio/keys`, though the day's counts start again at 00:00 UTC. An admin can pause every key of
a maker; every write of every key then answers 403 `E_KEYS_PAUSED`, reads are still served, and
nothing an agent does lifts it. The page's own totals (see the Limits table) never pause a key:
the call is refused with 429 until 00:00 UTC.

## Errors you will see

| code | meaning | fix |
|---|---|---|
| `E_FRONTMATTER` | a key is missing, unknown or malformed | follow the frontmatter table |
| `E_MDX_SYNTAX` | the text does not parse | fix the syntax at the reported position |
| `E_EXPRESSION`, `E_ESM` | `{...}`, `import` or `export` in the text | remove it; nothing is executed |
| `E_UNKNOWN_COMPONENT` | a component outside the eight | use a documented one |
| `E_COMPONENT_INLINE` | a component's opening tag, content and closing tag on one line | write it as a block: each tag on its own line, blank lines around the content |
| `E_COMPONENT_CHILDREN` | content between the tags of a component that takes none | write it self-closing: `<Name ... />` |
| `E_ATTRIBUTE_UNKNOWN`, `E_ATTRIBUTE_INVALID`, `E_ATTRIBUTE_REQUIRED` | an attribute problem | see the component table |
| `E_ATTRIBUTE_EXPRESSION` | `attr={...}` | write `attr="value"` |
| `E_RAW_HTML` | an HTML tag | use Markdown or a component |
| `E_CODE_LANG_REQUIRED`, `E_CODE_LANG_UNKNOWN` | the fence has no or an unknown language | add one from the list |
| `E_CODE_META` | the fence options after the language do not parse | follow the fence options above, for example `title="src/app.ts" ln=11` |
| `E_ALT_REQUIRED`, `E_IMAGE_SRC` | an image without alt text or outside `/assets/` | add alt text; upload first |
| `E_EMBED_FAILED` | the source returned an error (for example `github.com/... returned 404`) | fix the path or ref, validate again |
| `E_EMBED_MISSING` | an embed has no snapshot | run `validate_post` |
| `E_TOO_MANY_CODE_BLOCKS`, `E_TOO_MANY_EMBEDS`, `E_TOO_MANY_GATED`, `E_BLOCK_TOO_LARGE` | a limit | split the post, or gather gated code into fewer `<Supporters>` |
| `E_RATING_REQUIRED` | publish, or a save of a published post, without a rating: "Choose a rating: g, pg, pg13, r or x. See /ratings." | add `rating:` to the frontmatter (or `rating` to the request), validate, publish |
| `E_RATING_GATE` | a post rated 18+ behind `supporters`, `tier`, `gate: code` or a `<Supporters>` (List B, processor rule B1) | set visibility to public or members and remove the gate, or lower the rating |
| `E_RATING_MAX` | the rating is above what this platform accepts | use a lower rating, if it fits the guidelines |
| `E_FIELDS` | an unknown field slug, or more than three | use up to three slugs from the fields list |
| `E_GATE_TIER` | `gate_tier` without `gate: code`, or not one of your active tiers | set `gate: code`, use a slug from `list_tiers`, or remove it |
| `E_GATE_NESTED` | a `<Supporters>` inside another, or a footnote defined inside one | remove the inner tags; define the note outside the container |
| `E_COVER` | `cover:` is not an image stored on this page, written as `/assets/<key>` | upload the image to this page, then name it as `cover: /assets/<key>` |
| `E_COVER_ALT` | "The cover needs alt text. Add cover_alt, or give the image alt text where the post shows it." | add `cover_alt:`, or alt text on the image in the post |
| `E_PROJECT_UNKNOWN` | no project of this page has the slug `project:` names | use the slug of one of your projects (`list_projects`), or remove the key |
| `E_PROJECT_KIND` | `project:` in a project's own text: a project cannot be an update | remove the key from the project |
| `E_PROJECT_UNPUBLISHED` | publish of an update while its project is not published | publish the project first, then the update |
| `E_CATEGORY_UNKNOWN` | "No category on this page has the slug {slug}.": validate, or publish after the category was removed | use a slug from `get_me` `creator.categories`, or remove the key |
| `E_CSS_TOO_LARGE` | too many line-numbered code blocks | remove `ln` from some fences |
| `E_CSS` | the platform built styles outside its own allow-list (a platform fault, not the text) | report it with the `request_id` |
| `E_BODY_TOO_LARGE` (413) | the post text is over 256 KiB of UTF-8 | split the post |
| `E_BODY_TOO_COMPLEX` (422) | the text stacks markdown delimiters far beyond any post (a run of more than 256, or more than 256 unclosed brackets) | remove the repeated characters, or split the text |
| `E_TIMEOUT` (504) | validation took longer than 8 s, or an image body did not arrive in its deadline | split the post, or send the image faster, then retry |
| `E_VERSION_CONFLICT` | `expected_version` (or `If-Match`) is stale | fetch the post and merge |
| `E_NOT_VALIDATED` | publish before validate | run `validate_post` on the current version |
| `E_VALIDATION_FAILED` | publish of a version whose last validation reported errors | fix them, validate again |
| `E_ALREADY_PUBLISHED` | `preview_post` on a published post | open the public URL in `fix`; previews are for drafts |
| `E_NOT_PUBLISHED` | `unpublish_post` on a post that is not published | nothing to take down |
| `E_TIER_LOCKED` | a change of a tier's slug or currency after supporters joined it | keep them; create a new tier |
| `E_GOAL_REACHED` | a reached goal cannot change: `set_goals` changes its amount or removes it | add a goal above it instead; its title and promise can still be corrected |
| `E_PROJECT_CLOSED` | the project is closed (what a contributor meets; an agent never funds) | nothing; the maker closed funding |
| `E_PROJECT_AMOUNT` | the amount is outside the project's bounds (what a contributor meets) | a whole-dollar amount within the bounds `get_me` reports as `limits.project` |
| `E_KEYS_PAUSED` | This maker's keys are paused by an admin (writes only; reads are served) | nothing from an agent; the maker reads the notice in the Studio |
| `E_FORBIDDEN` | the role the key's creator holds on the team page does not allow the act ("Your role on {page} does not allow this.") | ask an owner of the page to do it, or to change that person's role |
| `E_INVITE_INVALID`, `E_INVITES_FROZEN`, `E_INVITE_QUOTA` | what a person meets at `/apply` or in the Studio, never an agent: that invite does not work; invites are paused for this maker; all twenty invites are out | leave the field empty to join the queue; an admin lifts it; none |
| `E_WALLET_LIMIT` | a project holds five wallets (a person adds them in the Studio) | remove one first |
| `E_IDEMPOTENCY_MISMATCH` | the same `Idempotency-Key` with a different text | use a new key for a new text |
| `E_MODERATION_HOLD` | the post or the maker is under a rule action | a person reads the notice at the URL in `fix`, replies or appeals; no tool lifts a hold |
| `E_INVALID_KEY` | the key is missing, malformed, unknown, expired or revoked | a person creates one at `/studio/keys` |
| `E_INSUFFICIENT_SCOPE` | the key was never given a scope (a suspended maker's write answers `E_MODERATION_HOLD`) | a person creates a key with that scope |
| `E_RATE_LIMITED` | this key has used its posts or images for today, or the keys of the page have used the page's posts, images or other writes for today, or its requests for this minute (the key's or the page's), or it is paused, or a second validation runs for the same maker (over MCP the tool answers only the publish limit) | wait for the `Retry-After` seconds, or resume the key in the Studio if it is paused |
| `E_UNAVAILABLE` (503) | the validation or image queue is full, or the service could not answer | retry in a moment |

Over MCP, a bad key, a key without a tool's scope, more than the key's requests a minute, a
write past the day's posts or images or from a paused key (a request with a refused write is
refused whole), an admin's pause and a body over 2 MiB are answered at the HTTP level before any
tool runs (401, 403, 429, 413) with a small JSON body; the key limits add `code`, `fix` and
`request_id` to it. `/agents.md` 06 lists them. Every other failure is a tool error with the
fields above.

Warnings: `W_HEADING_DEMOTED`, `W_WIDE_IGNORED`, `W_IMAGE_SIZE_UNKNOWN`, `W_EMBED_STALE`, `W_EMBED_META`,
`W_PUBLISH_AT_IGNORED`, `W_RATING_MISSING` (a draft without a rating; publishing needs one),
`W_COVER_SMALL` (the `cover:` image is narrower than 610 pixels, so cards use the next choice).
