# DevPlace Documentation

Complete developer documentation for `https://devplace.net`.

## Contents

- [Overview](#doc-index)
- [Getting started](#doc-getting-started)
- [Get started with vibing](#doc-getting-started-vibing)
- [The workspace editor](#doc-workspace-editor)
- [The feed](#doc-feed)
- [Code Farm](#doc-code-farm)
- [Quizzes](#doc-quizzes)
- [Block and mute](#doc-block-and-mute)
- [Emoji shortcodes](#doc-emoji-shortcodes)
- [Devii Assistant](#doc-devii)
- [Devii on Telegram](#doc-telegram)
- [Media gallery](#doc-media-gallery)
- [Notification settings](#doc-notification-settings)
- [Timezone-aware dates](#doc-timezones)
- [Online presence](#doc-presence)
- [Profile awards](#doc-awards)
- [AI content correction](#doc-ai-correction)
- [AI modifier](#doc-ai-modifier)
- [Terms of Service](#doc-terms)
- [Community Guidelines](#doc-community-guidelines)
- [Privacy Policy](#doc-privacy)
- [How moderation works](#doc-content-moderation)
- [Notice and takedown](#doc-intellectual-property)
- [Contact](#doc-contact)
- [SEO Diagnostics](#doc-tools-seo)
- [DeepSearch](#doc-tools-deepsearch)
- [AI Usage Analyzer](#doc-tools-isslop)
- [AI Usage Analyzer checks](#doc-isslop-checks)
- [Claude Code setup](#doc-claude)
- [Manual](#doc-claude-manual)
- [Subagents](#doc-claude-agents)
- [Commands](#doc-claude-commands)
- [Workflows](#doc-claude-workflows)
- [Components overview](#doc-components)
- [dp-avatar](#doc-component-dp-avatar)
- [dp-code](#doc-component-dp-code)
- [dp-content](#doc-component-dp-content)
- [dp-title](#doc-component-dp-title)
- [dp-upload](#doc-component-dp-upload)
- [dp-toast](#doc-component-dp-toast)
- [dp-dialog](#doc-component-dp-dialog)
- [dp-context-menu](#doc-component-dp-context-menu)
- [dp-lightbox](#doc-component-dp-lightbox)
- [devii-terminal](#doc-component-devii-terminal)
- [devii-avatar](#doc-component-devii-avatar)
- [emoji-picker](#doc-component-emoji-picker)
- [Design system overview](#doc-styles)
- [Colors](#doc-styles-colors)
- [Layout](#doc-styles-layout)
- [Responsiveness](#doc-styles-responsiveness)
- [Consistency rules](#doc-styles-consistency)
- [Authentication](#doc-authentication)
- [XML-RPC API](#doc-xmlrpc)
- [Overview](#doc-devrant)
- [Authentication & accounts](#doc-devrant-auth)
- [Rants](#doc-devrant-rants)
- [Comments](#doc-devrant-comments)
- [Users & avatars](#doc-devrant-users)
- [Notifications](#doc-devrant-notifications)
- [Client scripts](#doc-devrant-clients)
- [Conventions & Errors](#doc-conventions)
- [Authentication](#doc-auth)
- [Search & Lookups](#doc-lookups)
- [Votes, Reactions, Bookmarks & Polls](#doc-social-actions)
- [Posts, Comments, Projects, Gists & News](#doc-content)
- [Profiles & Social Graph](#doc-profiles)
- [Messaging](#doc-messaging)
- [Reporting & Moderation](#doc-moderation)
- [Notifications](#doc-notifications)
- [Uploads](#doc-uploads)
- [Project Filesystem](#doc-project-files)
- [Dev Workspaces](#doc-workspaces)
- [Tools (SEO, DeepSearch & AI Usage Analyzer)](#doc-tools)
- [Web Push](#doc-push)
- [Issue Reports](#doc-issues)
- [OpenAI Gateway](#doc-gateway)
- [Code Farm](#doc-game)
- [Quizzes](#doc-quizzes)

---

<a id="doc-index"></a>
# DevPlace Documentation

DevPlace is a server-rendered social network for developers with a clean HTTP surface you
can automate. Anything you can do in the browser you can also do from a script.

> Tip: use the **search box** in the sidebar, or the [docs search](/docs/search.html) page,
> to find anything across these pages instantly.

## Choose your path

**Use the site.** Learn what DevPlace offers and let the assistant do the work:
[Devii Assistant](#doc-devii), [Media gallery](#doc-media-gallery),
[Notification settings](#doc-notification-settings).

**Build on the API.** Authenticate, learn the shared conventions, then browse the
reference: [Authentication](#doc-authentication),
[Conventions and Errors](#doc-conventions). Every reference page lists its endpoints
with copy-paste cURL, JavaScript, and Python examples and an interactive panel that runs the
call with your own API key pre-filled.

**Contribute to DevPlace.** Go from a fresh clone to a running instance and your first
change: [Getting started](#doc-getting-started).

**Know where you stand.** The rules, what happens when they are broken, and what DevPlace does
with your data: [Terms of Service](#doc-terms),
[Community Guidelines](#doc-community-guidelines), [Privacy Policy](#doc-privacy),
[How moderation works](#doc-content-moderation),
[Notice and takedown](#doc-intellectual-property), [Contact](#doc-contact).

## Machine-readable schema

An OpenAPI schema and an interactive explorer are generated from the backend:

- [Swagger UI](/swagger) - try endpoints from an auto-generated console.
- [OpenAPI JSON](/openapi.json) - the raw schema for codegen and tooling.

## Download

Grab the entire documentation as a single file:

- [Single-page HTML](/docs/download.html) - one self-contained, offline-ready file.
- [Markdown](/docs/download.md) - the complete docs as one Markdown document.

## Base URL

Every example in these docs uses your current host:

```
https://devplace.net
```

---

<a id="doc-getting-started"></a>


---

<a id="doc-getting-started-vibing"></a>
# Get started with vibing

> **Alpha, admin-only.** Vibe coding is a preview feature. The runtime is still
> changing and access is currently limited to administrators. This guide is public
> so anyone can read how it works, but the create, start, terminal, and ingress
> actions described below only succeed for an administrator account.

**Vibing** is building software by talking to an AI agent instead of typing every
line yourself. On DevPlace you get a real Linux container in the cloud, a coding
agent that works like Claude Code, and a one-line way to put the result online.
You describe what you want, the agent writes and runs the code, and you ship it
under your own slug.

You can do every step from the admin **Containers** screens, but the friendliest
path is to simply ask **Devii**, the built-in assistant. This guide focuses on the
Devii way: each instruction below is plain English you can type into Devii, and the
note next to it names the tool Devii runs for you.

## What you get

- A **project** to hold your files (the persistent storage for your work).
- A **container** built from the shared `ppy` image, with your project files
  mounted at `/app` and a broad toolchain preinstalled.
- Three AI agents baked into every container, all running on **your own API key**:
  - **DevPlace Code (`dpc`)** - a coding agent in the same class as Claude Code.
  - **`botje.py`** - a plug-and-play DevPlace bot you can copy and customise.
  - **`pagent`** - a minimal, zero-dependency agent for small scripted tasks.
- **Ingress**: publish a port from your container to a public URL at `/p/<slug>`.

All AI usage from inside the container is metered to the account whose API key the
container carries, so your spend rolls up under your own profile, exactly like
direct API calls.

## The four steps, the Devii way

Open Devii from the user menu (the **Devii** item) and type these in order. Devii
asks for confirmation on anything destructive, so you stay in control.

**1. Create a project (storage).**

> "Create a project called Vibe Lab with the description: my first vibe-coded app."

Devii calls `create_project`. The project is the home for every file your container
produces.

**2. Attach a container to it.**

> "In Vibe Lab, create a container named lab that maps port 8000."

Devii calls `container_create_instance`. The instance runs the shared `ppy` image
with your project mounted at `/app`. A bare port like `8000` auto-assigns a unique
host port; use `host:container` only if you must pin one.

**3. Start the container.**

> "Start the lab container in Vibe Lab."

Devii calls `container_instance_action` with `action=start`. (If you set
`autostart` when creating it, it is already running and you can skip this.)

**4. Open a terminal.**

> "Open a terminal in the lab container."

Devii calls the `open_terminal` action, which opens a floating xterm.js window in
your browser attached to the container's interactive shell. From here you run
`dpc`, `botje.py`, or anything else.

You can also do everything without the terminal: ask Devii to run one-shot commands
with `container_exec` ("run `pip list` in the lab container"), read output with
`container_logs`, check resource use with `container_stats`, and import the
container's files back into the project with `container_instance_action`
`action=sync`.

## Inside the container

- The OS user is always **`pravda`** (uid 1000). This is deliberate: `/app` is
  bind-mounted from the host, so writing as uid 1000 keeps file ownership correct.
- Your working directory is **`/app`**, which is your project's files. Anything you
  create there can be synced back into the project.
- **`apt` and `sudo` work without real root.** `apt install <package>` installs
  system packages through a fakeroot wrapper, and `sudo` runs the command as
  `pravda` rather than switching to root. You cannot bind a port below 1024 (use a
  high port plus ingress instead), but otherwise the environment behaves like a
  normal box you own.
- Preinstalled tooling includes `git`, `curl`, `wget`, `vim`, `tmux`, `htop`, `nc`,
  `zip`, the Apache benchmark tool `ab`, Playwright with Chromium, and a wide
  Python stack (Flask, Django, FastAPI, uvicorn, pandas, numpy, requests, httpx,
  beautifulsoup4, sqlalchemy, pytest, ruff, black, and more).

## DevPlace Code (dpc)

`dpc` is **DevPlace Code**, a terminal coding agent that provides the same kind of
experience as Claude Code: you give it a task, it reads and writes files, runs
commands, fixes what it broke, and iterates until the job is done. It is installed
at `/usr/bin/dpc` and ready to use the moment your container starts.

```bash
dpc "build a small FastAPI app in app.py that serves a JSON health check at /"
```

`dpc` reads your API key from the container environment (`DEVPLACE_API_KEY`) and talks
to the platform AI gateway, so **all of its AI usage is metered through your own
account**. There is no separate key to manage and nothing to configure: it is plug
and play.

In a **workspace** you do not even have to start it. The DevPlace editor opens a
**DevPlace Code** terminal running `dpc` for you the moment the workspace boots, with
a plain shell beside it. See [The workspace editor](#doc-workspace-editor) for
the boot terminals, the trust policy, and every size you can change.

## Container environment keys

Every container is launched with these variables already set. Scripts and agents
inside the container read them to reach the platform and to attribute AI spend.

| Variable | What it contains |
|----------|------------------|
| `DEVPLACE_BASE_URL` | The public base URL of this DevPlace instance. |
| `DEVPLACE_OPENAI_URL` | The AI gateway endpoint, `DEVPLACE_BASE_URL` + `/openai/v1`. |
| `DEVPLACE_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
| `DEVPLACE_USER_UID` | The DevPlace user id whose identity the container carries. |
| `DEVPLACE_CONTAINER_NAME` | The instance's name. |
| `DEVPLACE_CONTAINER_UID` | The instance's unique id. |
| `DEVPLACE_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |

The key that lands in `DEVPLACE_API_KEY` is resolved in order from the instance's
**run-as user**, then its creator, then the project owner. You can point a container
at a specific account by asking Devii to set `run_as_uid` when creating or
configuring it. The OS user stays `pravda`; only the identity and key change.

## botje.py - the plug-and-play bot

`botje.py` (installed at `/usr/bin/botje.py`) is a complete, ready-to-run DevPlace
bot. Start it with no arguments and it logs in with your `DEVPLACE_API_KEY`, then
polls DevPlace for `@mentions` and direct messages and answers each one with a full
agent toolset. Give it a task on the command line and it runs that single task and
exits.

```bash
python /usr/bin/botje.py                              # run as a DevPlace bot (polling loop)
python /usr/bin/botje.py "summarise the latest news"  # run one task and exit
```

**What it can do.** Behind both modes is a complete agent: read, write, edit, and
patch files; search with grep, glob, and symbol lookup; search the web and do deep
research; fetch and download URLs; describe images; run shell commands; and plan,
reflect, verify, and delegate to sub-agents for larger jobs.

**How it is configured.** Everything comes from the environment, so it is plug and
play inside a container:

| Variable | Effect |
|----------|--------|
| `DEVPLACE_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
| `DEVPLACE_BASE_URL` | Which DevPlace instance to talk to (already set). |
| `BOT_USERNAME` | The bot's own username, so it ignores its own posts. |
| `MENTION_POLL_SECONDS` | How often it checks for mentions (default 30). |
| `DM_POLL_SECONDS` | How often it checks for direct messages (default 10). |
| `DEVPLACE_MAX_ITERATIONS` | Upper bound on agent steps per task. |

**Make it your own.** `botje.py` is the reference bot, and it is meant to be
forked. Copy it into your project and vibe the changes with `dpc`:

```bash
cp /usr/bin/botje.py /app/mybot.py
dpc "in mybot.py, make the bot also reply 'pong' whenever a message contains the word ping"
python /app/mybot.py
```

Because the copy lives in `/app`, a `sync` saves it into your project so it
persists. You can run it as the container's boot command (ask Devii to set
`boot_command` to `python /app/mybot.py`) and add a `restart_policy` so it stays up.

## Ingress: host your app at /p/&lt;slug&gt;

Ingress publishes one container port to a public URL on the platform. Once set, your
app is reachable at `/p/<slug>` over both HTTP and WebSocket. The target host and
port are derived from the instance, never from user input, so there is no way to
point ingress at something you do not own.

Two values control it:

- **`ingress_slug`** - the public name. Lowercase letters, digits, and hyphens,
  up to 63 characters, and unique across the whole platform.
- **`ingress_port`** - the container port to publish. It must be one of the ports
  you mapped on the instance. If the instance maps exactly one port you can omit
  this and it is chosen for you.

**Set it through Devii** at create time:

> "Create a container named web in Vibe Lab, map port 8000, and expose it publicly
> as vibe-lab on port 8000."

or on an existing instance by recreating it with the ingress fields, or by asking
Devii to configure the ports and ingress. The resulting URL is
`DEVPLACE_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
`DEVPLACE_INGRESS_URL` so your app can self-reference its own public address.

## Tutorial: vibe a web app and put it online

This is the full loop, start to finish, entirely through Devii and `dpc`.

**1. Create the project and an exposed container.** In Devii:

> "Create a project called Quote Wall. Then create a container named web in it, map
> port 8000, expose it publicly as quote-wall on port 8000, and start it."

Devii runs `create_project`, then `container_create_instance` with
`ports=8000`, `ingress_slug=quote-wall`, `ingress_port=8000`, `autostart=true`.

**2. Open a terminal.**

> "Open a terminal in the web container."

**3. Vibe the app with dpc.** In the terminal:

```bash
dpc "create app.py: a Flask app that serves an HTML page listing inspirational
     quotes, with a form to add a new quote stored in quotes.json. Bind to
     0.0.0.0 port 8000. Then run it."
```

`dpc` writes `app.py` and `quotes.json`, installs anything it needs, and starts the
server on port 8000 inside the container.

**4. Visit your live app.** Open `DEVPLACE_BASE_URL` + `/p/quote-wall` in your browser.
The platform proxies the request straight to port 8000 in your container. Add a
quote in the form and watch it persist.

**5. Keep it running and save the work.** Back in Devii:

> "Set the web container's boot command to `python /app/app.py`, set its restart
> policy to unless-stopped, then sync it."

Devii configures the boot command and policy with `container_configure_instance`,
and `sync` (via `container_instance_action`) imports `app.py` and `quotes.json` back
into the Quote Wall project so they are saved. Your app now restarts on its own and
its source lives in your project.

That is the whole vibe loop: describe, run, expose, save. From here you iterate by
asking `dpc` for the next feature and refreshing `/p/quote-wall`.

## Tutorial: vibe a custom bot by changing botje

`botje.py` is the reference bot, and it is built to be changed. In this tutorial you
turn the stock bot into a **personal helpdesk bot** that recognises its own commands,
adds a brand-new agent tool, and remembers state between restarts - all by chaining
small `dpc` edits. You never edit the file by hand; you describe each change and let
`dpc` make it.

The pattern is the same every time:

1. Copy `botje.py` once into your project.
2. Ask `dpc` for one focused change.
3. Run the bot and try it from another account.
4. Ask `dpc` for the next change.
5. When it behaves, set it as the boot command and `sync` to save it.

**1. Start from a copy.** In a project's running container (the Vibe Lab or Quote
Wall from the steps above both work), open a terminal and copy the bot into `/app`
so it persists with the project:

```bash
cp /usr/bin/botje.py /app/helpdesk.py
```

**2. Add a custom command.** A command is just a phrase the bot recognises in a
mention or DM. Ask `dpc` to add one:

```bash
dpc "in /app/helpdesk.py, add a custom command: when a direct message starts with
     '!help', reply with a short list of the commands this bot supports. Keep the
     existing mention and DM behavior intact."
```

`dpc` reads the file, finds where incoming messages are handled, and inserts the
command without disturbing the rest. Run it and test from a second account:

```bash
python /app/helpdesk.py
```

DM the bot `!help` from another user and you should get the command list back.

**3. Give it a brand-new tool (the special functionality).** The bot answers with an
agent that has a fixed toolset. You extend that toolset the same way the built-in
tools are defined: a function decorated with `@tool`. Describe the tool you want and
let `dpc` wire it in:

```bash
dpc "in /app/helpdesk.py, add a new @tool called open_ticket(summary, priority) that
     appends a ticket as one JSON line to /app/tickets.jsonl with an id, the summary,
     the priority, and the current ISO timestamp, and returns the new ticket id.
     Register it so the agent can call it, then teach the bot: when a DM starts with
     '!ticket ', open a ticket from the rest of the message and reply with the id."
```

Now the bot can file tickets on request, and because the agent sees the tool in its
list it can also decide to open one on its own when a conversation clearly describes
a problem. Restart and test:

```bash
python /app/helpdesk.py
```

DM `!ticket the login page is slow` and confirm a line lands in
`/app/tickets.jsonl`.

**4. Add memory so it survives restarts.** State lives in plain files under `/app`,
which is exactly what persists and syncs:

```bash
dpc "in /app/helpdesk.py, add a !tickets command that reads /app/tickets.jsonl and
     replies with the count of open tickets and the three most recent summaries.
     Make the file read tolerant of it not existing yet."
```

**5. Refine the voice.** Chaining keeps working as long as you ask for one change at
a time:

```bash
dpc "in /app/helpdesk.py, make every reply start with 'Helpdesk:' and stay under two
     sentences unless the user asked for a list."
```

**6. Run it on boot and save it.** Once the bot behaves, hand it to the container
service. In Devii:

> "Set this container's boot command to `python /app/helpdesk.py`, set its restart
> policy to unless-stopped, then sync it."

Devii configures the boot command and policy with `container_configure_instance`, and
`sync` imports `helpdesk.py` and `tickets.jsonl` back into the project so the whole
bot is saved. It now starts on its own, restarts if it stops, and answers on your own
API key.

**Where to take it next.** Because the bot already has file, web-search, deep-research,
fetch, vision, and shell tools, a single `dpc` prompt can teach it almost any new
behavior: summarise a URL someone sends, run a quick check and report the result,
post a daily digest, or escalate a ticket by mentioning an admin. Add one tool or one
command per prompt, test, and `sync`. That is how you vibe a bot with genuinely
special functionality without writing it from scratch.

## Limits and safety

- The feature is in **Alpha** and **admin-only**. Behaviour and limits may change.
- Devii **confirms before anything destructive**: deleting an instance and
  destructive shell commands (`rm`, `dd`, `truncate`, dropping a database, and the
  like) are refused until you explicitly confirm.
- You cannot bind ports below 1024 inside the container. Use a high port and
  ingress to serve on the public web.
- AI usage from `dpc`, `botje.py`, and `pagent` is metered to the API key the
  container carries. Keep an eye on your usage on your profile.

## Read next

- [Devii Assistant](#doc-devii) - everything the assistant can do for you.
- [DeepSearch](#doc-tools-deepsearch) and [SEO Diagnostics](#doc-tools-seo) -
  the other tools you can drive conversationally.

---

<a id="doc-workspace-editor"></a>
# The workspace editor

Every DevPlace workspace opens a full editor in your browser. It is branded DevPlace,
it starts a coding agent for you, and it is configured from your DevPlace account
rather than from inside the editor.

Open one from a project's **Workspace** page, or with the **Editor** button on the
project itself once the workspace is running.

## What opens on boot

When your workspace starts, two terminals open at the bottom of the window:

- **DevPlace Code** runs [`dpc`](#doc-getting-started-vibing), the coding agent
  that ships in every workspace. It has focus, so you can type a request straight
  away. Every token it spends is metered against your own DevPlace account.
- **pravda@workspace** is an ordinary login shell, so the Python, Rust, Nim and Swift
  toolchains are all on your `PATH`.

New terminals you open later are plain shells. To start another agent, pick
**DevPlace Code** from the terminal dropdown, or run the command
**DevPlace: Start DevPlace Code**.

The workspace opens straight onto your files with the terminal ready, not onto a welcome
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and
`dpc.log`) stay in the container and are never copied into your project.

You can turn either of them off. See **Your preferences** below.

## Every workspace is trusted

VS Code normally opens an unfamiliar folder in **Restricted Mode**, which disables
tasks, debugging and most extensions until you click to trust it. DevPlace turns
that off: your workspace is yours, so it is trusted from the first second and
nothing prompts you.

**This has a real consequence, and you should know it.** Automatic tasks are enabled
too, so if a project you open contains a `.vscode/tasks.json` with a
`"runOn": "folderOpen"` task, that task runs when the folder opens. If you are about
to open code you did not write and do not trust, read that file first.

An administrator can restore Restricted Mode for the whole site from the workspace
service settings.

## Size

Four separate things have a size, and they are set in two different places.

| What | Set by | Where |
|---|---|---|
| Editor font size, terminal font size, zoom | You | Your workspace page |
| Editor layout and terminal panel size | You | Your workspace page |
| How the editor opens (tab or sized window) | You | Your workspace page |
| CPU, memory and disk | An administrator | Your workspace quota |

Your own preferences follow you into every workspace you open. The container size is
part of your quota and is shown on the same page so you always know what you have.

## Your preferences

The **Editor** card on your workspace page holds them all:

- **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
  inside the editor and DevPlace will not touch it again).
- **Layout** - Standard, Terminal focus, or Zen.
- **Terminal panel** - Short, Normal, Tall or Maximized.
- **Editor font size**, **Terminal font size**, **Zoom level**.
- **Agent on boot** and **Shell on boot**.
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the
  width and height for the sized case.

Every field has a **Site default** option. Choosing it removes your preference and
lets the administrator's value apply again, including any future change to it.
**Reset to site defaults** does that for all of them at once.

Over the API and through Devii the same rule applies field by field: only the fields
you send are changed, and a field you send as empty or zero goes back to inheriting.

### They apply on the next start

Editor settings are read when the workspace container boots. After you save, the page
tells you if a restart is needed and gives you the buttons to do it.

### DevPlace never overwrites a setting you changed yourself

If you change something inside the editor, that value is yours from then on. DevPlace
only writes a setting it wrote itself last time, so a change to the site default
reaches everyone who has not expressed an opinion and no one who has.

## Doing it from Devii

Devii can read and change these for you:

- *"what is my workspace editor set to"* runs `workspace_editor_get`.
- *"make my workspace editor font 18 and use the light theme"* runs
  `workspace_editor_set`. It will show you the exact values and ask before saving.

## Commands inside the editor

Press `F1` and type `DevPlace` for the full list:

| Command | What it does |
|---|---|
| **DevPlace: Start DevPlace Code** | Opens another `dpc` terminal |
| **DevPlace: Open project on DevPlace** | Your project page |
| **DevPlace: Open workspace settings** | Your workspace page |
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
| **DevPlace: Open the DevPlace editor guide** | This page |

## Publishing a port from the editor

Forward a port in the editor's **Ports** view and DevPlace publishes it for you.
The moment you forward it, the editor registers the port with DevPlace, which
creates the tunnel, orders its HTTPS certificate and answers with the public
address - the same address the Ports view shows you. Publishing counts against
your tunnel quota, so a port DevPlace refuses is reported back in the editor with
the reason.

Two things to know:

- The address serves HTTPS as soon as the certificate is issued, which takes a
  few seconds. Until then your browser warns about the certificate name.
- Un-forwarding the port in the editor does **not** remove the tunnel. Public
  addresses are removed deliberately, on your workspace page or by asking Devii,
  so a restarted dev server never silently loses its link.

## Related

- [Get started with vibing](#doc-getting-started-vibing) - the container
  runtime, the agents, and publishing what you build.
- The **Dev Workspaces** API group for the same settings over HTTP.

---

<a id="doc-feed"></a>
# The feed

The post feed at `/feed` and the **Latest posts** section on the home page show the newest posts
across DevPlace. Rather than listing them in plain reverse-chronological order, the feed **interleaves
authors** so the page reads as a varied mix of people instead of a wall of consecutive posts from one
prolific account.

## How posts are ordered

Each page still covers the same chronological window of posts, but the posts inside it are
rearranged for a balanced spread:

- **Authors are interleaved as evenly as possible.** Two posts from the same author are never shown
  one after another, unless the entire page belongs to a single author.
- **Each author's own posts keep their original order.** Interleaving only changes how authors are
  woven together; within one author, the older-to-newer sequence is preserved exactly.
- **Nothing is dropped.** Every post in the window is shown. The reordering is a pure rearrangement,
  so it never hides a post and never duplicates one.

When one author has so many posts that they cannot all be separated (for example, four posts from one
person on a page of five), the unavoidable extras are placed last, after every other author has been
spread out.

## Filters and tabs

The reordering applies to every view of the feed: the **All** and **Following** tabs, topic filters,
and the free-text search box in the left panel. The **Trending** tab ranks by stars first and then
applies the same author interleaving.

## Pagination

Infinite scroll is unaffected. Because the interleave only rearranges the posts already chosen for a
page, each page continues exactly where the previous one ended, with no gaps and no repeats as you
scroll.

---

<a id="doc-code-farm"></a>
# Code Farm

Code Farm is a cooperative idle game built into DevPlace. Every member owns a farm of plots,
plants software projects that build over real time, harvests the finished builds for coins and
experience, and grows the farm with faster CI, more plots, and permanent perks. You can also
visit other members' farms to water their growing builds for a reward, or raid a ready build that
its owner left sitting too long.

Open it from your user menu or go straight to `https://devplace.net/game`. You need a member account to
play. Visiting another farm at `https://devplace.net/game/farm/{username}` is public.

Everything on this page also runs over the JSON API, so the game can be driven by a script as
well as by hand. The full request and response reference is the
[Code Farm API group](#doc-game); this page explains the rules and the exact numbers behind
them, then documents every field a client sees and shows a complete automated client at the end.

## The core loop

1. **Plant** a crop in an empty plot. Planting costs coins.
2. The crop **builds** over real time. The build finishes at a fixed wall-clock moment
   (`ready_at`); there is no server tick, the clock alone decides.
3. **Harvest** the finished build for coins and experience, which empties the plot again.
4. Spend the coins on faster CI, more plots, and perks so the next loop pays more.

A farm starts with **50 coins** and **4 plots**. Nothing is lost while you are away: builds keep
progressing and wait for you, so the game is safe to check once an hour or once a day.

## Crops

Each crop has a coin cost to plant, a base build time, a coin and experience reward on harvest,
and a level at which it unlocks. Slower crops pay far more per build.

| Crop | Key | Cost | Base build time | Coins | XP | Unlocks at |
|------|-----|-----:|----------------:|------:|---:|:----------:|
| 🐚 Shell Script | `shell` | 5 | 30s | 11 | 2 | level 1 |
| 🐍 Python Script | `python` | 15 | 2m | 36 | 5 | level 1 |
| 📜 Web App | `webapp` | 40 | 5m | 98 | 12 | level 2 |
| 🐹 Go Service | `api` | 90 | 10m | 224 | 25 | level 3 |
| 🦀 Rust Engine | `rust` | 200 | 30m | 520 | 60 | level 4 |
| λ Compiler | `haskell` | 500 | 1h | 1380 | 150 | level 6 |
| ⚙️ Kernel | `kernel` | 1200 | 2h | 3600 | 400 | level 8 |
| 🕸️ Distributed System | `distsys` | 5000 | 4h | 11300 | 900 | level 20 + Mastery |
| 🧠 ML Pipeline | `mlpipe` | 12000 | 6h | 21000 | 1800 | level 20 + Mastery |
| 🔐 Security Fortress | `secfort` | 30000 | 8h | 48000 | 3200 | level 20 + Mastery |

The three Mastery-tier crops require **level 20** and at least **one earned Mastery point** (see
Mastery, below). The Mastery unlock is permanent - it checks points ever earned, not points held,
so spending your points never re-locks the crops. Security Fortress is **raid-immune**: it can
never be stolen.

Build time is the base time divided by the farm build speed (CI tier, the Build Cache perk, the
Bare-Metal Legacy upgrade, and the Private Registry for its three crops), so upgrades finish
everything proportionally faster. Coin and experience rewards shown in the API already include
your perks, refactor bonus, Legacy multiplier, and the current Market Saturation factor, so read
them from the live state rather than from this table. Each crop in the state also carries a
`market_state` of `normal`, `saturated`, or `boosted`.

## Plots

A farm starts with 4 plots and can grow to **12**. Each extra plot costs more than the last: the
price starts at **100 coins** and doubles for every plot you have already added.

| Plot number | Cost |
|:-----------:|-----:|
| 5th | 100 |
| 6th | 200 |
| 7th | 400 |
| 8th | 800 |
| 9th | 1600 |
| 10th | 3200 |
| 11th | 6400 |
| 12th | 12800 |

More plots is more builds in parallel, which is the main way to scale output.

## CI tier (build speed)

Upgrading your CI tier multiplies build speed for the whole farm. The upgrade is permanent (until
a refactor resets it).

| Tier | Name | Build speed | Upgrade cost |
|:----:|------|:-----------:|-------------:|
| 1 | Local Build | 1.0x | starting |
| 2 | Shared Runner | 1.25x | 150 |
| 3 | Fast Runner | 1.6x | 400 |
| 4 | Parallel Matrix | 2.0x | 1000 |
| 5 | Distributed Cache | 2.5x | 2600 |

## Levels and experience

Harvesting grants experience. Experience raises your level, and higher levels unlock the more
valuable crops. The maximum level is **20**. Reaching a level needs `50 * (level - 1)^2` total
experience, so level 2 is at 50 XP, level 5 at 800, level 10 at 4050, and level 20 at 18050. The
state reports `level`, `level_into` (XP into the current level), `level_span` (XP from this level
to the next), and `level_is_max`.

## Perks

Perks are permanent upgrades bought with coins (until a refactor resets them). The cost of level
`n+1` is `round(base * growth^n)`, so each level costs more than the last. There are four:

| Perk | Key | Effect per level | Max level | First cost | Cost growth |
|------|-----|------------------|:---------:|-----------:|:-----------:|
| 📈 Optimizer | `yield` | +5% harvest coins | 10 | 120 | 1.6x |
| ⚡ Build Cache | `growth` | +4% build speed | 10 | 150 | 1.7x |
| 🏷️ Bulk Licenses | `discount` | -3% planting cost | 8 | 100 | 1.7x |
| 🎓 Mentorship | `xp` | +5% harvest experience | 10 | 140 | 1.6x |

The live state reports each perk's current level and the exact cost of its next level - read
`cost` from the state rather than recomputing it.

## Daily bonus and streak

Once per UTC day you can claim a coin bonus of `20 + 12 * (streak_day - 1)`, where the streak day
caps at seven: **20 coins** on day one up to **92 coins** from the seventh consecutive day on.
Missing a day resets the streak to one. The state field `daily_available` tells you when a claim
is ready, and `daily_reward` is the exact amount the next claim pays.

## Daily quests

Each day you are given three small goals, deterministically chosen per user and day, such as
planting a number of crops, harvesting a number of builds, watering neighbour builds, or earning a
number of coins from harvests. Progress is tracked automatically as you play. A completed quest is
claimed by its **kind** (`plant`, `harvest`, `water`, or `earn`) for a coin and experience reward.
The state field `can_claim` on each quest marks the ones that are ready; claiming an incomplete or
already-claimed quest returns an error.

## Fertilizer

Fertilizing a growing build spends coins to **halve its remaining time**. The exact cost is
`ceil(realizable_harvest_coins * skipped_seconds / full_build_seconds * 1.05)`, where
`realizable_harvest_coins` is what *this specific build* will actually pay out - it includes your
perks, prestige, Legacy multiplier, and the market factor, **and** the golden multiplier, an
active weekly-contract boost, an active Underdog boost, and the Canary upside. Because the price
is computed from the same value the harvest pays, fully fertilizing a build always costs at least
105% of what it can return. Fertilizer is a pure time-skip, never a profit, at any prestige, on
any build, with any combination of boosts. Each growing plot in the state reports its current `fertilize_cost`; a build
with less than two seconds remaining cannot be fertilized.

## Golden builds

Every planting has a **5% chance** to come out **golden**, decided the moment it is planted and
fixed for that build's lifetime. A golden harvest pays **5x coins** (experience is unchanged).
The plot field `is_golden` marks it - the owner always sees it, a visitor only when the build is
currently stealable (a golden build is a 5x raid target too, so watch your grace window).

## Watering a neighbour (cooperation)

Visit another member's farm and water one of their **growing** builds. Watering reduces that
build's remaining time by **8% of its full (speed-adjusted) build time** and rewards **you**, the
visitor, with **6 coins scaled by your own prestige and Tech Debt Payoff multiplier** (so watering
stays worth doing at every stage of the game) plus **3 experience**. The daily bonus and daily
quest coin rewards scale the same way. Each visitor can water a given build **once per
growth cycle**, and a single build takes at most **3 waterings** total across all visitors, so
popular farms fill up. Each plot reports `can_water` (true only for signed-in non-owners who have
not watered it yet), `watered_count`, and `max_waters`. Watering also advances your own `water`
quest.

## Raiding a ready build (competition)

If an owner leaves a build **ready** without harvesting it, another member can raid ("steal") it,
but only after a protection window has passed since the build became ready. The window is **60
seconds** base, **+30s** per level of the owner's Branch Protection Legacy upgrade, plus the
owner's Defense tier grace bonus (table below).

**A raid takes a share, it does not destroy the build.** The raider receives
`min(cap, max(floor, (50% - 5% x Branch Protection level) x (1 - Defense tier reduction)))` of the
build's realized coin value, and that share is recorded on the plot as `raided_fraction`. The
**owner still harvests everything that was not taken** - a build raided for 35% still pays its
owner the remaining 65%. A build can be raided until its shares add up to 100%, after which
`steal_reason` is `stripped`. A Security Fortress build can never be raided.

The `cap` is 100% normally and **20%** when the owner owns the Observability Suite, so that
building genuinely limits raid losses. The `floor` is the owner's Defense tier minimum. The owner
is notified of the raider's name, the crop, and the exact amount taken.

You can raid any given neighbour only **once per hour**, and any given farm can be raided at most
**3 times per day** in total, so an inactive player can never be stripped by an unlimited queue of
raiders. Raiding a farm holding more than **10x** your own coins grants you a 24-hour **Underdog**
boost (+25% harvest coins, visible as `underdog_boost_seconds_remaining` in your state).

Each plot on a farm you view reports:

- `can_steal` - true when everything below is satisfied and you can raid right now.
- `steal_coins` - the exact payout a raid would give.
- `steal_reason` - why a ready build is not stealable: `protected` (grace window still running),
  `cooldown` (you raided this owner within the hour), `stripped` (the build has already been
  raided for its full value), or `immune` (Security Fortress). Empty when stealable or not
  applicable.
- `raided_fraction` / `raided_pct` - how much of this build has already been taken by raiders.
- `steal_cooldown_seconds` - seconds until your per-owner cooldown ends (only set with
  `steal_reason` of `cooldown`; also mirrored farm-wide as `steal_cooldown_seconds` on the farm).
- `ready_at` - add the protection window to compute when a raid becomes possible.

Harvest your own ready builds promptly to keep them safe, or invest in Defense (below).

## Market Saturation

The game tracks the last **48 hours** of league-wide harvests of each crop and converts them into
supply measured in build time ("plot-days" at base rate), so a 30-second Shell Script and a
2-hour Kernel saturate on the same real-terms scale - quick crops are not punished for being
quick. As supply piles up, the crop's payout steps down:

Supply is measured **per active farm**, so a busy server is not permanently floored by a handful
of heavy players - the thresholds scale with how many people are actually farming.

| Supply (plot-days in 48h, per active farm) | Payout |
|:-------------------------:|:------:|
| under 8 | 100% |
| 8 or more | 85% |
| 24 or more | 70% |
| 48 or more | 55% |
| 96 or more | 40% |

Separately, while the high-tier market (Rust Engine, Compiler, Kernel) is saturated, the four
starter crops (Shell Script, Python Script, Web App, Go Service) pay a boost of up to **+15%**
(scaling with how collapsed the high tier is) as long as they are not overfarmed themselves - a
crop is either penalized or boosted, never both. This rewards planting what the market is short
on instead of printing one crop nonstop. Nothing else about the crop changes - cost, build time,
and unlock level are unaffected. The live factor is folded into each crop's `reward_coins` and
summarized as `market_state` (`normal`, `saturated`, or `boosted`); the server refreshes market
factors about every 30 seconds.

## Infrastructure, Defense, and Cosmetics (coin sinks)

Once you are earning more coins than you can spend on plots and perks, three permanent systems
give large coins a purpose.

### Infrastructure

One-time, prestige-gated buildings bought with `POST /game/infrastructure/buy`:

| Building | Key | Effect | Cost | Requires |
|----------|-----|--------|-----:|:--------:|
| 📦 Private Registry | `registry` | Rust, Compiler, and Kernel builds finish 15% faster (watering them is 15% more effective too) | 3,000,000 | prestige 3 |
| 🐤 Canary Deployments | `canary` | Every harvest has a 12% chance to double and a 6% chance to only refund its planting cost | 6,000,000 | prestige 8 |
| 🔭 Observability Suite | `observability` | Caps what any raider can take from you at 20% of a build's value (see Raiding and Defense) | 15,000,000 | prestige 15 |

### Defense

An upgradeable building (`POST /game/defense/upgrade`) that lowers your raid losses and lengthens
your protection window per tier:

| Tier | Name | Upgrade cost | Daily upkeep (min) | Raid loss reduction | Undefended raider share | Extra grace |
|:----:|------|-------------:|-------------------:|:-------------------:|:-----------------------:|:-----------:|
| 0 | Undefended | - | 0 | 0% | 50.0% | +0s |
| 1 | Firewall | 5,000 | 500 | 5% | 47.5% | +15s |
| 2 | WAF | 40,000 | 2,500 | 12% | 44.0% | +30s |
| 3 | SOC Monitoring | 300,000 | 15,000 | 20% | 40.0% | +60s |
| 4 | Zero Trust Mesh | 2,000,000 | 100,000 | 30% | 35.0% | +120s |

The reduction is multiplicative and stacks with Branch Protection: a Zero Trust Mesh owner with
Branch Protection 5 hands over 17.5% instead of 50%. Grace stacks on top of the base 60 seconds
and any Branch Protection levels. The Observability Suite caps the raider's share at 20%
regardless of tier.

Unlike a one-time purchase, Defense costs a **daily coin upkeep** of
`max(tier_minimum, 0.2% of your coin balance)` - a large balance pays real money, not a flat fee.
Upkeep is charged lazily whenever you load your own farm, for every day elapsed since the last
charge, so staying away is not cheaper than playing. If your balance cannot cover what is due,
**only what you can afford is taken** and the tier decays by one level - your balance is never
emptied, and you are notified. You can also leave the commitment deliberately with
`POST /game/defense/downgrade` (no refund). The state reports `defense_level`,
`defense_tier_name`, `defense_upkeep_daily` (today's exact charge), `defense_next_cost`, and
`defense_downgrade_available`.

### Cosmetics

Purely cosmetic titles and plot skins bought with coins (`POST /game/cosmetics/buy`) and, for
titles, equipped with `POST /game/cosmetics/equip`. Zero gameplay effect; an equipped title shows
next to your name on the leaderboard.

| Cosmetic | Key | Kind | Cost |
|----------|-----|:----:|-----:|
| 🏛️ The Architect | `title_architect` | title | 500,000 |
| ♻ Serial Refactorer | `title_refactorer` | title | 250,000 |
| ⚙ Kernel Hacker | `title_kernel_hacker` | title | 1,000,000 |
| 🌈 Neon Terminal | `skin_neon` | skin | 750,000 |

Era-exclusive cosmetics (when an Era awards one) appear in your owned list but are never sold in
the shop. The state's `cosmetics` list carries each purchasable cosmetic with an `owned` flag,
and `active_title` is your equipped title key.

## Mastery (beyond prestige)

Refactoring past **prestige 50** starts earning a second currency: one Mastery point the moment
you cross 50, then one more every 5 further prestige (`total earned = 1 + (prestige - 50) / 5`,
rounded down). Points never expire, and crop unlocks check points **ever earned**
(`mastery_points_earned_total`), so spending never re-locks content. Spend points with
`POST /game/mastery` on three permanent upgrades:

| Mastery upgrade | Key | Effect | Cost (points) |
|------------------|-----|--------|:-------------:|
| 🔁 Continuous Delivery | `autoreplant` | Auto-replant the same crop right after every harvest, if still affordable and unlocked | 3 |
| 📊 Farm Analytics | `analytics` | Unlocks lifetime stats: `lifetime_coins_earned` and `lifetime_harvests` | 2 |
| 📜 Legacy Contracts | `contracts` | Unlocks a weekly contract slot paying Stars and a temporary coin boost | 4 |

Each has a single level. The state reports `mastery_points` (spendable),
`mastery_points_earned_total`, and each upgrade's level and cost.

### Weekly contracts

With Legacy Contracts owned, one **weekly contract** appears in your `quests` list with
`scope: "weekly"` - a larger goal of the same four kinds, deterministic per user and ISO week.
A coin-denominated contract scales its goal with your own earning power, so it stays a week's work
at every prestige. Claiming it (`POST /game/quests/claim` with the contract's `quest` kind and
`scope=weekly`) pays a fixed **3 Stars** (5 for a coin contract) plus experience, and grants a
**+20% harvest-coin boost for 48 hours**, shown as `contract_boost_seconds_remaining`.

## Refactor (prestige)

At level 10 or above you can **refactor**: coins, XP, level, CI tier, perks, and extra plots
reset, but you gain a permanent **+25% coin bonus per refactor** that multiplies with your perks.
Refactoring is the long-term progression and the single largest factor in the leaderboard. The
state reports `prestige`, `prestige_multiplier`, `prestige_min_level`, and `prestige_available`.

Refactoring is not free. The fee is:

```
fee = round(20000 * (1 + prestige) * (1 + 0.25 * prestige) + 0.15 * coins)
```

so it grows with both your refactor count and your current balance (a wealth tax), and each
refactor takes longer to afford than the last. The live state reports the exact `refactor_cost`
and whether you can pay it right now (`refactor_affordable`). After the fee, **10%** of your
remaining coins carry over into the new run - up to **35%** with the Golden Parachute Legacy
upgrade - previewed exactly as `refactor_carryover_preview` (and `refactor_carryover_pct`). The
new run starts with the 50 starting coins plus that carry-over.

Each refactor also awards **Stars**: `1 + level / 5 + prestige` (rounded down, using the level
and prestige you refactor from). Stars are never reset and are spent on permanent **Legacy
upgrades**. What survives a refactor: prestige, Stars, Legacy upgrades, Mastery points and
upgrades, Infrastructure, Defense, cosmetics, lifetime stats, and your Era counters.

## Community treasury and weekly grant

Every refactor fee flows into a shared **community treasury**. Once per ISO week, an active farm
that is still building up - at least **5 harvests this week**, fewer than **10,000 coins**, and
at most **prestige 5** - can claim a grant with `POST /game/grant`. The treasury is **shared
out between everyone currently eligible** rather than paid first-come-first-served: the amount is
`treasury_balance / eligible_farms`, capped at **2,500 coins** and suppressed entirely below a
**250 coin** minimum so nobody claims dust. The state reports `grant_available`, `grant_amount`,
`grant_reason` (a human-readable explanation when unavailable), and the current
`treasury_balance`.

## Legacy upgrades

Stars buy Legacy upgrades, permanent boosts that persist through every future refactor. The cost
of level `n+1` is `round(base * growth^n)` Stars. There are six:

| Legacy upgrade | Key | Effect | Max level | First cost | Cost growth |
|----------------|-----|--------|:---------:|-----------:|:-----------:|
| 🤖 CI Bot | `autoharvest` | Auto-collect ready builds whenever you load your farm | 1 | 3 | - |
| 💎 Tech Debt Payoff | `multiplier` | +10% harvest coins per level, stacks with refactor | 10 | 1 | 1.6x |
| 🏎️ Bare-Metal | `speed` | +5% base build speed per level | 8 | 1 | 1.7x |
| 🗂️ Monorepo | `plots` | +1 starting plot after each refactor per level | 8 | 3 | 2.0x |
| 🛡️ Branch Protection | `defense` | +30s steal grace and -5% steal loss per level | 5 | 2 | 1.8x |
| 🪂 Golden Parachute | `carryover` | +5% refactor coin carry-over per level | 10 | 2 | 1.8x |

The live state reports your `stars` balance and each legacy upgrade's current level and the exact
Star cost of its next level.

## Leaderboards and scoring

The default leaderboard ranks the top 25 farms by a composite score. Refactors dominate the
formula, then lifetime harvests, level, and everything else. The coin term is **capped**, so the
board measures what you have built rather than what you are sitting on, and refactoring - which
spends your balance - never costs you rank:

```
score = xp
      + prestige        * 5000
      + total_harvests  * 10
      + min(coins / 20, 100000)
      + (ci_tier - 1)   * 250
      + (plots - 4)     * 200
      + total_perk_levels * 120
      + min(streak, 30) * 15
```

Read the top farms with `GET https://devplace.net/game/leaderboard` (public, no account needed; results are
cached server-side for about 15 seconds). Add `?board=` to switch boards:

| Board | Ranked by |
|-------|-----------|
| `score` | The composite score above (default). |
| `prestige` | Prestige count, Stars as tiebreak. |
| `harvests` | Harvests this ISO week (`harvests_week`). |
| `raids` | Average coins per successful raid over the last 30 days, minimum 3 qualifying raids; each entry carries `raid_avg`. |
| `time_to_kernel` | Fastest Kernel harvest since the player's last refactor; each entry carries `time_to_kernel_seconds`. |
| `fair_play` | `harvests_week * 50 - coins / 200000` - rewards recent activity over hoarding. |
| `era` | The current Era's score (below); empty when no Era is running. |

Every entry carries `rank`, `username`, `level`, `xp`, `coins`, `total_harvests`, `prestige`,
`score`, and `title` (the display name of the player's equipped cosmetic title, empty when none).

## Eras (seasons)

Administrators can occasionally start an **Era**: a fresh, visible "this season" leaderboard
(`board=era`) that everyone starts at zero on, while their real coins, prestige, Stars, Legacy,
and Mastery are completely untouched. Era score is `era_coins / 20 + era_harvests * 10 + prestige
* 2000`, so veterans keep an edge without it being insurmountable. When an Era ends, the top 10
by Era score earn permanent Stars (50, 30, 20, 15, 10, then 5 each for ranks 6-10) and sometimes
an Era-exclusive cosmetic. Your own state reports `era_active`, `era_name`, `era_coins`, and
`era_harvests` while one is running; an Era can also make Era-exclusive crops plantable for its
duration.

## Live updates

A farm page subscribes to the pub/sub topic `public.game.farm.{username}` and refreshes whenever
that farm changes, so watering, harvesting, and raids appear without a reload. A scripted client
does not need pub/sub: it can simply re-read the state after each action, since every action
endpoint returns the full updated farm.

## Playing through the API

Every action is a normal DevPlace endpoint that negotiates HTML or JSON. Send
`Accept: application/json` to get JSON, and authenticate exactly like the rest of the API: with
your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key is on your
[profile page](/profile/YOUR_USERNAME).

### Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/game` | Your farm page (HTML, or the same JSON as `/game/state` when asked). |
| `GET` | `/game/state` | Your full farm state (always JSON). |
| `GET` | `/game/leaderboard` | Top farms (public). Accepts `?board=`. |
| `GET` | `/game/farm/{username}` | Another member's farm. |
| `POST` | `/game/plant` | Plant `crop` in `slot`. |
| `POST` | `/game/harvest` | Harvest the build in `slot`. |
| `POST` | `/game/fertilize` | Halve the remaining time of `slot`. |
| `POST` | `/game/buy-plot` | Unlock one more plot. |
| `POST` | `/game/upgrade` | Raise the CI tier. |
| `POST` | `/game/perk` | Upgrade `perk` (`yield`, `growth`, `discount`, `xp`). |
| `POST` | `/game/daily` | Claim the daily bonus. |
| `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind, optional `scope` (`daily` or `weekly`). |
| `POST` | `/game/prestige` | Refactor at level 10 or above; costs the current `refactor_cost` in coins. |
| `POST` | `/game/grant` | Claim the weekly community grant from the treasury. |
| `POST` | `/game/legacy` | Buy a Legacy upgrade (`key`) with Stars. |
| `POST` | `/game/mastery` | Buy a Mastery upgrade (`key`) with Mastery points. |
| `POST` | `/game/infrastructure/buy` | Buy an Infrastructure building (`key`) with coins. |
| `POST` | `/game/defense/upgrade` | Buy the next Defense tier with coins. |
| `POST` | `/game/defense/downgrade` | Drop one Defense tier to escape its upkeep (no refund). |
| `POST` | `/game/cosmetics/buy` | Buy a cosmetic (`key`) with coins. |
| `POST` | `/game/cosmetics/equip` | Equip an owned title cosmetic (`key`). |
| `POST` | `/game/farm/{username}/water` | Water a neighbour's build in `slot`. |
| `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. |

POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an
integer plot index, 0-based), `crop`, `perk`, `quest`, `scope`, and `key` (a
Legacy/Mastery/Infrastructure/cosmetic key) where the table notes them. Every own-farm POST
returns `{"ok": true, "farm": {...}}` - the full updated farm - so one call both performs the
action and gives you the new state. The two neighbour actions return the **neighbour's** farm as
you see it (`{"farm": {...}}`), and a successful steal adds `"stole_coins"` with your payout.

### Errors

An invalid action (not enough coins, wrong plot state, a still-protected harvest, an active
cooldown, an unmet requirement) returns **HTTP 400** with a human-readable reason:

```json
{"error": {"status": 400, "message": "Not enough coins to plant that."}}
```

An unknown farm username is **404**. Invalid credentials are **401**; a request with no
credentials at all is redirected (303) to the login page, so always send your API key. Mutating
requests count against the sitewide rate limit (per client IP, 60 per minute by default) - reads
do not.

### Two things that happen on state reads

Loading your own farm (`GET /game`, `GET /game/state`, or the farm returned by any action) is
when lazy owner-side effects run, so a scripted client should expect them:

- With the **CI Bot** Legacy upgrade, every ready build is auto-harvested (and with Continuous
  Delivery, auto-replanted) during the read - the state you get back is post-collection.
- With a **Defense** building, any daily upkeep due is charged during the read, so your `coins`
  can be lower than the previous response predicted (and the tier one lower, if you could not
  pay).

### Reading the state

```python
import json, urllib.request

request = urllib.request.Request(
    "https://devplace.net/game/state",
    headers={"X-API-KEY": "YOUR_API_KEY", "Accept": "application/json"},
)
with urllib.request.urlopen(request) as response:
    farm = json.loads(response.read())["farm"]

print("coins", farm["coins"], "level", farm["level"], "plots", farm["plot_count"])
for plot in farm["plots"]:
    print(plot["slot"], plot["state"], plot.get("crop_name"), plot.get("remaining_seconds"))
```

### The plot shape

`GET /game/state` returns `{"ok": true, "farm": {...}}`. The farm carries your totals plus the
lists you act on. A plot looks like this:

```json
{
  "slot": 0,
  "state": "growing",
  "crop_key": "python",
  "crop_name": "Python Script",
  "crop_icon": "🐍",
  "reward_coins": 36,
  "reward_xp": 5,
  "ready_at": "2026-06-23T12:34:56+00:00",
  "remaining_seconds": 73,
  "watered_count": 1,
  "max_waters": 3,
  "can_water": false,
  "can_steal": false,
  "steal_coins": 0,
  "steal_cooldown_seconds": 0,
  "steal_reason": "",
  "is_golden": false,
  "fertilize_cost": 22,
  "raided_fraction": 0.0,
  "raided_pct": 0
}
```

A plot's `state` is `empty`, `growing`, or `ready`. `reward_coins`/`reward_xp` on a plot are the
crop's **base** rewards; the multiplied live values are on the matching entry in `crops`. An
entry in `crops` carries the live `cost`, `reward_coins`, `reward_xp`, `grow_seconds`,
`min_level`, `market_state`, and a `locked` flag, so a client can decide what is both unlocked
and affordable without hard-coding the tables above.

### The farm state reference

Every field on the `farm` object, grouped. Fields marked *(owner)* are only populated when you
read your **own** farm - on someone else's farm the lists are empty and the flags false/zero.

**Identity:** `owner_username`, `owner_uid`, `is_owner`.

**Progress and currency:** `coins`, `xp`, `level`, `level_into`, `level_span`, `level_is_max`,
`total_harvests`, `plot_count`, `max_plots`, `next_plot_cost` (0 when maxed).

**CI:** `ci_tier`, `ci_label`, `ci_speed`, `ci_next_tier`, `ci_next_label`, `ci_next_cost` (all
`next` fields 0/empty at the top tier).

**Lists:** `plots`, `crops`, plus *(owner)* `perks`, `quests` (daily entries, and the weekly
contract when unlocked), `legacy`, `mastery`, `infrastructure`, `cosmetics`. Each purchasable
entry carries `key`, `name`, `icon`, `description`, its current `level`/`owned` state, the exact
next `cost`, and a `maxed` flag where applicable.

**Refactor:** `prestige`, `prestige_multiplier`, `prestige_min_level`, `prestige_available`
*(owner)*, `refactor_cost`, `refactor_affordable` *(owner)*, `refactor_carryover_pct`,
`refactor_carryover_preview`, `stars`.

**Grant and treasury** *(owner)*: `grant_available`, `grant_amount`, `grant_reason`,
`treasury_balance`.

**Daily:** `streak`, `daily_available` *(owner)*, `daily_streak_reset` (true when your streak
lapsed, so `daily_reward` already reflects the reset value), `daily_reward`.

**Raiding:** `steal_cooldown_seconds` (your remaining cooldown against this farm's owner; 0 on
your own farm), `steal_max_per_victim_per_day` (how many raids any single farm can absorb per
day).

**Mastery and lifetime stats:** `mastery_points`, `mastery_points_earned_total`,
`mastery_analytics_unlocked`, `lifetime_coins_earned`, `lifetime_harvests`, `harvests_week`.

**Defense:** `defense_level`, `defense_tier_name`, `defense_upkeep_daily`, `defense_next_cost`
(0 at the top tier), `defense_downgrade_available` *(owner)*.

**Cosmetics and boosts:** `active_title`, `underdog_boost_seconds_remaining`,
`contract_boost_seconds_remaining`.

**Auto-harvest** *(owner)*: `auto_harvested`, `auto_harvest_coins`, `auto_harvest_xp` - what the
CI Bot collected during this read, so a client can report it. They are 0 unless the CI Bot Legacy
upgrade is owned and something was ready.

**Era:** `era_active`, `era_name`, `era_coins`, `era_harvests`.

### A complete automated farmer

This script plays the core loop on a schedule: it claims the daily bonus and any finished quests,
harvests every ready build, replants empty plots with the most valuable crop it can afford, and
prints a one-line summary. It uses only the Python standard library. Set your URL and API key,
then run it under `cron` or a `systemd` timer, or leave it looping.

```python
#!/usr/bin/env python3
"""Plays the DevPlace Code Farm automatically over the JSON API."""

import json
import time
import urllib.error
import urllib.parse
import urllib.request

BASE = "https://devplace.net"
API_KEY = "YOUR_API_KEY"
POLL_SECONDS = 60


def call(method, path, fields=None):
    headers = {"X-API-KEY": API_KEY, "Accept": "application/json"}
    data = None
    if fields is not None:
        data = urllib.parse.urlencode(fields).encode()
        headers["Content-Type"] = "application/x-www-form-urlencoded"
    request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
    with urllib.request.urlopen(request) as response:
        return json.loads(response.read())


def state():
    return call("GET", "/game/state")["farm"]


def best_affordable_crop(farm):
    options = [c for c in farm["crops"] if not c["locked"] and c["cost"] <= farm["coins"]]
    if not options:
        return None
    return max(options, key=lambda c: c["reward_coins"])


def claim_free_rewards(farm):
    if farm.get("daily_available"):
        call("POST", "/game/daily")
    if farm.get("grant_available"):
        call("POST", "/game/grant")
    for quest in farm.get("quests", []):
        if quest.get("can_claim"):
            call("POST", "/game/quests/claim", {"quest": quest["kind"], "scope": quest["scope"]})


def harvest_and_replant(farm):
    for plot in farm["plots"]:
        if plot["state"] == "ready":
            call("POST", "/game/harvest", {"slot": plot["slot"]})
    farm = state()
    for plot in farm["plots"]:
        if plot["state"] != "empty":
            continue
        crop = best_affordable_crop(farm)
        if not crop:
            break
        call("POST", "/game/plant", {"slot": plot["slot"], "crop": crop["key"]})
        farm = state()
    return farm


def run():
    print("Code Farm automation started.")
    while True:
        try:
            farm = state()
            claim_free_rewards(farm)
            farm = harvest_and_replant(state())
            print(f"coins={farm['coins']} level={farm['level']} harvests={farm['total_harvests']}")
        except urllib.error.HTTPError as error:
            print("api error:", error.code, error.read().decode()[:200])
        except OSError as error:
            print("network error, retrying:", error)
        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    run()
```

From here the natural extensions are easy: spend surplus coins with `POST /game/buy-plot` and
`POST /game/upgrade` when you can afford them, raise perks with `POST /game/perk`, water
neighbours by walking `GET /game/farm/{username}` and posting to its `/water` path for plots where
`can_water` is true, raid where `can_steal` is true, and refactor with `POST /game/prestige` once
`refactor_affordable` is set. Be polite to the rate limiter: mutating calls are limited per
client, so a poll interval of a minute or more with one action per finished build stays well
within limits.

## Ask Devii

You can also play in plain language through the Devii assistant, which calls the same endpoints
for you:

> Check my Code Farm, harvest anything that is ready, and replant with the best crop I can afford.

Devii reads your state, performs the actions, and reports your new coin and level totals.

</div>

<div class="devii-doc-cta">
    <a href="/game" class="sidebar-link">Open Code Farm</a>

---

<a id="doc-quizzes"></a>
# Quizzes

A quiz on DevPlace is user-generated content, exactly like a gist or a project: it has an owner,
a title, a description, comments, votes, bookmarks and reactions. Any signed-in member writes
quizzes, every member plays them, and guests read published ones.

Open the hub at `https://devplace.net/quizzes`. It is one page with three columns: filters on the left, the
quiz list in the middle showing what you still have **to do** and what you already **completed**
with your score, and the cross-quiz **scoreboard** on the right.

## Creating a quiz

There are three ways to make one, and they all end at the same place:

1. **New quiz** on the hub opens a small form (title, description, settings) and creates a draft.
   You then add its questions in the builder.
2. **Create quiz with Devii** opens the assistant with a request already typed in. Devii asks you
   for the topic, the number of questions and the difficulty, builds the whole quiz in one call,
   and shows you the result. It never sends the message for you and it never publishes without
   asking.
3. **The API**, by POSTing one JSON document to `/quizzes/import`. This is the same call Devii
   makes.

## Publishing is permanent

A draft is fully editable. The moment you publish it, the quiz, its questions and its options are
**frozen forever**. There is no unpublish, no post-publish edit and no administrator override.
The only operation left is deleting it.

This is deliberate. It is what makes a score comparable: two members who answered the same
published quiz answered exactly the same questions, so the scoreboard is honest.

Before you press Publish, check:

- Every question has the prompt you meant to write.
- Every single-choice question has exactly one correct option, and every multiple-choice question
  has at least one.
- Every free-text question has a reference answer or grading criteria.
- The time limit and the pass mark are what you want.

The builder shows this as a live checklist and the Publish button stays disabled until the list
is empty, so you cannot freeze something broken by accident.

## The eight question kinds

| Kind | What the player sees | How it is graded |
|------|----------------------|------------------|
| `single_choice` | Radio options | The chosen option is the correct one |
| `multiple_choice` | Checkbox options | Partial credit: correct picks minus wrong picks, never below zero |
| `true_false` | Two large buttons | The chosen boolean matches |
| `free_text` | A textarea | The AI reviewer, against your criteria. Partial credit |
| `fill_blank` | One input per blank | Per blank, whitespace normalized, case sensitivity is your choice |
| `numeric` | A number input | Within the tolerance you set |
| `ordering` | One selector per position | The longest correct run from the start |
| `matching` | A selector per left-hand item | Per pair |

Ordering and matching use plain selectors plus keyboard controls, never a drag-only interaction,
so they work with a keyboard and a screen reader.

## How free-text grading works

A free-text answer goes to the platform's own AI reviewer, together with your reference answer
and your grading criteria. The reviewer returns a score between 0 and 1, a short piece of
feedback for the learner, and a confidence. The score is re-clamped on the server and the
correct/incorrect verdict is derived from the clamped score, so a reviewer cannot mark an answer
correct while scoring it zero. The answer text is treated as data, never as instructions.

The call is billed to the **answering member's** own API key, so it shows up in that member's AI
usage exactly like any other AI feature they use.

When the reviewer is unavailable - it is down, it times out, or it returns something unreadable -
the answer is **still graded**, by a deterministic comparison of the keywords in your reference
answer against the learner's answer. That answer is stamped `graded_by: "fallback"` and both the
player's screen and the JSON say so. Grading never silently becomes a zero.

## Settings

| Setting | What it changes |
|---------|-----------------|
| Shuffle questions | Each attempt gets its own question order, stable for that attempt |
| Shuffle options | The answer options are reordered per attempt |
| Reveal answers | After a question is answered, its correct answer is shown |
| Allow review | The results screen lists every answer with its feedback |
| Time limit | Seconds from the moment the attempt starts. 0 means no limit |
| Pass mark | Percentage needed for a pass verdict. 0 means no verdict |

The time limit is a deadline stored on the attempt. Nothing runs in the background: when the
deadline has passed, the attempt reads as expired the next time anyone looks at it and refuses
further answers.

## Playing

Starting a quiz creates an attempt. You get **one** in-progress attempt per quiz: pressing Start
again resumes the one you already have, on any device and in any tab, because the attempt lives
on the server and never in your browser.

Each question can be answered exactly once. Submitting the same question twice is refused and
credits nothing. When you finish, the score is recomputed from your recorded answers, so it is
always exactly the sum of what you earned.

## The scoreboard

The rail on the hub ranks members across every published quiz. Two rules make it honest:

- **Your best attempt per quiz counts, never the sum of your attempts.** Replaying a quiz can
  raise your contribution up to your personal best and never beyond it, so grinding one easy quiz
  is worth nothing.
- **Quizzes you wrote yourself count too.** Playing your own quiz scores like any other, and
  your **Your progress** card shows exactly the same figures the board ranks you by.

Ties are broken by the number of quizzes completed and then deterministically, so the board never
reshuffles between two page loads.

## Driving a quiz from a script

Everything above is available over the JSON API. This client imports a quiz, publishes it, plays
it end to end and prints the result, using only the standard library.

```python
import json
import urllib.request

BASE = "https://devplace.net"
API_KEY = "YOUR_API_KEY"


def call(method, path, fields=None):
    data = urllib.parse.urlencode(fields, doseq=True).encode() if fields else None
    request = urllib.request.Request(f"{BASE}{path}", data=data, method=method)
    request.add_header("Authorization", f"Bearer {API_KEY}")
    request.add_header("Accept", "application/json")
    if data:
        request.add_header("Content-Type", "application/x-www-form-urlencoded")
    with urllib.request.urlopen(request) as response:
        return json.loads(response.read())


document = {
    "title": "SQLite fundamentals",
    "description": "Three questions on WAL and indexing.",
    "settings": {"reveal_answers": True, "pass_percent": 60},
    "questions": [
        {
            "kind": "single_choice",
            "prompt": "Which journal mode allows concurrent readers and one writer?",
            "points": 1,
            "options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
        },
        {
            "kind": "true_false",
            "prompt": "A partial index can carry a WHERE clause.",
            "points": 1,
            "correct_boolean": True,
        },
        {
            "kind": "numeric",
            "prompt": "How many bytes are in a kibibyte?",
            "points": 1,
            "numeric_value": 1024,
        },
    ],
}

created = call("POST", "/quizzes/import", {"document": json.dumps(document)})
slug = created["data"]["slug"]

call("POST", f"/quizzes/{slug}/publish", {"confirm": "true"})

started = call("POST", f"/quizzes/{slug}/attempts")
attempt_uid = started["data"]["uid"]
attempt = call("GET", f"/quizzes/{slug}/attempts/{attempt_uid}")

for question in attempt["attempt"]["questions"]:
    fields = {"question_uid": question["uid"]}
    if question["kind"] == "single_choice":
        fields["option_uids"] = question["options"][1]["uid"]
    elif question["kind"] == "true_false":
        fields["answer_text"] = "true"
    else:
        fields["answer_text"] = "1024"
    call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/answer", fields)

result = call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/finish")
print(result["attempt"]["score_percent"], "percent")
```

The complete request and response reference is the
[Quizzes API group](#doc-quizzes).

---

<a id="doc-block-and-mute"></a>
# Block and mute

Every profile that is not your own carries a **Block** and a **Mute** button next to Follow. Both act
only on your own account, are reversible, and are never disclosed to the other person. Each click asks
for confirmation before it takes effect.

## Block

Blocking someone removes them from your experience entirely. Once you block a user:

- Their posts disappear from your feed, the landing page, and the projects and gists listings.
- Their comments are hidden from you on every post, project, gist and news item.
- Opening a direct link to one of their posts, projects or gists returns "not found".
- Their conversations vanish from your messages, and they can no longer send you a direct message.
- The issue list hides issues they reported.
- They can no longer create notifications for you.

Blocking is **one-directional**: it changes only what you see. The other person is not told and their
own view of the site is unchanged.

### The one exception: their profile

The blocked user's **own profile page stays fully visible to you**, including all of their content.
This is deliberate, so you can always review who you blocked and lift the block. Use the **Unblock**
button there to restore them everywhere at once.

## Mute

Muting is the lighter option. A muted user can no longer create notifications for you - no follow,
mention, reaction, comment-reply or direct-message alert - but **all of their content stays visible**
to you across the site. Use it when you still want to read someone but no longer want to be pinged by
them. The **Unmute** button reverses it.

## Block versus mute

| | Hides their content | Stops their notifications |
|---|---|---|
| **Mute** | No | Yes |
| **Block** | Yes | Yes |

Block is the stronger action and already includes everything mute does, so there is no need to mute
someone you have blocked.

## Where to manage them

Both are managed from the other user's profile page. There is nothing to configure in settings: the
buttons toggle between Block/Unblock and Mute/Unmute based on the current state, and the change applies
immediately after you confirm.

---

<a id="doc-emoji-shortcodes"></a>
# Emoji shortcodes

Type a colon-wrapped name like `:rocket:` in any text you write and it is replaced with the matching
emoji 🚀 when the content is shown. This is the same `:name:` convention used by GitHub and Discord, so
shortcodes you already know work here too.

## How to use it

Write the shortcode inline with your text:

- `:smile:` becomes 😄
- `:rocket:` becomes 🚀
- `:tada:` becomes 🎉
- `:fire:` becomes 🔥
- `:+1:` becomes 👍 and `:-1:` becomes 👎
- `:smiling_imp:` becomes 😈 and `:imp:` becomes 👿

A shortcode that is not a real name is left exactly as you typed it, so `:not_an_emoji:` stays as plain
text. Names are case-insensitive.

## Where it works

Shortcodes are expanded everywhere your prose is rendered:

- Posts, comments, and post and comment titles
- Project and gist titles and descriptions
- News articles
- Direct messages
- Live content as it appears (new comments, chat replies, message bubbles)

Both the server-rendered pages and the live, in-browser updates use the **same shortcode list**, so what
you see while typing matches what everyone else sees after the page reloads.

## The full set

The list is the complete standard emoji set, the same one GitHub and Discord draw from: several thousand
names covering faces, hands, hearts, objects, food, flags, and symbols. If a name exists in that
standard set, it works here.

If you prefer to pick from a visual grid instead of remembering names, use the **emoji picker** button in
the composer; it inserts the actual emoji directly.

## Code stays untouched

Shortcodes inside code spans and fenced code blocks are never expanded, so technical text such as
`a:rocket:b` written as code keeps its colons. This keeps snippets and configuration examples exact.

---

<a id="doc-devii"></a>
<div class="devii-doc-hero-mark">🤖</div>
    <h1>Devii - your AI teammate on DevPlace</h1>
    <p>Devii builds and runs things for you on this platform, just by chatting. Ask it to write a client,
        ship a complete bot, automate your day, or operate your account - in plain language.</p>
    <div class="devii-doc-hero-actions">
        <a href="/devii/" class="btn btn-primary btn-lg" data-devii-open><span class="icon">🤖</span> Launch Devii</a>
        <span class="devii-doc-hero-note">Available to everyone. Guests get a free sandbox; sign in to let Devii work on your own account.</span>
    </div>
</div>

<div class="docs-content" data-render>
## What Devii does for you

Describe what you want and Devii produces working results - code, automations, and actions on
the platform - then verifies them for you.

- **Generate API clients** in any language, wired to your account.
- **Build complete bots** that post, comment, vote, and reply on this community.
- **Automate your presence** with scheduled tasks that run for you.
- **Operate your account by chat** - post, comment, follow, message, manage gists and projects.
- **Work on your project files** - read, write, and edit a project's filesystem line by line.
- **Call any external API** - integrate web services with full HTTP requests (GET, POST, and more).
- **Attach files from the internet** - hand Devii a URL and it stores it as an attachment on your post or project.
- **Read and send your email** - connect your own mailbox over IMAP/SMTP and have Devii list, search, read, organise, and send mail for you.
- **Customize the site for yourself** - apply your own CSS and JavaScript, scoped to a page or globally.
- **Invent your own tools** - describe a tool in plain language and Devii adds it to its toolset.
- **Guide you live on screen** - highlight elements, show tips, scroll, and walk you through the site.
- **Answer platform questions** and turn the [documentation](#doc-index) into ready-to-run examples.

## Generate a client in any language

Name the part of the platform you want and your language. Devii returns a complete, runnable
client wired to your account with your API key (see [Authentication](#doc-authentication)).

> Write me a Python client for my feed and notifications, with functions to post and to mark
> notifications read.

> Generate a typed TypeScript SDK for posts, comments, and reactions, with examples.

> Give me a single-file Go client that creates a gist and prints its URL.

## Build a complete bot, on the fly

Describe the bot and the language. Devii writes the whole thing - main loop, authentication,
error handling - ready to drop in and run.

> Build a complete Node.js bot that watches for mentions of me and replies with a friendly
> answer, polling every minute.

> Write a Rust bot that posts my latest GitHub release to the feed whenever a new one appears.

> Create a Python bot that reads the news feed each morning, picks the top story, and publishes
> a short summary post - with a daily schedule so it runs by itself.

Ask for any language: Python, TypeScript, Go, Rust, Java, C#, Ruby, PHP, Bash - Devii adapts the
client and the bot to the one you name.

## Automate your day

Devii can schedule work and run it for you on a cadence you choose.

> Every weekday at 09:00, summarize my unread notifications and send me a direct message with
> the highlights.

> Once an hour, check for new comments on my latest post and thank each new commenter.

### Reminders

Ask Devii to remind you and it schedules the reminder instead of answering right away.

> Remind me to go upstairs in 40 seconds.

> Tell me to take a break every day at 15:00.

Reminders are **timezone-aware**: a wall-clock time like "3pm" is read in your own timezone (your
browser tells Devii which one), so you never do the maths. They are **persistent**: a queued
reminder is stored and runs on the server, so it still fires after a server restart and even when
the Devii window is closed. When a reminder fires you get an in-app notification and a live toast
with its message, on top of seeing it in the terminal. You control the **Reminders** notification
like any other on your profile. Ask Devii to list, change, run now, or delete your reminders at any
time. Reminders that survive restarts and notify you require being signed in; a guest session can
still set a reminder, but only while that session stays open.

## Operate your account by conversation

Anything you can do on the site, you can ask Devii to do - and it confirms the result.

> Post a gist titled "Quicksort in Rust" with the code below, then follow the three most active
> authors on the leaderboard.

> Find the post about WebSockets, leave a thoughtful comment, and upvote it.

## Pull in files and call external services

Devii can reach the internet for you and bring the results back onto the platform.

> Attach this image to my project: https://example.com/diagram.png

> Create a post titled "Release banner" with this picture attached:
> https://example.com/banner.jpg

> Call the JSON API at https://api.example.com/v1/generate with a POST, send my prompt as the
> body, and show me what it returns.

## A guide that points at the screen

Devii can see the page you are on and guide you through it live - highlighting buttons, leaving
short tips, scrolling to the right place, and moving you between pages.

> Show me how to create a gist. Walk me through it step by step on screen.

> Take me to my notifications and point out how to mark them all read.

## Chat from Telegram

Prefer your phone? Connect your Telegram account and talk to the same Devii from the DevPlace
bot, with markdown replies, a typing indicator, and image understanding. See
[Devii on Telegram](#doc-telegram) to pair in under a minute.

## Your built-in docs helper

Not sure how an endpoint works? Ask in the terminal on any documentation page.

> How do I paginate the feed, and what does the next cursor look like? Show me a working example.

## Try it now

Open the terminal and type anything - Devii plans, acts, verifies, and shows its work as it
goes. Signed-in users get a persistent session that follows them across tabs and devices; guests
get an instant sandbox.
</div>

<div class="devii-doc-cta">
    <a href="/devii/" class="btn btn-primary btn-lg" data-devii-open><span class="icon">🤖</span> Launch Devii</a>

---

<a id="doc-telegram"></a>
# Devii on Telegram

Chat with your own Devii assistant from Telegram. Once you connect your Telegram account, the
DevPlace bot talks to the same Devii that runs in the web terminal: it operates your DevPlace
account through your API key, answers in markdown, shows a typing indicator while it works,
updates a single message as it thinks instead of flooding the chat, and can read images you send.

## Connect your account

1. Open your [profile](/profile) and find the **Telegram** card in the sidebar.
2. Click **Request pairing code**. A four digit code appears. It is valid for a limited time
   (one hour by default).
3. Open the DevPlace bot in Telegram and send it the four digit code.
4. The bot replies confirming the connection. You can now chat with Devii directly.

The code is single use. If it expires or you lose it, request a fresh one from your profile. To
disconnect, click **Disconnect** on the same card, which unlinks the Telegram chat from your
account.

## What you can do

- Ask Devii anything you would ask in the web terminal: it can read your feed, manage posts,
  projects, gists and files, search the documentation, and more, all on your own account.
- Send a photo and Devii can see it and respond to it.
- Long answers are split automatically so they always fit Telegram's message limits.
- Markdown in Devii's replies is rendered with Telegram formatting (bold, italics, code,
  links and code blocks).

## Privacy and limits

Your Telegram conversation is a separate thread from the web terminal, but it shares the same
Devii memory, tools, and your rolling 24 hour AI usage limit. Only private chats are supported,
and the bot only ever acts for the account that paired with it. Disconnecting removes the link
immediately.

If the bot does not respond, the Telegram service may be turned off for this deployment. The
service is managed by administrators and is not enabled by default.

---

<a id="doc-media-gallery"></a>
# Media gallery

Every image, video, and file you share on DevPlace is collected in one place: the **Media** tab on
your profile. Whatever you attach to a post, project, gist, comment, direct message, issue report, or
news item shows up here automatically, newest first.

## Where to find it

Open any profile and choose the **Media** tab, or go straight to
`/profile/YOUR_USERNAME?tab=media`. The gallery is public: like the Posts, Projects, and Gists
tabs, anyone can browse a member's media.

## Viewing media

The gallery is a responsive grid of thumbnails, from a wide multi-column layout on the
desktop down to a single column on a phone.

- **Images** show as thumbnails. Click one to open it full size in the lightbox; click the
  backdrop, press `Escape`, or use the close button to dismiss it.
- **Videos** play inline with standard controls.
- **Other files** (PDFs, archives, code, audio) appear as a labelled card showing the file name and
  size, and download when clicked.

Each tile links back to the post, project, or gist the media belongs to. Long galleries are split
into numbered pages.

## Deleting your own media

Hover (or tap) a tile you uploaded and a delete button appears. Confirm, and the item is removed
from your Media tab and from anywhere it was shown, including the original post or project. You can
only delete media you uploaded yourself.

You can also ask the Devii assistant to do it, for example:

> Delete the screenshot I attached to my last post.

Devii shows you exactly which file it will remove and asks you to confirm before deleting.
</div>

<div class="devii-doc-cta">

---

<a id="doc-notification-settings"></a>
# Notification settings

DevPlace notifies you when something involves you: a comment on your post, a reply to your comment, a
mention, an upvote on your work, a new follower, a direct message, a badge, a level-up, or an update
on an issue you filed, or someone gives you an award on your profile. You decide which reach you, and how.

Each notification type is delivered on three independent channels:

- **In-app** - the notification appears on DevPlace (the bell in the top navigation and the
  `/notifications` page). While you have DevPlace open, an enabled in-app notification also raises a
  live, click-through toast in real time, and the unread bell and message badges update instantly,
  so you see it without reloading.
- **Push** - a native web push notification is sent to the devices where you enabled push.
- **Telegram** - the notification is delivered to your paired Telegram chat. This channel works only
  once you have connected Telegram, and it is **off by default** for every type.

The channels are independent: you can keep a type in-app but silence its push (or the reverse), and
enable Telegram for only the types you care about. The live toast rides the in-app channel: silence a
type's in-app box and it neither lands in your bell nor pops a toast.

## Where to find it

Open your profile and choose the **Notifications** tab, or go straight to
`/profile/YOUR_USERNAME?tab=notifications`. The tab is private: only you (and administrators) can see or
change your settings.

Each notification type is one row with three switches, **In-app**, **Push**, and **Telegram**. The
Telegram switch stays disabled until you connect Telegram. Ticking or unticking a switch saves
immediately - there is no separate save button.

## What each type covers

| Type | Fires when |
|------|------------|
| Comments | someone comments on your post |
| Replies | someone replies to your comment |
| Mentions | someone mentions you with `@username` |
| Upvotes | someone `++`'d your post, comment, project, or gist |
| Followers | someone starts following you |
| Direct messages | someone sends you a message |
| Badges | you earn a badge |
| Level-ups | you reach a new level |
| Issue tracker | there is an update on an issue report you filed |
| Reminders | a reminder or scheduled task you asked Devii to run fires |
| Farm raids | someone steals a ready build from your Code Farm |
| Awards | someone gives you an award on your profile |
| Quiz attempts | someone completes one of your quizzes |

## Defaults

In-app and push are **on** for every type until you turn them off, so notifications work out of the
box; Telegram is **off** by default and delivers only once you connect it. A type you have never
changed follows the platform default; once you change a switch, your choice is remembered and no
longer follows later changes to the default.

Use **Reset to defaults** at the bottom of the tab to clear all of your choices and return to the
platform defaults.

## Turning push on

Toggling **Push** for a type only takes effect once you have enabled push notifications on the device
with the bell-with-slash button in the top navigation or on the `/notifications` page. Until then,
push has nowhere to be delivered. Likewise, the **Telegram** switch only delivers once you have paired
Telegram - see [Devii on Telegram](#doc-telegram) to connect your account.

## Ask Devii

You can change these settings in plain language through the Devii assistant:

> Turn off push notifications for upvotes.

> Stop notifying me about new followers entirely.

Devii reads your current settings, makes the change, and can reset everything to the defaults (it asks
you to confirm a reset first, since that clears all of your choices).
</div>

<div class="devii-doc-cta">

---

<a id="doc-timezones"></a>
# Timezone-aware dates

Every date and time shown on DevPlace is displayed in **your own timezone**, automatically. You do not set a timezone anywhere: the page works it out from your browser and converts every timestamp to your local time. Two people in different parts of the world looking at the same post see the same moment, each in their own clock.

## What you see

- **Relative times** such as `5m ago`, `3h ago`, or `2d ago` are shown for anything recent and update on their own while the page is open.
- After about a month, a relative time switches to an absolute **DD/MM/YYYY** date, shown in your local timezone.
- Hover over any time to see the **full local date and time** in a tooltip.

Because everything is anchored to a single universal instant, the conversion is exact: a message sent at 14:30 UTC shows as 15:30 for a reader in Central Europe and 09:30 for a reader on the US East Coast, with no configuration on either side.

## How it works

DevPlace stores and sends every timestamp as **UTC** (Coordinated Universal Time) in the standard ISO 8601 format. The server never guesses your timezone. Instead, each timestamp is delivered to your browser as a machine-readable value, and a small piece of JavaScript reformats it to your local timezone using the browser's built-in internationalization support. The browser already knows its own timezone, so this needs no cookie, no header, and no account setting.

If JavaScript is disabled, you still see a sensible fallback date (the server-rendered text inside the element), so the page is never blank. With JavaScript on, that text is replaced by your local time the moment the page loads, and any times added later (a new message arriving, more posts loading as you scroll) are localized automatically.

## For contributors

The mechanism is two Jinja globals plus one frontend module.

**Server side.** Use `local_dt(iso, mode)` or the relative-time shortcut `dt_ago(iso)` in templates. They emit a `<time>` element carrying the normalized UTC value and a server-rendered fallback:

```
<time datetime="2026-06-16T14:30:00+00:00" data-dt data-dt-mode="ago">3h ago</time>
```

`mode` is one of `date` (DD/MM/YYYY), `datetime` (DD/MM/YYYY HH:MM), or `ago` (relative). `local_dt` normalizes a naive timestamp to UTC, returns an empty string for empty input, and returns the raw value unchanged if it cannot be parsed.

**Client side.** `static/js/LocalTime.js` (reachable as `app.localTime`) finds every element marked `data-dt`, reformats its text to the browser's local timezone, and sets a full-datetime tooltip. A `MutationObserver` localizes any date inserted after load, and relative times refresh every 60 seconds. Frontend code that builds dates itself (for example the live message bubbles) creates the same `data-dt` element and the observer handles the rest.

When converting an existing display that used a precomputed relative string, keep a fallback so a missing field never blanks the date:

```
{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}
```

**What stays as plain text.** The `format_date()` and `time_ago()` helpers remain plain-text functions for JSON API responses, the no-JavaScript fallback, and the few dates that must not be localized: user-entered calendar dates such as a project's release or demo date (which are plain DD/MM/YYYY values, not a specific instant), and any date placed inside an HTML attribute. Do not wrap those in `local_dt`.

---

<a id="doc-presence"></a>
# Online presence

DevPlace shows whether a user is **online** right now. You see it on every profile page and at the top of a direct-message conversation, as a small status dot with a short label: `online`, `last seen 5m ago`, or `offline`.

## What you see

- Every user **avatar** carries a small dot in its bottom-right corner: **green** when the person is online, **muted grey** when they are not, so presence is visible everywhere an avatar appears (the feed, comments, the navbar, message lists, and more). Hover the dot for the exact status.
- On a **profile** and at the top of a **conversation** you also get the word `online`, or **`last seen ...`** with a relative time, or `offline` if the person has not been seen since the feature started tracking them.
- The indicator **updates on its own** while you have the page open: if someone comes online while you are looking at their profile, the dot turns green within a few seconds, and it fades back to grey shortly after they go idle. You never need to refresh. Live updating needs you to be signed in; a signed-out visitor sees the status as it was when the page loaded.
- The **feed** shows an **Online now** panel at the bottom of the left sidebar: the avatars of everyone currently online, ordered alphabetically so they keep a stable spot, with a live count. People appear and disappear from it in real time as they come and go, again with no refresh.

"Active" simply means loading any page on the site. There is nothing to switch on and no busy or away status to set; presence is automatic.

## How long you stay "online"

You count as online for a short window after your last activity. The default window is **60 seconds**, so a moment after you stop browsing you quietly drop to `last seen ...`. Because the window is deliberately short, the status is honest: a green dot means the person really is here right now, not that they logged in hours ago. Operators can change the window, so on some deployments it may be longer or shorter.

To keep the indicator steady rather than jittery, the status is **quick to turn on and slow to turn off**: you show as online the moment you are active, but a brief pause does not immediately drop you - there is a short grace margin before the dot goes grey. This stops the flicker you would otherwise see if your activity landed right on the edge of the window, and it applies to both the avatar dots and the Online now list, so they always agree.

## Privacy

Online status is public, the same way your posts and profile are public. It reflects only activity on DevPlace and never your location, your device, or anything you do elsewhere. The only thing recorded is the time you were last active, and it is overwritten in place each time, never kept as a history.

## For contributors

Presence is one timestamp plus a lightweight push, built to touch the database as little as possible.

**The data.** Each user row carries a single `last_seen` column (UTC ISO). There is no separate table and no per-request insert, so the feature adds no data growth. A user is online when `now - last_seen` is under `config.PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60).

**Writing it.** The `track_presence` middleware in `main.py` resolves the current user on each non-asset request and calls `services/presence.py` `touch(uid)`. `touch` keeps a per-worker in-memory throttle and writes `last_seen` at most once per `PRESENCE_WRITE_SECONDS` (half the window) per user, as an in-place `UPDATE`. Continuous browsing is a dictionary lookup, not a write. This is the only cross-worker-correct approach here: pub/sub is in-process, so SQLite is the shared medium.

**Reading it.** `presence.is_online(user_row)` is exposed as the Jinja global `is_online(user)` and rendered from the user row a page already loaded, so no extra query runs. The value is also on `UserOut.last_seen` and `ProfileOut.profile_online` for JSON clients.

**Live updates (one set, one topic, change-only, hysteresis).** `PresenceRelayService` (`services/presence_relay.py`) runs on the service-lock owner and recomputes ONE online set each tick from ONE indexed query over the online population (`presence.online_candidates()`, capped at `config.PRESENCE_TRACK_LIMIT`, env `DEVPLACE_PRESENCE_TRACK_LIMIT`, default 500). Membership uses hysteresis (`presence.stays_online`): a user goes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after an extra `PRESENCE_ONLINE_MARGIN_SECONDS` grace (`DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - quick on, slow off - which is what prevents boundary flicker. That single set is published on the single shared topic `public.presence.roster` as `{count, online: [uid, ...], users: [...]}`, **only when the set of online users changes** (a `frozenset` compare, so reordering never republishes) and never on a fixed interval, so an idle site costs zero messages. `online` is the authority for every avatar dot anywhere on the page; `users` is the same set trimmed to `PRESENCE_ONLINE_LIMIT` for the feed's avatar panel. There are no per-user presence topics: one set, one topic, one frame, so no two indicators can drift apart. `services/presence.py` `is_online(user)` is literally `stays_online(seconds_since(last_seen), was_online=False)`, so the server-rendered initial state and the live set apply the same rule.

**The frontend.** `static/js/PresenceManager.js` (`app.presence`) makes ONE subscription to `public.presence.roster` and keeps the pushed `online` uid set. Every element carrying `data-presence-uid` - anywhere on any page, discovered on load and through a `MutationObserver` for markup inserted later - resolves its state as membership of that one set, toggling the `online` class and, for `data-presence-label` elements, the "online / last seen X / offline" text (the relative time is a `<time data-dt data-dt-mode="ago">` formatted by the shared `LocalTime`, so presence never formats a date itself). There is **no client-side clock and no expiry timer**: the relay is the only thing that decides who is online, and before the first frame arrives the server-rendered state simply stands. `static/js/OnlineUsers.js` (`app.onlineUsers`) renders the feed's avatar panel from `users` in that same frame, so the panel and the dots are two views of one payload. `services/presence.py` `online_users()` renders the initial panel server-side with the same indexed `last_seen` query (`database.get_online_users`; `idx_users_last_seen`), ordered **alphabetically by username** so an avatar keeps a stable position instead of jumping around as activity ticks.

**The avatar dot.** The corner dot is one reusable partial, `templates/_presence_dot.html`, which emits a `<span class="presence-dot" data-presence-uid=... data-presence-last-seen=...>` (guarded on `uid`, so an author with no resolvable user renders no dot). It is included by the shared avatar partial `templates/_avatar_link.html` (which covers most avatars) and by the raw-avatar sites wrapped in a positioned `.avatar-badge` span, including the feed's Online now panel - a roster avatar is not a special case, it is the same subscribed dot as everywhere else. In JavaScript the matching builder is `Avatar.badgeElement(user)` (`static/js/Avatar.js`), used by `OnlineUsers` and by the chat's conversation list, so dot markup exists in exactly two places: the partial and that helper. Because the dot carries `data-presence-uid` but no `data-presence-label`, `PresenceManager` drives its colour with **no extra JavaScript**. The dot sizes itself as a percentage of the avatar (clamped 8-14px), so it stays proportional at every avatar size. Reuse `is_online`, `_presence_dot.html`/`Avatar.badgeElement`, the `public.presence.roster` topic, and `PresenceManager` for any new online indicator rather than re-implementing presence.

---

<a id="doc-awards"></a>
# Profile awards

Members can give one award per cooldown window to another member on their profile. The giver writes a short message (up to 125 characters); the server enqueues durable image generation and returns immediately. When the job finishes, the receiver gets a notification and the award appears on their profile gallery, a prominent banner, and a small badge on their avatar for a configurable display window.

Guests can browse awards read-only on profiles. Muting a user suppresses award notifications only; it does not block giving.

## Giving an award

On another member's profile, use **Give Award** when you are not on cooldown. Submit a short message in the modal. The giver's API key pays for gateway image generation. You cannot award yourself, and block rules match direct messages.

## Viewing awards

- **Awards tab** on a profile lists published awards newest first (first tab when the user has at least one award).
- **Prominent banner** shows the latest award for a limited time after publish.
- **Avatar badge** appears bottom-left on avatars while the latest award is prominent.
- Images are served at `/awards/{slug}/{size}` with sizes `512`, `256`, or `64`.

## Admin revoke

Administrators can revoke a published award from the gallery or prominent banner. Revocation soft-deletes the award and attachments and recomputes receiver stats. Restored rows from **Admin - Trash** bring the award back.

## API

See [Profiles & Social Graph](#doc-profiles) for `POST /profile/{username}/award` and the awards tab on `GET /profile/{username}?tab=awards`. Image redirects are documented on the same page. Admin revoke is under [Admin API](/docs/admin.html).

---

<a id="doc-ai-correction"></a>
# AI content correction

AI content correction automatically rewrites the prose you author so it reads the way you want, using
an instruction you choose. It is **opt-in** and **off by default**. When you turn it on, your post,
comment, project, gist, message, or bio is corrected according to your instruction whenever you create
or edit it.

## What it does

When enabled, the following prose fields are corrected:

| Where | Fields corrected |
|-------|------------------|
| Posts | title and body |
| Projects | title and description |
| Gists | title and description |
| Comments | body |
| Direct messages | body |
| Your profile | bio |

Code and source files are **never** touched: a gist's source code, project files, and any code block
are left exactly as written. Correction is for prose only.

In direct messages the correction is delivered live: the corrected message appears in the chat for both
participants without a reload.

## How it works

Each correction is a single call to the platform AI gateway, authenticated with **your own API key**,
so the usage is attributed to you. The correction is fail-soft: if the AI call fails or returns
something unexpected, your original text is kept unchanged. The rewrite preserves your meaning,
language, line breaks, and markdown.

The correction applies everywhere you create or edit prose: the web interface, the REST and devRant
APIs, and the Devii assistant. New content and edits are both corrected.

## Background or synchronous

You choose **when** the correction is applied with the **Apply mode** setting:

| Mode | Behaviour |
|------|-----------|
| In background (default) | Your content is saved instantly exactly as written, then corrected a moment later. Nothing you do is slowed down; the corrected text appears the next time the content is loaded. |
| Synchronously | The save waits for the correction to finish, so the result is already corrected when the page reloads. The correction is usually fast, so this is a reasonable choice if you prefer to see the corrected text immediately. |

Both modes use your own API key and are fail-soft. The only difference is whether saving waits for the
rewrite or lets it happen just afterwards.

## Usage totals

Each successful correction is metered, and your running totals are shown on your profile: how many
corrections were made and how many tokens they used. Beyond those counts and tokens, the profile also
shows performance figures: average latency, average speed (tokens per second), and total processing
time. The dollar figures - total cost and average cost per call - are visible to administrators only;
you see the corrections, token counts, and performance figures. Totals only increase when a correction
call actually succeeds.

## Turning it on

Open your profile at `/profile/YOUR_USERNAME` and find the **AI content correction** card. Tick
**Enable correction**, choose an **Apply mode** (background or synchronous), adjust the correction
instruction if you like, and press **Save**. The default instruction is:

> Leave literary as is, only do punctuation and casing

Replace it with any instruction up to 20000 characters, for example "fix spelling and grammar but keep
my tone" or "translate to formal English". The setting is private: only you (and administrators) can
see or change it.

## Ask Devii

You can configure it in plain language through the Devii assistant:

> Enable AI correction and only fix my punctuation.

> Apply my corrections synchronously.

> Turn AI content correction off.

Devii reads your current setting, mode, and prompt, then changes them for you.

---

<a id="doc-ai-modifier"></a>
# AI modifier

The AI modifier lets you embed an instruction directly inside the prose you write and have the
platform execute it in place. It works like AI content correction, except it runs **only** where your
text contains an inline `@ai <instruction>` directive. When that marker is present, the configured
prompt tells the model to carry out the instruction behind `@ai` and replace that marked part,
removing the `@ai` marker.

It is **enabled by default**, and by default it applies **synchronously** (the save waits for the
result). You can turn it off or switch to background mode on your profile.

## What it does

When you write something like:

> Here are the release notes. @ai summarize this in one sentence

the modifier executes that instruction and replaces the marked part, so the stored text becomes the
one-sentence summary with the `@ai` marker gone. Text with no `@ai ...` directive is left exactly as
written, so the modifier is invisible until you ask for it.

The following prose fields are processed:

| Where | Fields processed |
|-------|------------------|
| Posts | title and body |
| Projects | title and description |
| Gists | title and description |
| Comments | body |
| Direct messages | body |
| Your profile | bio |

Code and source files are **never** touched: a gist's source code, project files, and any code block
are left exactly as written. The modifier is for prose only.

In direct messages the modifier runs live: typing `@ai <instruction>` in a message executes it and the
resolved result appears in the chat for both participants without a reload.

## How it works

Each modification is a single call to the platform AI gateway, authenticated with **your own API
key**, so the usage is attributed to you. It is fail-soft: if the AI call fails or returns something
unexpected, your original text is kept unchanged. It applies everywhere you create or edit prose: the
web interface, the REST and devRant APIs, and the Devii assistant. New content and edits are both
processed.

## What the model knows (context)

So that an instruction like "answer the question above" or "reply to this" actually works, the modifier
gives the model a short, read-only **context block** describing who is asking and where the directive
sits. It always includes:

- **The date** and that you are on the DevPlace developer network.
- **You**: your username, role, level, stars, post count, leaderboard rank, follower count, member-since
  date, and your bio.
- **Where the directive is**, depending on what you are writing:
  - In a **comment**: the post, project, gist, or news item it is on (title and an excerpt), and the
    comment you are replying to.
  - In a **post**: its topic and any project it is attached to.
  - In a **project or gist**: the title and description, and for a gist its language and source code (as
    reference only - your code is never rewritten).
  - In a **direct message**: who you are messaging and the recent conversation, so a reply can follow
    the thread.

This lets you write `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to
this`. The context is summarised and length-limited, and the extra context tokens count toward your
usage totals like any other tokens. The model is told to use the context but never to repeat it back.

## Synchronous or background

You choose **when** the modification is applied with the **Apply mode** setting:

| Mode | Behaviour |
|------|-----------|
| Synchronously (default) | The save waits for the modification to finish, so the result is already applied when the page reloads. |
| In background | Your content is saved instantly exactly as written, then modified a moment later; the result appears the next time the content is loaded. Nothing you do is slowed down. |

Both modes use your own API key and are fail-soft. The only difference is whether saving waits for the
rewrite or lets it happen just afterwards.

## Usage totals

Each successful modification is metered, and your running totals are shown on your profile: how many
modifications were made and how many tokens they used. Beyond those counts and tokens, the profile also
shows performance figures: average latency, average speed (tokens per second), and total processing
time. The dollar figures - total cost and average cost per call - are visible to administrators only;
you see the modification and token counts and the performance figures. Totals only increase when a
modification call actually succeeds.

## Configuring it

Open your profile at `/profile/YOUR_USERNAME` and find the **AI modifier** card. Toggle **Enable
modifier**, choose an **Apply mode** (synchronous or background), adjust the instruction prompt if you
like, and press **Save**. The default prompt is:

> Execute what is behind `@ai` (the prompt) and replace that part including `@ai`

Replace it with any instruction up to 20000 characters. The setting is private: only you (and
administrators) can see or change it.

## Ask Devii

You can configure it in plain language through the Devii assistant:

> Turn the AI modifier off.

> Apply my AI modifier changes in the background.

> Enable the AI modifier.

Devii reads your current setting, mode, and prompt, then changes them for you.

---

<a id="doc-terms"></a>
# Terms of Service

**Version 1.** These terms govern your use of DevPlace through the web
application and through any client that speaks to its API. By creating an account you accept them.
When the version above changes, you are asked to accept the new version before you can create or
change anything; you can always keep reading and can always delete your account.

## 1. Who may use DevPlace

You must be at least 16 years old to hold an account. You declare your
date of birth at signup. DevPlace stores only the age band derived from it and discards the date
itself.

One person may hold one account. You are responsible for everything done with your credentials.

## 2. Zero tolerance for objectionable content and abusive behaviour

**DevPlace does not tolerate objectionable content or abusive users.** Content in any of the
categories listed in the [Community Guidelines](#doc-community-guidelines) is prohibited
anywhere on the platform: posts, comments, gists, project descriptions, project files, quizzes,
polls, awards, uploaded media, direct messages, profile fields, issue reports, assistant output, and
anything a container workspace serves through the public ingress.

Prohibited content is removed. Accounts that post it are suspended or closed. This applies whether
the content was written by a person, generated by an assistant, or served by software you deployed.

## 3. How the rules are enforced

Three mechanisms enforce section 2, and all three are described in full on the
[How moderation works](#doc-content-moderation) page:

1. **Automated filtering** at the moment content is created or edited.
2. **A report control on every surface**, available to every signed-in member.
3. **A moderation queue** worked by administrators, with a published response window of
   24 hours.

You can also **block** any account. Blocking hides that person's content from you everywhere except
their own profile, and stops them contacting you.

## 4. Your content

You keep ownership of everything you write, upload or publish. You grant DevPlace the licence it
needs to store your content, show it to the people you published it to, transmit it, and make the
backups and copies that operating the service requires. That licence ends when the content is
deleted, except for copies retained in backups until they rotate out and for records required for
legal or moderation purposes.

You are responsible for having the rights to everything you publish. See
[Notice and takedown](#doc-intellectual-property) if your rights have been infringed.

## 5. Acceptable use

You must not:

- publish content in a prohibited category;
- harass, threaten, impersonate or dox any person;
- attack, overload or attempt to gain unauthorised access to DevPlace or any other system;
- use container workspaces or the AI gateway to attack third parties, mine cryptocurrency, send bulk
  unsolicited mail, or host prohibited content;
- scrape or automate in a way that degrades the service for others;
- evade a suspension or ban.

## 6. Code execution

DevPlace runs user code in **containers on DevPlace servers**. Nothing you write here is downloaded
to or executed on your device by the platform, and no DevPlace client changes its own behaviour by
downloading code. You are fully responsible for the code you run and for anything it serves publicly.

## 7. Artificial intelligence

Assistant features send content to a third-party model provider. Nothing you author is sent for
processing without your explicit consent, which you grant and withdraw at any time from your profile
privacy tab. See the [Privacy Policy](#doc-privacy).

Assistant output is generated text. It can be wrong. It is your responsibility to check it, and it is
subject to the same rules as anything you write yourself.

## 8. Rankings, awards and seasons

DevPlace ranks contributors, runs seasonal Code Farm Eras, and lets members give each other
awards. All of it is **status and cosmetic only**. Nothing of monetary value is offered,
awarded or exchanged; no entry fee is charged; nothing can be purchased that affects a ranking,
an award or an outcome; and no in-platform currency can be bought or cashed out. None of it is a
contest, sweepstake or lottery, and no app store, device maker or platform operator sponsors,
endorses, administers or is otherwise involved in any of it.

## 9. Suspension, closure and appeal

DevPlace may remove content and suspend or close an account that breaks these terms. When that
happens you are told what was decided and why. Reply to the notice, or use the contact details on the
[Contact](#doc-contact) page, to dispute a decision.

## 10. Deleting your account

You can delete your account yourself, at any time, from your profile. Your account and personal data
are removed. The process, the short reversible window, and what is retained are described on the
[Privacy Policy](#doc-privacy) page.

## 11. Availability and liability

DevPlace is provided as it is, without warranty. It may change or be unavailable. To the extent the
law allows, DevPlace is not liable for indirect or consequential loss. Nothing here limits liability
that cannot lawfully be limited.

## 12. Changes to these terms

Material changes bump the version at the top of this page. You are asked to accept the new version
before your next write. Continuing to read and deleting your account never require acceptance.

## 13. Contact

Postal address, email and telephone number are published on the
[Contact](#doc-contact) page.

---

<a id="doc-community-guidelines"></a>
# Community Guidelines

**Version 1.** DevPlace is a network for developers. It does not
editorialise technical opinion: you may be blunt, contrarian, or wrong about a language, a framework
or an architecture, and nobody will remove you for it. What it does enforce is a short, fixed list of
prohibited categories. Everything below is prohibited everywhere on the platform.

Each heading is a reason key you can select when you report something. The same list, exactly as
every client renders it, is published at [report reasons](/reports/reasons).

## `hate` - Hate speech and discrimination

Content that attacks, dehumanises or advocates exclusion of a person or group on the basis of race,
ethnicity, national origin, religion, disability, disease, age, sex, gender identity or sexual
orientation.

## `violence` - Realistic violence and threats

Threats of violence against any person, glorification of violence, and realistic depictions of
violence or gore. Fictional violence in a game project is allowed; a threat aimed at a real person is
not, in any form, including as a joke.

## `weapons` - Weapons and dangerous instructions

Instructions for building weapons, explosives, or other devices whose purpose is to injure people.
Security research, exploit write-ups and vulnerability discussion are explicitly **allowed** - that is
the work of this community - provided they are not operational instructions for harming people.

## `sexual` - Sexual and pornographic content

Pornography and sexually explicit material. Any sexual content involving minors is removed
immediately, the account is closed, and it is reported to the authorities.

## `religious` - Content targeting religion or belief

Content that attacks or demeans a religion or its adherents. Discussing, criticising or disagreeing
with ideas is allowed; attacking people for holding them is not.

## `misinformation` - False and misleading information

Fabricated claims presented as fact where they can cause real harm, especially about health, safety
or elections. Being wrong in a technical argument is not misinformation.

## `exploitative` - Exploitation of a person

Content that exploits, endangers or sexualises a person, including any depiction of a minor in a
sexual context, and content that targets a person because of their vulnerability.

## `harassment` - Harassment and bullying

Sustained targeting of a person, pile-ons, doxing, unwanted contact after being asked to stop, and
publishing private information about someone without their consent.

## `spam` - Spam and unwanted promotion

Bulk or repetitive posting, unsolicited advertising, link farms, engagement manipulation, and content
whose only purpose is to drive traffic elsewhere.

## `intellectual_property` - Copyright and trademark infringement

Publishing content you have no right to publish. See
[Notice and takedown](#doc-intellectual-property) for how to file and how to counter-notice.

## `self_harm` - Self-harm and suicide

Content that encourages, instructs or glorifies self-harm, suicide or eating disorders. If you are
struggling, please reach out to a local crisis line - DevPlace is not a support service.

## `illegal` - Illegal activity

Content that facilitates crime: trade in controlled substances, stolen credentials or data, fraud
kits, malware distribution to third parties, or evasion of sanctions.

## `other` - Something else

Anything that is clearly wrong but does not fit a category above. Describe it in the detail field; a
moderator reads every report.

## What is explicitly allowed

To be unambiguous, because this is a developer platform and a naive reading of the list above would
break it:

- security research, exploit development, malware analysis and reverse engineering;
- profanity, strong opinions, and harsh technical criticism of code, tools and ideas;
- discussion of weapons, violence or crime as subject matter in research, journalism, fiction or a
  game project;
- error messages, stack traces and payloads that happen to contain offensive strings.

The line is intent and target. Analysis is fine. Aiming it at a person is not.

## Reporting

Every surface that can carry user content carries a **Report** control. Select the reason, add
detail, and submit. Reports are private: the person you reported is never told who reported them.
Every report is acknowledged and reviewed within 24 hours. Track yours at
`/reports/mine`.

## Enforcement

Depending on severity and history a moderator may remove the content, issue a warning, suspend the
account for a period, or close it permanently. You are always told what was decided and why, and you
can reply to dispute it. See [How moderation works](#doc-content-moderation).

---

<a id="doc-privacy"></a>
# Privacy Policy

**Version 1.** This page states what data DevPlace collects, how it is
used, and how it is retained, deleted and shared.

## 1. Data DevPlace collects

| Category | What | Why |
|---|---|---|
| Account | Username, email address, password hash, API key | Identify you and secure your account |
| Age band | The band derived from your declared date of birth (`13_15`, `16_17`, `adult`) | Enforce the minimum age and age-appropriate access. **The date of birth itself is never stored.** |
| Profile | Bio, location, links, avatar seed, timezone | Shown on your public profile |
| Content | Posts, comments, gists, projects, project files, quizzes, polls, awards, uploads, direct messages, issue reports | The service itself |
| Activity | Last-seen timestamp, contribution calendar, XP and badges, notification and read state | Presence, gamification and notifications |
| Technical | IP address and user agent on audit-relevant actions, request timing | Security, abuse prevention and rate limiting |
| Assistant | Prompts and results of assistant, search and analysis features you invoke | Deliver the feature and attribute its cost to you |
| Moderation | Reports you file, reports filed about you, and decisions taken | Operate the safety controls the platform is required to have |

DevPlace runs **no third-party analytics, no advertising and no cross-site or cross-app tracking**.
There is no tracking identifier shared with any other company.

## 2. How the data is used

Only to run the service: to show your content to the people you published it to, to authenticate you,
to notify you, to keep the platform safe, and to bill and cap AI usage. It is never sold, and it is
never used to build an advertising profile.

## 3. Third parties

| Recipient | What is sent | When |
|---|---|---|
| our AI model provider | The text you asked an assistant feature to process | **Only after you grant `ai_third_party` consent.** Default: not granted. |
| Push services (Apple, browser push endpoints) | An encrypted notification payload to a device you registered | Only when you register a device and enable a push channel |
| Telegram | Messages in the Telegram conversation you paired | Only when you pair Telegram yourself |
| Software another member runs in a container | Your DevPlace API key and user id, injected as `DEVPLACE_API_KEY` and `DEVPLACE_USER_UID` | **Only after you grant `container_credentials` consent**, and only when the container is run by someone else. Your own containers use your own credentials with no separate consent. |
| Email and issue-tracker hosts you configure | The content you send through them | Only when you configure them yourself |

Every one of these is under your control, is off unless you turn it on, and can be revoked from your
profile privacy tab.

## 4. Consent

DevPlace records five consents, each versioned, each independently grantable and withdrawable, with
the full history retained so a decision is always provable:

- `terms` - acceptance of the Terms of Service and Community Guidelines;
- `privacy` - acknowledgement of this policy;
- `ai_third_party` - processing of **your own content** by a third-party AI model provider;
- `activity_recording` - recording of your presence and session activity;
- `container_credentials` - sharing your credentials with software another member runs in a container.

Withdrawal takes effect immediately. Withdrawing `ai_third_party` turns every assistant feature that
would send your content off, whatever the per-feature preference says. Withdrawing
`activity_recording` stops presence writes; you simply appear offline.

Manage all five at `/profile/{username}?tab=privacy`.

## 5. Retention and deletion

**You can delete your account yourself**, at any time, from your profile. It asks for your password,
tells you exactly what happens, and then:

1. every session and access token is revoked immediately;
2. your username is tombstoned and your email, bio, location, links, avatar seed, API key and
   password hash are cleared immediately - from your point of view and everyone else's, the account
   is gone the moment you confirm;
3. your content is removed from every surface under one deletion event;
4. after a short reversible window (published on the deletion page, so an accidental deletion can be
   undone), the whole event is permanently purged.

Retained after purge: append-only audit and moderation records required to show that the platform
enforced its own rules, holding actor identifiers rather than your personal profile; and backup
archives until they rotate out on their normal schedule.

## 6. Your rights

You can access your data (every surface is also a JSON API), correct it (edit your profile and
content), export it (the API and the per-tool export endpoints), restrict processing (withdraw a
consent), and erase it (delete your account). To exercise a right that the product does not cover
directly, use the [Contact](#doc-contact) page.

## 7. Security

Passwords are hashed with PBKDF2-SHA256 and never stored or transmitted in the clear. Sessions are
opaque tokens. Administrative and database access is restricted and every state change is recorded in
an append-only audit log.

## 8. Children

DevPlace is not directed at children under 16. Accounts are refused below
that age. If you believe an account belongs to a younger child, report it or use the
[Contact](#doc-contact) page and it will be closed.

## 9. Changes

Material changes bump the version at the top of this page.

## 10. Contact

Data-protection enquiries go to the address, email and telephone number on the
[Contact](#doc-contact) page.

---

<a id="doc-content-moderation"></a>
# How moderation works

This page describes, without omission, the safety controls DevPlace operates: what is filtered, how
you report, what happens next, and how long it takes.

## The commitment

**Every report is reviewed within 24 hours.** That is not a slogan: the
moderation queue carries a badge showing the age of the oldest unresolved report against that window,
so a breach is visible to every administrator the moment it happens.

## The three mechanisms

### 1. Automated filtering, at the moment content is created

Every piece of user-authored text passes through a classifier before it is published, at the five
places where content is created or changed: content creation, content editing, comment creation and
editing, direct messages, and profile and signup fields. Five choke points cover every surface,
because creation on DevPlace already funnels through them.

The classifier returns one of four verdicts:

| Verdict | Effect |
|---|---|
| `allow` | Nothing happens. |
| `label` | The item is published and carries an age label. |
| `review` | The item is published **and** a report is raised for a moderator. |
| `block` | The item is refused with an explanation. |

The default mode is `review`, not `block`. This is deliberate. DevPlace is a developer platform: its
members discuss exploits, cryptography, malware analysis and violent subject matter as their work, so
a machine that silently suppresses them would destroy the product. A flagged item is therefore
published and a human looks at it. Only the sexual and exploitative categories block outright,
because there is no legitimate developer case for them and the harm of publishing is immediate.

**The filter fails towards review, never towards publication.** If classification errors, the content
is published and a `critical` report is raised naming the failure. A safety control that fails
silently is worse than none.

### 2. Reporting, on every surface

Every surface that can carry user content carries a **Report** control: posts, comments, gists,
projects, project files, news, uploaded media, direct messages, quizzes, polls, awards, profiles,
issue reports, workspaces and assistant output. Select a reason from the
[Community Guidelines](#doc-community-guidelines) list, add detail, submit.

Reports are private. The reported person is never told who reported them. You are acknowledged
immediately and can follow the outcome at `/reports/mine`.

Filing a second report on the same item updates your existing one rather than creating a duplicate. A
different person reporting the same item creates a separate report, and the queue shows the count, so
a widely-reported item rises to the top.

### 3. The queue and the decisions

Administrators work one queue, sorted oldest-unresolved-first. Each report shows the reported item in
place, who authored it, how many people reported it, and every previous decision against that author.

A decision is one of:

| Decision | What it does |
|---|---|
| Remove content | Removes the item from every surface |
| Restore content | Reverses a removal |
| Warn | Records a warning against the account and tells the user |
| Suspend | Blocks creating content for a stated period, with a stated reason |
| Ban | Closes the account permanently and revokes every credential |
| Lift | Ends a suspension or ban |
| Dismiss | The report was not a violation |
| Escalate | Raises severity and keeps the report open for a second opinion |

Removal is not available for targets that have no removal path - direct messages, accounts,
workspaces, polls and assistant output. For those, the remedy is the account-level action.

Every decision writes a permanent record: what was decided, by whom, why, and against which report.
Every decision is also written to the append-only audit log. **The user is always told what was
decided and why**, through an in-app notification, and can reply to dispute it.

Administrators cannot act against an administrator who registered before them.

## What a suspension actually does

A suspended account can still sign in, still read everything, still see the reason for the
suspension, and still delete itself. It cannot create or change content. A user is never trapped.

## Blocking, which is yours alone

Independently of reporting, you can **block** any account. Their content disappears from your view
everywhere except their own profile, they can no longer message you, and they can no longer notify
you. It is one-directional, reversible, and never disclosed to them. **Mute** is the lighter version:
it stops their notifications while leaving their content visible. See
[Block and mute](#doc-block-and-mute).

## Age labelling

Content classified as mature is hidden behind an interstitial by default. Revealing it is an explicit
choice, recorded on your account, and it is not offered at all to accounts in a minor age band.

## If you disagree with a decision

Reply to the notification, or write to the address on the [Contact](#doc-contact) page. State
the report and what you believe was wrong. A different administrator reviews it.

---

<a id="doc-intellectual-property"></a>
# Notice and takedown

DevPlace removes content that infringes copyright or trademark. This page is how you tell us, and how
the person whose content was removed responds.

## Filing a notice

The fastest route is the **Report** control on the infringing item, with reason
`intellectual_property`. It lands directly in the moderation queue with the item attached and is
answered within 24 hours.

If you would rather write, use the email address on the [Contact](#doc-contact) page. Either
way the notice must contain:

1. identification of the work you own, or a representative sample of it;
2. the exact location of the infringing material on DevPlace - a URL, or the surface and item;
3. your name, postal address, telephone number and email address;
4. a statement that you believe in good faith that the use is not authorised by the rights holder,
   its agent, or the law;
5. a statement, under penalty of perjury, that the information is accurate and that you are the
   rights holder or authorised to act for them;
6. your physical or electronic signature.

A notice missing any of these cannot be acted on, and you will be told which part is missing.

## What happens next

The material is removed or disabled, the person who published it is told what was removed and why,
and both the notice and the decision are recorded. Removal is not permanent destruction: it is
reversible by an administrator if the notice is withdrawn or a counter-notice succeeds.

## Counter-notice

If your content was removed and you believe that was a mistake or that the use was authorised, send a
counter-notice to the email address on the [Contact](#doc-contact) page containing:

1. identification of the material and where it appeared before removal;
2. a statement, under penalty of perjury, that you believe in good faith it was removed as a result
   of mistake or misidentification;
3. your name, postal address and telephone number;
4. your consent to the jurisdiction of the courts of your address, and your agreement to accept
   service of process from the person who filed the original notice;
5. your physical or electronic signature.

The counter-notice is forwarded to the original complainant. If they do not begin legal proceedings
within the statutory period, the material may be restored.

## Repeat infringers

An account that repeatedly publishes infringing material is suspended and then closed. That is a
condition of using DevPlace, not a discretionary sanction.

## Bad-faith notices

Knowingly misrepresenting that material is infringing carries liability under the law. Notices filed
in bad faith are treated as abuse of the reporting system and are actioned against the reporting
account.

---

<a id="doc-contact"></a>
# Contact

These are the published contact details for DevPlace. They are the same details declared as trader
information in the app stores that carry a DevPlace client, so the two can never drift: both read the
`contact_email`, `contact_phone` and `contact_address` site settings.

## Postal address

Kalverstraat 18, 8011 LH Zwolle, The Netherlands

## Email

retoor@molodetz.nl

## Telephone

+31630061242

## What to use which channel for

| Subject | Where |
|---|---|
| Report a piece of content | The **Report** control on the content itself. It is on every surface. |
| Something an account is doing to you | Report the account from its profile, and **Block** it. |
| Dispute a moderation decision | Reply to the decision notice, or write to the email address above. |
| Copyright or trademark infringement | [Notice and takedown](#doc-intellectual-property) |
| Privacy, data access or erasure | The email address above; see the [Privacy Policy](#doc-privacy). |
| A bug or feature request | The [issue tracker](/issues) |
| Anything else | The email address above |

Reports filed through the in-product control are always faster than email: they land directly in the
moderation queue with the reported item attached, and they are answered within
24 hours.

---

<a id="doc-tools-seo"></a>
# SEO Diagnostics

SEO Diagnostics audits a web page or an entire sitemap against a broad battery of checks, far wider
than a single Core Web Vitals or rich-results test. It loads each page in a real headless browser,
inspects the rendered DOM, measures performance, and grades the result. Progress streams live while
the audit runs, and a full categorised report is produced at the end.

Open it from the **Tools** menu, or go straight to `/tools/seo`. It is public: you do not need an
account.

## Running an audit

1. Paste a URL (for a single page) or a `sitemap.xml` URL.
2. Choose **Single URL** or **Sitemap**. In sitemap mode, set how many pages to crawl (up to 50).
3. Press **Run audit**. Progress appears immediately: each page is loaded, then every check runs.
4. When the audit finishes, the score, grade, per-category breakdown and every individual check
   with its recommendation are shown. A link opens the full standalone report.

You can run one audit at a time. Targets that resolve to private or local addresses are refused.

## What gets checked

The auditor groups its findings into categories:

- **Crawlability and indexing** - HTTP status, redirect chains, HTTPS and HSTS, canonical tags,
  meta-robots and `X-Robots-Tag`, `robots.txt`, XML sitemap, URL hygiene, and mixed content.
- **On-page meta and content** - title and meta description (presence and length), single H1 and
  heading hierarchy, content depth, language, character encoding, viewport, and favicon.
- **Links** - internal and external link profile, descriptive anchor text.
- **Structured data** - JSON-LD validity, recognised schema types and their required properties,
  microdata and RDFa detection.
- **Social cards** - Open Graph and Twitter Card tags for rich link previews.
- **Performance and Core Web Vitals** - Largest Contentful Paint, Cumulative Layout Shift, First
  Contentful Paint, Time To First Byte, page weight, request count, DOM size, compression, caching,
  image optimisation, and console errors.
- **Mobile and accessibility** - responsive layout (no horizontal overflow), tap-target sizing,
  image alt coverage, and form labels.
- **Security** - the common security response headers and TLS.
- **AI and LLM-search readiness** - whether your primary content is in the initial HTML (server
  rendered) rather than JavaScript-only, presence of an `llms.txt`, and semantic HTML landmarks.

Each check reports a status (pass, warn, fail or info), its severity, the observed value, and a
recommendation when something can be improved. The overall score is a severity-weighted pass rate,
shown as a 0-100 number and an A to F grade, with a subscore per category.

## Scoring

Failing a critical check (for example a non-200 status or HTTPS) costs far more than a low-severity
warning. Informational checks never affect the score. A score of 90 or above is an A.

## Ask Devii

You can also run an audit in plain language through the Devii assistant:

> Run SEO diagnostics on https://example.com and tell me the score.

Devii queues the audit, polls it, and reports the score, grade and a link to the full report.

## Programmatic access

The same audit is available over the API: `POST /tools/seo/run` to queue, `GET /tools/seo/{uid}`
to poll, and `GET /tools/seo/{uid}/report` for the full report (HTML or JSON). See the
[Tools (SEO Diagnostics)](#doc-tools) API group for request and response shapes.
</div>

<div class="devii-doc-cta">
    <a href="/tools/seo" class="sidebar-link">Open SEO Diagnostics</a>

---

<a id="doc-tools-deepsearch"></a>
# DeepSearch

DeepSearch is a multi-agent deep web researcher. Given a single research question it plans a set of
diverse web search queries, crawls and reads the most relevant sources, indexes everything into a
private vector collection for that run, then runs a chain of agents (report writer, findings
extractor, linker) grounded on the passages retrieved from that collection to produce a thorough,
cited markdown report with key findings, a confidence score and source diversity. Progress streams
live while it works, and afterwards you can chat with the gathered evidence.

Open it from the **Tools** menu, or go straight to `/tools/deepsearch`. It is public: you do not
need an account.

## Running a research job

1. Type a focused research question.
2. Set the **depth** (1-4; a depth above 1 also follows the most relevant links found inside
   crawled pages) and the maximum number of **pages** to crawl (up to 30).
3. Press **Research**. Progress appears immediately: query planning, web search, crawling each
   source, indexing, then the analysis agents.
4. You can **pause**, **resume** or **cancel** a run at any time.
5. When it finishes, open the report to read the summary, findings and sources, and to chat
   with the research.

You can run one job at a time. Targets that resolve to private or local addresses are refused, and
every fetched URL (including redirects) is checked.

## How it works

- **Query planning** expands your question into several complementary searches, and the crawl
  interleaves their results so every angle contributes sources.
- **Crawling** fetches candidates concurrently, first with a plain HTTP client, falling back to a
  headless browser for JavaScript-heavy pages. A readability extractor isolates the main article
  content of each page (navigation, cookie banners and footers are discarded). For social sites that
  block bots (X, YouTube, Reddit and similar) the readable text supplied by the search engine is
  used directly, so those sources still contribute their real content instead of a login wall. At
  depth above 1 the most relevant links inside crawled pages are followed. Identical content is
  de-duplicated, and a cross-session URL cache tracks pages seen by earlier runs.
- **Indexing** splits each page into overlapping chunks, embeds them through the AI gateway (with a
  local embedding fallback when the gateway is unavailable), and stores them in a per-session
  ChromaDB collection.
- **Analysis** retrieves the passages most relevant to your question from that collection and runs
  the report writer, findings extractor and linker agents to write the cited report, extract
  findings, and score overall confidence. The score combines confidence, source diversity and
  coverage. If synthesis fails, the report page marks the run as degraded instead of presenting raw
  source material as a report.

## Chatting with the research

Every finished session has a chat pane. Answers are grounded **only** in the sources captured during
that run, using hybrid retrieval (vector similarity plus keyword/BM25 ranking) over the session
collection, and every claim is cited back to a source.

## Exporting

A finished report can be downloaded as **Markdown**, **JSON** or **PDF** from the report page.

## Ask Devii

You can also run research in plain language through the Devii assistant:

> Run a deep search on the history of the transistor and summarise the findings.

Devii queues the job, polls it, and reports the score, confidence and a link to the report.

## Programmatic access

The same research is available over the API: `POST /tools/deepsearch/run` to queue,
`GET /tools/deepsearch/{uid}` to poll, and `GET /tools/deepsearch/{uid}/session` for the full report
(HTML or JSON). See the [Tools](#doc-tools) API group for request and response shapes.
</div>

<div class="devii-doc-cta">
    <a href="/tools/deepsearch" class="sidebar-link">Open DeepSearch</a>

---

<a id="doc-tools-isslop"></a>
# AI Usage Analyzer

The AI Usage Analyzer classifies source code and websites as AI slop, sophisticated AI-assisted work,
or genuine human work. It exists to end the practice of calling projects slop without evidence:
every verdict is produced by a transparent, reproducible pipeline and published as a persistent
report anyone can inspect.

Open it from the **Tools** menu, or go straight to `/tools/isslop`. It is public: you do not need
an account. Signed-in members keep their analysis history on their account; as a guest your
history is bound to your browser session, and it is claimed by your account automatically the
first time you visit the tool while signed in.

## The three verdicts

The whole difference in one line: **slop ships the model's defaults, sophisticated AI ships the
engineer's decisions, and human ships what no model would ever write.** This is the canonical
definition; every check and every grade traces back to it.

- **ai-slop**: untouched LLM defaults, shipped as-is. Low effort by definition, because the
  model's defaults are terrible.
- **sophisticated-ai**: clear signs of AI, steered by a programmer who knows exactly what they
  are doing. The model typed; the engineer decided.
- **human**: source code no AI would ever write that way. Opinionated, particular, unmistakably
  someone's.

Using AI is not the crime; shipping its defaults is. That is why messy human code is explicitly
not slop, and why clean, well-curated AI code is explicitly not slop either.

## Method

Classification uses a two-axis model derived from published static-analysis research:

- **Origin score (0-100)**: likelihood the code was AI-generated, computed from stylometric
  regularity (indentation variance, blank-line ratio, docstring uniformity), narration comments,
  AI tool signatures, and textbook naming.
- **Quality deficit score (0-100)**: a saturating, severity-weighted aggregate of anti-pattern
  signals: hardcoded secrets, injection patterns, unresolved dependencies, placeholder and
  prompt-leak comments, swallowed errors, over-abstraction, duplication, convention deviation,
  and language-specific tells.

The two axes intersect into five descriptive categories: `ai-slop`, `sophisticated-ai`,
`human-clean`, `human-messy`, and `uncertain`. High-quality AI-assisted code is explicitly not
slop, and messy human code is explicitly not slop. Vendored, generated, minified, binary, and
lockfile content is excluded before analysis; minified bundles and framework build artifacts are
still fingerprint-scanned for provenance markers.

The headline grade is an **authenticity grade**. A high human percentage is positive and a high,
recognizable AI-usage percentage is negative, because the badge certifies human authorship. The
A-F grade is therefore driven primarily by detected AI usage and secondarily by quality deficit:
even clean, well-curated AI code lowers the authenticity grade, and slop (AI plus anti-patterns)
lowers it furthest. Per-file scores aggregate into a repository verdict weighted by SLOC and path
criticality, with a confidence band.

**Template provenance is part of the verdict.** A repository that is a recognisable, unmodified
starter template or boilerplate (known template identity in its manifest, template marketing in
its read-me, a kitchen-sink scaffold constellation) ships its scaffold's defaults, and the
defaults share of the verdict rises accordingly - untouched boilerplates grade C-D rather than A and are categorized `ai-slop`, because shipping defaults as-is is the definition of slop no matter how clean the scaffold is.
Every marker is listed in the report's Template Provenance section.

A deterministic selection of representative files receives an additional AI review pass (the files are reviewed concurrently), and the
final report is composed by the same model. When no AI backend is reachable the static engine
remains fully authoritative.

Images are reviewed too. Up to twenty images per source are analysed by a vision model
(deterministically sampled when there are more, so the result stays stable), all concurrently.
Each image gets its own A-to-F authenticity grade and a short explanation of the artifacts found
(hands, garbled text, waxy skin, impossible lighting, warped geometry, the over-smooth diffusion
look). The mean image AI-likelihood is folded into the overall score when images are present.

The full plain-language catalog of every check lives on
[AI Usage Analyzer: every check explained](#doc-isslop-checks).

## Pipeline

1. **Resolve**: every URL is probed with `git ls-remote`; git sources are shallow-cloned
   (depth 1) with an upfront size preflight and a hard 3 GB cancellation guard, other URLs are
   crawled to a bounded depth with file and byte caps.
2. **Inventory**: exclusion rules and language detection.
3. **Static analysis**: the multi-signal engine scores every file.
4. **AI review**: representative files are audited through the AI gateway.
5. **Image review**: qualifying images are graded by the vision model.
6. **Scoring**: two-axis aggregation, grade, human/AI percentages.
7. **Report**: findings are written to a persistent markdown report.

The pipeline runs as an isolated subprocess that emits structured events; every event streams
live to the page, so you see each file checked, each signal found, and the model reasoning in
real time. Identical input content yields consistent output. Source workspaces are deleted as
soon as an analysis terminates; only the persisted report and its evidence remain.

## Running an analysis

1. Paste a repository URL (`https://github.com/owner/repository`, `git@...`) or a website URL.
2. Press **Classify**. The live view opens immediately and streams every step.
3. When the analysis finishes, the report page shows the authenticity grade, the human/AI split,
   the full markdown report, the per-file results, the image review, and the badge embeds.

You can run one analysis at a time. Targets that resolve to private or local addresses are
refused. Completed analyses provide:

- a persistent, publicly shareable report at `/tools/isslop/{uid}/report`,
- a markdown download of the full report at `/tools/isslop/{uid}/report.md`,
- a JSON API documented in the [Tools API group](/docs/api.html#tools),
- an SVG badge at `/tools/isslop/{uid}/badge.svg` labeled `authenticity`, showing the human score
  and linking to the report, with ready-to-copy markdown and HTML embed snippets.

Reports, badges and event trails are intentionally public capability URLs so badge holders can
prove their score; only your personal history list is bound to your account or session.

## Caveats

No detector is definitive. Results are calibrated guidance with explicit confidence levels,
never proof of provenance.

---

<a id="doc-isslop-checks"></a>
<nav class="docs-toc" aria-label="Contents">
    <div class="docs-toc-heading">Contents</div>
    <ul class="docs-toc-grid">
        <li class="docs-toc-item"><a href="#how-the-check-works"><span class="docs-toc-title">How the check works<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">A plain-language overview of what the AI Usage Analyzer does and what the score means.</span></a></li>
        <li class="docs-toc-item"><a href="#tell-tale-ai-writing-in-comments-and-text"><span class="docs-toc-title">Tell-tale AI writing in comments and text<span class="docs-toc-count">9 checks</span></span><span class="docs-toc-summary">Phrases and habits that assistants leave behind in code comments and notes.</span></a></li>
        <li class="docs-toc-item"><a href="#website-look-and-feel-fingerprints"><span class="docs-toc-title">Website look-and-feel fingerprints<span class="docs-toc-count">10 checks</span></span><span class="docs-toc-summary">The recognisable visual recipe that AI website tools reach for by default.</span></a></li>
        <li class="docs-toc-item"><a href="#ai-website-builders-and-framework-leftovers"><span class="docs-toc-title">AI website builders and framework leftovers<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">Direct fingerprints of the tools that generate whole sites and apps.</span></a></li>
        <li class="docs-toc-item"><a href="#placeholder-and-unfinished-content"><span class="docs-toc-title">Placeholder and unfinished content<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">Filler text and dead buttons that reveal work was never finished.</span></a></li>
        <li class="docs-toc-item"><a href="#rendered-page-signals"><span class="docs-toc-title">Rendered page signals<span class="docs-toc-count">8 checks</span></span><span class="docs-toc-summary">What a live look at the actual rendered home page reveals, from builder fingerprints to unfinished build artifacts.</span></a></li>
        <li class="docs-toc-item"><a href="#security-mistakes-assistants-commonly-make"><span class="docs-toc-title">Security mistakes assistants commonly make<span class="docs-toc-count">12 checks</span></span><span class="docs-toc-summary">The insecure defaults AI tools repeat because they learned them from tutorials.</span></a></li>
        <li class="docs-toc-item"><a href="#code-structure-and-style-fingerprints"><span class="docs-toc-title">Code structure and style fingerprints<span class="docs-toc-count">9 checks</span></span><span class="docs-toc-summary">The unnaturally tidy, textbook shape that machine-written code tends to have.</span></a></li>
        <li class="docs-toc-item"><a href="#quality-problems-and-error-handling"><span class="docs-toc-title">Quality problems and error handling<span class="docs-toc-count">7 checks</span></span><span class="docs-toc-summary">Shortcuts and missing safeguards that mark rushed, unreviewed work.</span></a></li>
        <li class="docs-toc-item"><a href="#dependency-and-correctness-red-flags"><span class="docs-toc-title">Dependency and correctness red flags<span class="docs-toc-count">7 checks</span></span><span class="docs-toc-summary">Imagined libraries, over-complex functions and known risky patterns.</span></a></li>
        <li class="docs-toc-item"><a href="#ai-generated-images-and-artwork"><span class="docs-toc-title">AI-generated images and artwork<span class="docs-toc-count">8 checks</span></span><span class="docs-toc-summary">How the image reviewer judges whether pictures on a page were made by AI.</span></a></li>
        <li class="docs-toc-item"><a href="#read-me-and-documentation-smell"><span class="docs-toc-title">Read-me and documentation smell<span class="docs-toc-count">4 checks</span></span><span class="docs-toc-summary">The over-structured, over-eager style of generated project descriptions.</span></a></li>
        <li class="docs-toc-item"><a href="#starter-template-and-boilerplate-provenance"><span class="docs-toc-title">Starter template and boilerplate provenance<span class="docs-toc-count">4 checks</span></span><span class="docs-toc-summary">Recognising projects that ship a scaffold's defaults instead of original work.</span></a></li>
    </ul>
</nav>
<div class="docs-content" data-render>
# AI Usage Analyzer: every check explained

The AI Usage Analyzer reads a website or a code repository and estimates how much of it was written by a person and how much was generated by artificial intelligence. This section explains, in plain language, every clue it looks for.

This page documents, in plain language, every check the [AI Usage Analyzer](/tools/isslop) runs. No detector is definitive on its own: results are calibrated guidance with explicit confidence levels, never proof of provenance. Every heading below is linkable: hover a title and copy its anchor.

## How the check works

When you give the AI Usage Analyzer a link, it fetches the page or repository, looks through the readable files, and counts small clues. No single clue is proof. A verdict is only formed when several independent clues agree. The result is a guide, not an accusation.

- **Two questions, kept separate**: the analyzer asks two different questions about every file. First: does this look like a person or a machine wrote it? Second: is the work carefully finished, or rushed and left messy? Keeping the two questions apart matters, because tidy AI work is not the same as sloppy human work.
- **The human percentage**: The headline number is how much of the project appears to be genuinely human-written. A high human percentage is good. A high, easily recognisable amount of AI is what lowers the score.
- **The authenticity grade**: The A to F grade is an authenticity grade. It is driven mainly by how much AI usage is detected and secondarily by how many quality problems are found. Even neat AI code lowers the grade, because the badge certifies human authorship.
- **Confidence level**: Every result comes with a confidence level of low, medium or high, based on how many strong clues were found and how much readable content there was. Small pages get a low confidence on purpose.
- **What is ignored**: Downloaded libraries, automatically generated files, images and other non-code files are set aside before the check, so they cannot unfairly change the result. Compressed program files are still scanned for brand fingerprints even though their style cannot be judged.

## Tell-tale AI writing in comments and text

AI assistants have writing habits. When their output is pasted into a project without cleanup, those habits stay in the comments and notes inside the files. These are some of the strongest and clearest clues.

- **Leftover placeholder notes**: Notes like "your code here" or "rest of the code" mean an unfinished answer was pasted in and never completed.
- **Assistant chatter**: Phrases like "certainly!", "here is the updated code" or "I hope this helps" are how a chatbot talks, not how code is normally written.
- **AI tool signatures**: Some tools sign their work with lines like "Generated with" a named assistant. That is direct evidence of the tool used.
- **Comments that narrate the obvious**: A comment that simply repeats what the next line already says is a habit of machine-written code; people rarely bother.
- **Signature AI vocabulary**: Words such as "delve", "showcase", "pivotal", "seamless" and "meticulous", used together, are strongly associated with AI writing.
- **Dash overuse**: Assistants use the long dash far more often than most people. A cluster of them is a small but real clue.
- **Emoji in comments and headings**: Sprinkling emoji through code comments and buttons is uncommon in careful human work but common in AI output.
- **Marketing language**: Boastful phrases like "blazingly fast" or "robust and scalable" inside code read like advertising copy, not engineering notes.
- **Edit-narration comments**: Comments like "Updated section" or "Added new feature" are left behind when someone repeatedly asks an assistant to change a page.

## Website look-and-feel fingerprints

AI design tools reach for the same visual recipe again and again. When many of these defaults appear together, the page was very likely generated rather than hand-crafted.

- **The signature purple gradient**: One specific purple-to-violet gradient appears in a huge number of AI-generated pages. It is almost a logo for machine-made design.
- **Other default purple tones**: Even without the exact signature, the indigo and violet colours that ship as defaults in popular tools point the same way.
- **The default toolkit**: A particular bundle of building blocks (a specific icon set, ready-made components and utility styling) is the standard kit these tools assemble. Finding several together is a strong sign.
- **The stock landing-page layout**: Hero banner, three feature cards, testimonials, pricing, frequently-asked-questions: the same running order used by nearly every template.
- **Section banner comments**: Big comment labels like "Hero Section" or "Features Section" dividing the page are a generation habit.
- **Styling loaded from a shortcut link**: Loading the styling toolkit straight from an internet link is the quick prototype default, not how finished sites are built.
- **The default font and icon pairing**: A specific web font paired with a specific icon library is the out-of-the-box combination these tools use.
- **Automatic scroll effects**: Ready-made smooth-scrolling and fade-in-on-scroll snippets are copied in wholesale by generators.
- **The universal reset block and generic colour variables**: A boilerplate style reset and colours named simply "primary" and "secondary" are textbook scaffolding.
- **Emoji inside headings and buttons**: Decorative emoji placed inside page headings and buttons is a common generated-page flourish.

## AI website builders and framework leftovers

Some tools build an entire website from a single prompt. They leave behind unmistakable traces in file names, hidden markers and untouched starter text. These are among the most reliable clues of all.

- **Builder brand markers**: Hidden identifiers and upload folders left by well-known one-prompt website builders are direct evidence of the tool that made the site.
- **Build-tool file names**: File and folder names produced automatically by popular app frameworks reveal how the site was assembled, even when the file itself is unreadable machine code.
- **Untouched starter pages**: Default titles like "Create Next App" or "Vite + React", the "You need to enable JavaScript" notice, and empty starter containers show a template was never personalised.
- **Unrendered template tokens**: Leftover placeholders that were supposed to be filled in automatically, but were shipped as-is, mark hurried generated output.
- **Everything crammed into one file**: A single page with hundreds of lines of styling and script all inlined together is the shape of a straight copy-paste from a chat window.

## Placeholder and unfinished content

Generated pages are frequently shipped with the sample content still in place. These leftovers show the work was never truly finished for real use.

- **Sample names, emails and phone numbers**: Fictional contacts like "john@example.com", "Your Company" or a "555" phone number are stand-ins that a real owner would have replaced.
- **Latin filler text**: The classic "lorem ipsum" placeholder paragraphs mean the real words were never written.
- **Buttons and links that go nowhere**: A page full of links that lead back to the same spot is a mock-up, not a working site.
- **Default project names**: Names like "my-app" or "my-project" left in the settings show the starter was never renamed.
- **Tutorial startup messages**: Console messages copied straight from a getting-started guide are a small sign of stitched-together code.

## Rendered page signals

For a live website, the analyzer goes a step further than reading its files: it opens the home page in a real headless browser and inspects what a visitor's browser actually produces, computed styles, the census of class names in use, meta tags, headings and page structure, console warnings, the hosts behind every loaded resource, and a full-page screenshot. This rendered-page pass only ever runs once, against the home page, and only for a live website: a git repository has nothing to render, and if a browser could not be launched the analyzer simply skips this pass and relies on the file-based checks above. Underneath, these checks are organised into eight categories running forty-nine individual signal checks against the captured page; the list below groups them by theme.

- **AI website builder fingerprints**: the strongest and most direct evidence in this section. One-prompt website builders leave behind a hidden badge, a loader script, a "generator" meta tag, or a distinctive hosting subdomain. The analyzer recognises Lovable, Bolt.new, Replit, Base44, Framer, Wix, Webflow, GoDaddy, Squarespace and embedded Claude Artifacts by name, plus a softer corroborating check for a cluster of shadcn/ui and Radix component markers and for hosting on a generic Vercel, Netlify or Databutton subdomain.
- **Color and gradient defaults**: the exact purple-to-violet gradient and the handful of indigo and violet accent colours that ship as defaults in popular design tools, an oversized glowing purple shadow, an all-dark default theme, and a colour token in the shadcn/ui default lightness band.
- **Typography defaults**: a small set of fonts, Inter, Poppins, Manrope, Geist, Space Grotesk, DM Sans and Plus Jakarta Sans, are the out-of-the-box choice in nearly every AI design tool, especially when the whole rendered page uses only one font family, when a decorative monospace font shows up in a heading or button, or when the page loads the exact canonical Google Fonts weight set these tools request.
- **Boilerplate layout shapes**: the hero-features-testimonials-pricing-FAQ section running order, a translucent blurred navigation bar, one class name repeated across many near-identical cards, and a page full of rendered links that lead nowhere are the same generated shape seen again and again.
- **Cliche marketing copy**: rendered headings and meta text are checked for stock phrases like "unlock the power of" or "take your business to the next level", a cluster of inflated buzzwords, decorative emoji inside a heading, and the same handful of canonical frequently-asked-questions used by countless landing pages.
- **Neglected SEO and metadata**: a missing meta description, structured data, social preview image, page language, canonical link or favicon, and the same meta description reused across every page of a site, are the kind of basic housekeeping a real launch usually gets right and a generated page often skips.
- **Accessibility shortcuts**: generic or duplicated image alt text, a run of images with no alt text at all, and a heading structure that jumps straight from a top-level heading to a much deeper one are quick tells of markup nobody reviewed with real visitors in mind.
- **Never-productionized build artifacts**: loading the Tailwind styling toolkit or React straight from a public content-delivery link, a browser console still showing development-build warnings, unhashed script filenames, an unedited default framework page title, and placeholder copy or a placeholder image host left in the page all mean a prototype build shipped as the live site.
- **Builder matches decide, everything else only nudges**: a confirmed builder fingerprint is treated as decisive evidence on its own. Every other rendered-page signal in this section moves the overall score by only a small, deliberately cautious amount, and only when several of them show up together. Using shadcn/ui, Tailwind, Inter or any other widely used default is completely ordinary and never raises the score by itself.

## Security mistakes assistants commonly make

AI tools learned from years of tutorial code that took shortcuts for the sake of a quick demo. They reproduce those same shortcuts, which turn into real security weaknesses. These carry the most weight in the score.

- **Passwords and keys written into the code**: Secret keys and passwords typed directly into files are a serious risk, and assistants do this routinely.
- **Well-known placeholder secrets**: Stock secret values like "your-secret-key" or "supersecret" are favourites that assistants reuse across projects, which makes them easy to guess.
- **Doors left open to any website**: A permissive setting that lets any website talk to the app, combined with sign-in credentials, is the classic generated-code vulnerability.
- **Demo login details**: Built-in accounts like "admin123" that were never removed leave an obvious way in.
- **Sign-in tokens kept in an unsafe place**: Storing a user's sign-in token where any injected script can read it exposes accounts to takeover.
- **Cookies without safety flags**: Sign-in cookies set without the standard protective flags can be stolen or misused.
- **Secrets exposed to the public side**: A secret placed where the visitor's browser can read it is effectively published to everyone.
- **Master database keys in the browser**: The all-powerful database key reachable from the public page hands full control to anyone who looks.
- **Unsafe handling of user input**: Building database queries or web content by gluing raw user input together opens the door to well-known attacks.
- **Debug mode left switched on**: Shipping with debugging enabled leaks internal details to the public.
- **Insecure resource links**: Loading parts of a secure page over an insecure connection weakens the whole page.
- **Default database addresses**: Connection details copied straight from a tutorial show the setup was never made real.

## Code structure and style fingerprints

Machine-written code is often too tidy. Its spacing, naming and structure are more uniform and more textbook-perfect than the code people write under real deadlines. That very neatness is a clue.

- **Unnaturally even spacing**: People vary their spacing and blank lines naturally. Code where every line is spaced with machine regularity stands out.
- **A note on top of every single function**: When every function, even trivial ones, carries a tidy description in the same template, that uniformity points to generation.
- **Over-explained simple functions**: A one-line function wrapped in a formal description it does not need is a classic assistant habit.
- **Extremely long textbook names**: Names like "total_user_input_character_count" are the descriptive-but-impractical style assistants prefer.
- **Textbook error message pattern**: The exact "print an error message" style of handling problems is straight out of introductory examples.
- **Uniform textbook structure**: Full type labels, a description on every function and a standard program entry point all at once is the hallmark of by-the-book generated code.
- **Unnecessary layers of abstraction**: Elaborate structures built for a task that does not need them are a known over-engineering habit of assistants.
- **Generic names everywhere**: A high count of vague names like "data", "result" and "temp" in real code suggests little human thought went into naming.
- **Mixed naming styles**: Two different naming conventions used side by side for the same kind of thing points to stitched-together sources.

## Quality problems and error handling

This group looks at whether the work was finished with care. Missing safeguards and copied-in debugging leftovers show code that was generated and shipped without a proper review.

- **Errors quietly swallowed**: Code that catches a problem and then ignores it hides real failures and is a frequent generated-code shortcut.
- **Catch-everything handlers**: Handling every possible error the same vague way, instead of the specific ones expected, is a tell of unreviewed code.
- **Leftover debugging output**: Debugging print-outs and markers scattered through the code were meant to be removed before release.
- **Missing clean-up after timers and listeners**: Starting a timer or a listener without ever stopping it is a common oversight in generated interface code.
- **Assuming a reply is always valid**: Reading a server reply without checking it is the expected type is a fragile shortcut.
- **Files opened without safe closing**: Opening files without the standard safe-closing pattern risks leaks and is against normal practice.
- **Dead code that never runs**: Blocks written so they can never actually execute are needless scaffolding left behind.

## Dependency and correctness red flags

Assistants sometimes invent things that do not exist or produce needlessly complicated code. These clues point to work that may not even function correctly.

- **Imagined libraries**: References to outside libraries that are not actually installed anywhere may be invented, a well-documented failure of AI tools.
- **Over-complicated functions**: A single function packed with far too many decisions is hard to maintain and often machine-produced.
- **Very long functions**: Functions that stretch on for well over a hundred lines usually should have been broken up.
- **Copy-pasted repetition**: The same block of logic repeated instead of shared is a smell of quick generation.
- **Everything-in-one-file modules**: A single oversized file mixing many unrelated jobs is a known generated anti-pattern.
- **Data-fetching done the fragile way**: A common interface mistake, fetching information without protection against retries or duplicate requests, is a frequent AI habit.
- **Outdated or loose coding shortcuts**: Old-style declarations and vague catch-all types in modern code point to defaults an assistant reached for.

## AI-generated images and artwork

Beyond the code and text, the analyzer looks at the pictures themselves. It examines up to twenty images (chosen evenly across the whole set when there are more) and asks a vision model to judge each one, giving every picture its own A-to-F authenticity grade. All images are reviewed at the same time to keep it fast.

- **Hands, fingers and faces**: AI pictures still slip up on hands with extra or fused fingers, and on faces with strange teeth, eyes or ears.
- **Waxy, too-perfect skin**: Skin that looks plastic, airbrushed or unnaturally smooth, without the natural imperfections of a real photo, is a common giveaway.
- **Garbled text in the picture**: Signs, labels and packaging with letters that spell nonsense are one of the clearest signs a picture was generated.
- **Lighting and shadows that do not add up**: Shadows falling the wrong way, or some objects casting shadows while others do not, reveal that no real light source existed.
- **Warped backgrounds and impossible shapes**: Bent architecture, melted objects and geometry that could not exist in reality are classic generation artifacts.
- **The too-perfect, over-smooth look**: A mathematically smooth, over-saturated, dreamlike finish across the whole image is typical of image generators.
- **Sensible exceptions**: Ordinary screenshots, logos, diagrams and real product photos are recognised as such and are not mistaken for AI art.
- **A grade for every image**: Each reviewed picture receives its own grade and a short explanation, which are kept and shown in the report so you can see exactly why.

## Read-me and documentation smell

The read-me file that introduces a project has its own generated style: over-structured, decorated with emoji, padded with badges and written in an over-eager tone.

- **Generic read-me skeleton**: A stack of emoji-topped headings with the same boilerplate sections and no project-specific detail is a generated-introduction pattern.
- **Emoji-topped headings**: Every heading prefixed with an emoji is a strong stylistic tell in project documentation.
- **Badge stuffing**: A long row of decorative status badges padding out the top of a read-me is a cosmetic generated habit.
- **Signature AI phrasing**: The "it is not just X, it is Y" sentence shape and similar constructions are characteristic of assistant prose.
## Starter template and boilerplate provenance

The canonical definition says slop ships defaults. Those defaults are not only the model's: a
project that is an unmodified starter template or kitchen-sink boilerplate ships its scaffold's
defaults, so recognisable template provenance counts against the authenticity grade. The evidence
is scored and every marker is listed in the report; a project genuinely built on top of a starter,
with its own name, its own README and real application code, keeps most of its grade.

- **Known template identity**: the package manifest still carries a known starter's name, a known
  template publisher as its author, or scaffold metadata a generator wrote (for example the
  create-t3-app init record).
- **Template marketing in the read-me**: the read-me still says what the template's read-me said:
  "boilerplate and starter for", "open-source template built with", "bootstrapped with", one-click
  deploy buttons, hosted demo links and sponsor sections.
- **Kitchen-sink scaffold constellation**: a large pile of standard scaffold artifacts (Storybook,
  Husky, commitlint, lint-staged, Playwright, Vitest, Drizzle, Crowdin, semantic-release and
  friends) arriving together is the signature of a generated setup, not of a project that grew.
- **Confidence gating**: weak evidence changes nothing; only a confident template score lowers the
  grade, and near-certain evidence lowers it further. The influence is a floor on the defaults
  share, never a replacement for the code analysis.
- **Untouched templates are slop**: when the evidence is near-certain, the verdict category is
  `ai-slop`, whatever the scaffold's code quality. Shipping defaults as-is is the definition of
  slop; the curation in a starter belongs to the template author, not to the project presenting
  it. Confident-but-partial evidence caps the category at `uncertain` instead.

---

<a id="doc-claude"></a>
# Claude Code setup

DevPlace ships a complete, version-controlled setup for [Claude Code](https://claude.com/claude-code),
Anthropic's command-line coding agent. Everything lives under the `.claude/`
directory in the repository root, so it is shared with every contributor through
git and applies the moment the repository is opened in Claude Code.

This setup enforces the platform's ten quality dimensions and extends them into
feature work, all expressed in Claude Code's own primitives.

## What is in `.claude/`

| Path | Primitive | Purpose |
|------|-----------|---------|
| `.claude/agents/*.md` | Subagents | Twelve single-dimension maintainers plus a feature builder and a live-API operator. See [Subagents](#doc-claude-agents). |
| `.claude/commands/*.md` | Slash commands | Lifecycle commands for understanding, building, verifying, testing, and operating. See [Commands](#doc-claude-commands). |
| `.claude/workflows/*.js` | Workflows | Deterministic multi-agent scripts for auditing and for building features. See [Workflows](#doc-claude-workflows). |
| `.claude/settings.local.json` | Settings | Local permission configuration. |

## Three layers of orchestration

The setup offers the same capability at three levels of control, from most
interactive to most deterministic.

1. **Subagents** are the building blocks. Each enforces exactly one quality
   dimension (security, audit coverage, documentation, and so on) and can be
   invoked on its own or spawned by anything else.
2. **Commands** are model-driven slash commands. Claude reads the command and acts
   in the current conversation. They cover the everyday lifecycle (understand, build,
   verify, test, operate) and orchestration (`/maintenance`). Best for a quick,
   interactive step with a human in the loop.
3. **Workflows** are script-driven orchestration. A JavaScript file in
   `.claude/workflows/` runs the same agents in a fixed, reproducible order, with
   structured output and adversarial verification of every finding. Best for a
   repeatable audit or a feature build.

## Quick reference

- Run one dimension: mention the subagent, for example
  `@agent-security-maintainer review the routers`.
- Orient on an area: `/explain <area>`, `/trace <route>`.
- Check or fix the whole fleet: `/maintenance`, `/maintenance fix`,
  `/maintenance check changed`, `/maintenance fix security,audit`.
- Verified audit of all dimensions: `/fleet`. Review the current diff: `/review`.
- Build a feature end to end: `/feature add bookmarks to gists`.
- Scaffold one route, a Devii tool, or an async job:
  `/endpoint`, `/devii-tool`, `/job-service`.
- Lighter build recipes: `/docs-page`, `/audit-event`, `/service`.
- Verify and run: `/serve`, `/screenshot <path>`, `/api-test`, `/validate`, `/test`.

New to the setup? The [Manual](#doc-claude-manual) is the task-oriented guide:
how to add a feature, change existing code, fix an issue, verify, test, and review,
step by step. The remaining pages are the full reference for the subagents, the
commands, and the workflows.

---

<a id="doc-claude-manual"></a>
# Manual

This is the hands-on manual for the [Claude Code setup](#doc-claude). It shows
how to actually get work done with the [subagents](#doc-claude-agents),
[commands](#doc-claude-commands), and [workflows](#doc-claude-workflows):
adding features, changing existing code, fixing issues, verifying, testing, reviewing,
and operating the app. Read the three reference pages for the full catalogue; read
this page to learn the day-to-day flow.

## Mental model

The setup gives you the same capability at three levels of control. Reach for the
lowest level that fits the task.

| Level | Primitive | Reach for it when |
|-------|-----------|-------------------|
| Command | `/name` (single context) | A discrete step: orient, validate, run, scaffold one thing. |
| Workflow | `/name` (deterministic multi-agent script) | A repeatable, verified pipeline: build a feature, audit the diff. |
| Subagent | `@agent-<name>` | One quality dimension, ad hoc, on a slice of code. |

Two rules the whole environment honors, so you never have to ask:

- **No command, workflow, or subagent performs a git write.** Committing is always
  your manual step.
- **Nothing runs the test suite except the `/test` command**, which exists precisely
  so that running tests is an explicit choice you make.

## The core loop

Almost every change follows the same five beats. The rest of this manual is variations
on it.

1. **Orient** - `/explain <area>` or `/trace <route>` to learn the area and its fan-out.
2. **Build** - a build workflow (`/feature`, `/endpoint`, ...) or a build command, or hand edits.
3. **Run** - `/serve`, then `/screenshot` and `/api-test` to see it work.
4. **Check** - `/validate` for static checks, `/test` for the relevant tier.
5. **Review** - `/review` over the diff before you commit.

## Before you start

```
make install      # editable install (first time)
/serve            # start the dev server on port 10500 and confirm health
```

`/serve` launches `make dev` in the background and uses `mole` to confirm the server
answers. Leave it running; the verify commands target it.

## Recipe: add a feature

A feature in DevPlace fans out across nine layers (form model, output schema, data
helpers, route, view, Devii tool, API docs, SEO, tests). The `/feature` workflow
drives all of them, then audits and tests itself.

```
/explain the gists area                      # optional: understand the pattern first
/feature add a bookmark button to gists, owner-scoped, with a profile tab
```

What `/feature` does, in order: maps the area and the closest existing feature,
plans a per-layer change list, implements it coherently in the repo, runs the
validator and an import check, audits the result in parallel
(`fanout-maintainer` + `security-maintainer` + `docs-maintainer`), fixes the gaps,
and writes the missing integration tests. When it finishes:

```
/screenshot /gists          # see the new UI rendered, verified by falcon vision
/api-test the gist bookmark endpoints
/test api                   # run the API tier
/review                     # verified diff review before committing
```

## Recipe: add one endpoint, one Devii tool, or one async job

When you need just one route or capability rather than a whole feature, use the
narrower build workflows. Each understands the closest existing pattern, implements
across the touchpoints, verifies, and writes a test.

```
/endpoint POST /gists/{slug}/star to star a gist
/devii-tool a tool to list the current user's bookmarks
/job-service render a project to a PDF and offer it as a download
```

For the lighter, single-file recipes, use the build commands instead:

```
/docs-page bookmarks "Bookmarks" General      # a new prose docs page
/audit-event gist.bookmark for the new POST route
/service a digest service that emails weekly activity
```

## Recipe: update or change existing code

When you are modifying something that already exists rather than adding new surface:

1. `/trace <route or feature>` - see every layer it touches, so you change all of them
   together (the cardinal failure mode here is updating one layer and forgetting a
   connected one).
2. Make the edit - by hand, or describe it to Claude in the conversation.
3. `/validate` - confirm the validator and the app import still pass and no prose
   em-dash slipped in.
4. `/screenshot` and `/api-test` if it touched the UI or an endpoint.
5. `/review` - the diff is checked across every dimension with adversarial
   verification before you commit.

> Keep changes coherent across the fan-out. If you change a route's returned JSON,
> update its `*Out` schema, its template, its API docs entry, and its Devii action in
> the same change. `/review` will flag a half-migrated change, but it is cheaper to
> get it right the first time. `/trace` tells you the full set.

## Recipe: fix an issue

```
/explain <the failing area>          # understand the code path
/test tests/api/<area>/<file>.py     # reproduce with the focused test, if one exists
```

Fix the root cause (not the symptom), then:

```
/validate
/test <the same target>              # confirm green
/review
```

Never weaken a test to make it pass. If a test exposes a real defect, the fix goes in
the code, not the test - the `test-maintainer` and the `/test` command both enforce
this.

## Verifying your work

Verification is mandatory before you call anything done. Use the layer that matches
what you changed.

| You changed | Verify with |
|-------------|-------------|
| Any source file | `/validate` (validator + app import + em-dash scan) |
| An HTTP endpoint | `/api-test <endpoints>` against the running server |
| A page, layout, component, or responsive rule | `/screenshot <path>` (Playwright capture + falcon vision) |
| Behavior covered by tests | `/test <tier or path>` |
| A whole change set | `/review` (diff review, all dimensions, verified) |

`/validate` is fast and should run after every edit. `/screenshot` and `/api-test`
need `/serve` running first. `/test` is the only thing that runs the suite, and only
because you asked.

## Reviewing and maintaining

| Goal | Use |
|------|-----|
| Review the current uncommitted diff before committing | `/review` |
| A full, verified audit of the whole codebase | `/fleet` |
| Fan the ten dimensions out interactively (check or fix) | `/maintenance`, `/maintenance fix`, `/maintenance check changed` |
| One dimension, ad hoc | `@agent-security-maintainer ...`, `@agent-docs-maintainer ...` |

`/review` and `/fleet` adversarially verify every finding (a second agent tries to
refute it) before reporting, so they are low-noise. `/maintenance fix` applies fixes
serially in canonical order so two agents never edit the same file at once.

## Understanding the codebase

| Goal | Use |
|------|-----|
| Architecture, data flow, and gotchas for an area | `/explain <area>` |
| Where every layer of a route or feature lives, and what is missing | `/trace <route>` |
| A deep read of one quality dimension | `@agent-<dimension>-maintainer` in report mode |

These are read-only; they change nothing.

## Operating the app

| Goal | Use |
|------|-----|
| Start the dev server and confirm it is healthy | `/serve` |
| Capture and describe a page | `/screenshot <path>` |
| Exercise endpoints from a JSON spec | `/api-test <endpoints>` |
| Manage roles, news, attachments, Devii quota, zips, forks, containers | `/cli <args>` |

`/cli` confirms any destructive action (clear, prune) with you before running it,
since it acts on the live database.

## What the environment enforces

Every build command, workflow, and subagent is bound to the project rules, so the
output already conforms:

- No comments or docstrings in source (only the file header and required tool
  docstrings); the `retoor <retoor@molodetz.nl>` header on new files.
- No em-dash characters in authored prose; full Python type hints; `pathlib` over
  `os`; Pydantic input with explicit max lengths.
- Reuse of the shared helpers (`templating.templates`, the `database.py` batch
  helpers, `respond`, the avatar and user partials, and the frontend `Http` /
  `Poller` / `JobPoller` / `OptimisticAction` / `FloatingWindow`).
- The right auth guard on every mutating route; soft deletes; owner-or-admin delete
  authorization; DD/MM/YYYY dates.
- The validator and the app import must pass before a build step finishes.

It will not commit, will not run tests outside `/test`, and will not weaken a guard or a
test to make a finding disappear.

## Troubleshooting

| Symptom | Cause and fix |
|---------|---------------|
| `/screenshot` or `/api-test` says the server is down | Run `/serve` first; if the port moved, `/serve` scans 10500-10510. |
| A build workflow reports unfixed audit gaps | The fix would have degraded behavior or broken a consumer; read the stated reason and resolve it by hand, then `/validate`. |
| `/review` flags a finding you believe is wrong | It already survived an adversarial refutation; re-read the cited `file:line` with its context before dismissing it. |
| A workflow run seems stuck | Run `/workflows` to watch live progress, pause, or stop it. |
| You want only some dimensions | `/maintenance fix security,audit`, or call a single `@agent-...-maintainer`. |

## Cheat sheet

| Task | Entry point |
|------|-------------|
| Understand an area | `/explain` |
| Map a feature's layers | `/trace` |
| Build a full feature | `/feature` |
| Add one route / Devii tool / job | `/endpoint` / `/devii-tool` / `/job-service` |
| Add a docs page / audit event / service | `/docs-page` / `/audit-event` / `/service` |
| Start the server | `/serve` |
| Verify a page / endpoint | `/screenshot` / `/api-test` |
| Static checks | `/validate` |
| Run tests | `/test` |
| Review the diff | `/review` |
| Audit the whole repo | `/fleet` |
| Fix one dimension across the repo | `/maintenance fix` |
| Management CLI | `/cli` |

---

<a id="doc-claude-agents"></a>
# Subagents

The fourteen files in `.claude/agents/` are Claude Code **project subagents**. Each is
a Markdown file with YAML frontmatter (`name`, `description`, `tools`, `model`) and a
body that is the subagent's full system prompt. Twelve are single-dimension
**maintainers**: each enforces exactly one quality dimension and nothing else, and
each carries the same accuracy doctrine: confirm every finding against the source,
actively disprove false positives, cross-reference every consumer before changing
anything, and never reduce functionality to satisfy a rule. The remaining two are the
constructive `feature-builder` and the live-API `DevPlace` operator.

## The fleet

These ten maintainers are the orchestrated fleet that `/maintenance` and the `/fleet`
workflow run across every quality dimension, in canonical order.

| Subagent | Dimension |
|----------|-----------|
| `security-maintainer` | Authorization on every route, private-resource gating, read-only file guards, input validation, XSS controls. |
| `audit-maintainer` | Every state-changing action emits an audit record; denials and failures carry the right result; the event catalogue is complete. |
| `devii-maintainer` | The Devii assistant can do everything a role allows over REST, and exposes only the tools that role may call. |
| `docs-maintainer` | The root and nested CLAUDE.md files, README, the API docs, and the prose pages agree with the source, with correct role gating. |
| `fanout-maintainer` | A feature is wired through every layer: form model, output schema, response, Devii tool, API docs, SEO, docs. |
| `dry-maintainer` | Shared helpers are reused instead of duplicated logic re-implemented. |
| `style-maintainer` | Naming, headers, typing, and formatting follow the project rules, applied with context so intentional patterns are left alone. |
| `frontend-maintainer` | ES6 modules, custom components, and CSS follow the project's strict structure. |
| `seo-maintainer` | Public pages carry the right search metadata and appear in the sitemap. |
| `test-maintainer` | Routes without an integration test get one written. It never runs the suite. |

## Additional maintainers

Two more single-dimension maintainers live in `.claude/agents/` but are invoked on
their own rather than as part of the orchestrated fleet run.

| Subagent | Dimension |
|----------|-----------|
| `background-maintainer` | Every non-response-critical side-effect (audit, XP and rewards, notifications, mention and admin fan-out) is deferred through the in-process background queue at the right choke point, response-critical work and cache invalidation stay inline, and external or async work uses a JobService. |
| `locust-maintainer` | Every load-testable route has a weighted `locustfile.py` task, the file compiles clean, and routes that must not be load tested stay excluded. It edits the locustfile but never runs a load test. |

## Non-maintainer agents

The last two subagents are not reviewers.

| Subagent | Role |
|----------|------|
| `feature-builder` | The constructive counterpart to the maintainers: it researches the task, then writes a new feature or extends an existing one across the full fan-out (data layer, server, view, agent, docs, SEO, tests), and reports what must be restarted to go live. |
| `DevPlace` | A live API operator for the DevPlace instance at pravda.education: it reads the running OpenAPI schema and carries out a given task by calling that API, cleaning up its temporary files when done. |

## Modes

Each subagent operates in one of two modes, chosen by how it is invoked.

- **Report** (default): record findings only, change nothing.
- **Fix**: apply a minimal root-cause fix per the doctrine, then re-validate every
  touched file with the per-language checks and confirm the build still imports.

A subagent never runs the test suite and never performs a git write.

## Invoking a subagent

Mention the subagent by its `name`, or let Claude delegate to it automatically based
on its `description`:

```
@agent-security-maintainer check every POST in routers/ has a guard
@agent-docs-maintainer report doc drift in the issues router (report only)
```

Each subagent's `model` is set to `inherit`, so it runs on the model the session is
using. To run the whole fleet at once, use the
[`/maintenance` command or the `/fleet` workflow](#doc-claude-workflows).

---

<a id="doc-claude-commands"></a>
# Commands

The files in `.claude/commands/` are Claude Code **slash commands**: Markdown prompt
templates, invoked as `/name`, that run in the current conversation with the
project's specific knowledge baked in. They are lighter than the
[workflows](#doc-claude-workflows) (single context, no multi-agent fan-out) and
cover the everyday development lifecycle: understand, build, verify, test, and
operate.

Arguments after the command name are available to the prompt; for example
`/explain the issues router` passes "the issues router" as the subject.

## Understand and navigate

| Command | What it does |
|---------|--------------|
| `/explain <area>` | Reads the relevant nested CLAUDE.md and the code, then summarizes the architecture, data flow, invariants, and entry points. Read-only. |
| `/trace <route>` | Traces a route or feature across the nine-layer fan-out and reports where each layer lives and which are missing. Read-only. |

## Verify and test

| Command | What it does |
|---------|--------------|
| `/validate` | Runs the mandatory pre-completion check on changed files: the validator, the app import, and an em-dash scan. Never runs the suite. |
| `/test [tier or path]` | The sanctioned way to run tests: a tier (`unit`, `api`, `e2e`, `all`) or a single `path::test_name`. The subagents and workflows never run tests; this command is how you ask. |

## Build recipes

These are the lighter scaffolds that complement the build
[workflows](#doc-claude-workflows) (`/feature`, `/endpoint`, `/devii-tool`,
`/job-service`).

| Command | What it does |
|---------|--------------|
| `/docs-page <slug> <title>` | Scaffolds a new prose docs page: the template plus the `pages.py` registration, then validates it. |
| `/audit-event <key>` | Adds an audit event end to end: the `events.md` key, the `category_for` mapping, and the recorder call at the mutation. |
| `/service <desc>` | Adds a background `BaseService`: the class with config fields and `run_once`, registration in `main.py`, and docs. |

## Accessibility

| Command | What it does |
|---------|--------------|
| `/aria` | A WCAG 2.2 AA+ accessibility specialist: audits and upgrades the site section by section with semantic HTML and ARIA for screen-reader users, working through the pages and components until the whole site is covered. |

## Operate and verify the UI

These drive the local `rclaude` toolset against a running dev server.

| Command | What it does |
|---------|--------------|
| `/serve` | Starts the dev server in the background and confirms it is healthy on port 10500 (via `mole`). |
| `/screenshot <path>` | Captures a page with Playwright and describes it with `falcon` vision. The required visual check for any UI change. |
| `/api-test <endpoints>` | Writes a `hound` JSON spec and runs it against the server to verify status, body, and headers. |
| `/cli <args>` | Runs the `devplace` management CLI (roles, api keys, news, attachments, Devii quota, zips, forks, containers), with destructive actions confirmed first. |

## Maintenance

| Command | What it does |
|---------|--------------|
| `/maintenance [check\|fix] [changed] [subset]` | Fans the ten [subagents](#doc-claude-agents) out across every quality dimension. Check runs in parallel; fix runs serially in canonical order. |

## A typical loop

A common cycle while building looks like this:

1. `/explain` or `/trace` to orient on the area.
2. A build workflow (`/feature`, `/endpoint`) or build command for the change.
3. `/serve`, then `/screenshot` and `/api-test` to verify it runs and renders.
4. `/validate` for the static checks, then `/test` for the relevant tier.
5. `/review` (a workflow) before committing.

Git writes are intentionally absent from every command, in line with the project
rules; committing stays a manual step.

---

<a id="doc-claude-workflows"></a>
# Workflows

This page documents the deterministic orchestration layer of the
[Claude Code setup](#doc-claude): the workflow scripts that run the
[subagents](#doc-claude-agents) as fixed, reproducible pipelines. For the
lighter, single-context slash commands (including `/maintenance`), see
[Commands](#doc-claude-commands).

## Workflows

The files in `.claude/workflows/` are **deterministic** orchestration scripts. Each
runs as a fixed pipeline of subagents with structured output, in the background, and
is invoked by its file name as a slash command. Run `/workflows` at any time to watch
live progress, pause, or stop a run.

| Command | Type | What it does |
|---------|------|--------------|
| `/fleet` | read-only | Runs all ten dimensions in parallel, then adversarially verifies every finding against the source before reporting it. The high-confidence, low-false-positive audit. |
| `/review` | read-only | Reviews the current `git diff` across every dimension with the same adversarial verification. A pre-commit gate. |
| `/feature` | build | Adds a feature across the full fan-out: understand the area, plan the layers, implement, audit completeness and security, fix gaps, and write tests. |
| `/endpoint` | build | Scaffolds one new route across every touchpoint (form model, output schema, guarded handler, mount, template, Devii action, API docs, SEO) and writes its test. |
| `/devii-tool` | build | Adds a Devii capability with auth flags matched to the route guard, dispatcher wiring, docs, and a test, then verifies role gating and confirmation. |
| `/job-service` | build | Scaffolds an async job service in the zip and fork pattern: the service class, enqueue, status, and download routes, the output schema, Devii tools, the frontend poller, and docs. |

### Read-only versus build workflows

The two read-only workflows (`/fleet`, `/review`) fan subagents out freely, since
nothing is written. The build workflows (`/feature`, `/endpoint`, `/devii-tool`,
`/job-service`) implement coherently in a single pass so that interdependent files
stay in agreement, then fan out again for a parallel audit, then fix gaps. They edit
files directly in the working tree, run the project validator and an import check
when done, and never run the test suite or commit.

### Adversarial verification

The read-only workflows do something the simple `/maintenance` command does not:
each candidate finding is handed to a second, independent subagent instance whose
only task is to **refute** it against the source. A finding is reported only if it
survives that refutation. This directly enforces the project rule that a wrong
finding is worse than a missed one.

### Passing input

Build workflows take a plain description as their argument:

```
/feature add a bookmark button to gists, owner-scoped, with a profile tab
/endpoint POST /gists/{slug}/star to star a gist
/devii-tool a tool to list the current user's bookmarks
/job-service render a project to a PDF and offer it as a download
```

The read-only workflows take no required input; `/review` accepts an optional base
git ref to diff against.

## Where to start

For a routine change, run `/review` before committing. For a new capability, run the
matching build workflow and review its output. For a periodic deep audit, run
`/fleet`. All of them reuse the same ten subagents, so the quality bar is identical
no matter which entry point you choose.

---

<a id="doc-components"></a>
# Components overview

DevPlace ships a set of reusable **custom HTML web components** (custom elements). They
are plain ES6 modules under `static/js/components/`, registered once through
`components/index.js` (imported by `Application.js`, which loads on every page), so each element
is available everywhere, including these documentation pages.

## Conventions

- **Prefix.** Project components use the `dp-` prefix (`dp-avatar`, `dp-code`, `dp-toast`,
  `dp-dialog`, `dp-context-menu`). The assistant ships its own `devii-` elements, and the
  emoji picker is a vendored third-party element.
- **Light DOM.** Components render into the light DOM (no shadow root) so the site's global CSS
  applies to them directly, matching the existing `devii-*` elements.
- **Base class.** Most extend `Component` (`static/js/components/Component.js`), a thin
  `HTMLElement` subclass with attribute helpers (`attr`, `boolAttr`, `intAttr`).
- **Singletons.** Behavioural singletons (`dp-dialog`, `dp-context-menu`, `dp-toast`,
  `dp-lightbox`) are created once by `Application.js` and reachable as `app.dialog`,
  `app.contextMenu`, `app.toast`, and `app.lightbox`.

## Catalog

| Component | Purpose |
|---|---|
| [dp-avatar](#doc-component-dp-avatar) | User avatar image from a username seed. |
| [dp-code](#doc-component-dp-code) | Syntax-highlighted code block with copy button. |
| [dp-content](#doc-component-dp-content) | Render markdown/emoji/media as safe sanitised HTML (client-side; live content only - server content uses the backend `render_content`). |
| [dp-title](#doc-component-dp-title) | Render a single-line title as safe inline emoji/markdown (client-side; live titles only - server titles use the backend `render_title`). |
| [dp-upload](#doc-component-dp-upload) | Clean file upload button with count and removable chips. |
| [dp-toast](#doc-component-dp-toast) | Transient corner notifications. |
| [dp-dialog](#doc-component-dp-dialog) | Promise-based confirm, prompt, and alert modal. |
| [dp-context-menu](#doc-component-dp-context-menu) | Right-click / long-press context menu. |
| [dp-lightbox](#doc-component-dp-lightbox) | Full-screen image viewer opened by clicking a thumbnail. |
| [devii-terminal](#doc-component-devii-terminal) | The Devii assistant terminal window. |
| [devii-avatar](#doc-component-devii-avatar) | The Devii animated avatar character. |
| [emoji-picker](#doc-component-emoji-picker) | Vendored emoji picker element. |

## A note on scope

Components are the self-contained, presentational UI pieces. The rest of the frontend is
plain ES6 modules: page controllers that enhance server-rendered partials (votes,
reactions, comments, polls, which share the `OptimisticAction` base) and small utilities (`Http`
for fetch, `Poller` and `JobPoller` for live and job-status polling, plus DOM helpers). Those are
not custom elements because they operate on server-side markup rather than rendering
standalone UI.

---

<a id="doc-component-dp-avatar"></a>
# dp-avatar

Renders a circular avatar image from a username seed via the local Multiavatar
service at `/avatar/multiavatar/{seed}`. A thin element wrapper around the `Avatar` helper.

Source: `static/js/components/AppAvatar.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `username` | string | (required) | Seed used to generate and label the avatar. |
| `size` | integer | `24` | Width and height in pixels. |

Both attributes are observed: changing them re-renders the avatar.

## Usage

```html
&lt;dp-avatar username="alice_test" size="64"&gt;&lt;/dp-avatar&gt;
```

No JavaScript is required - the element renders itself on connection.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <dp-avatar username="alice_test" size="32"></dp-avatar>
        <dp-avatar username="bob_test" size="48"></dp-avatar>
        <dp-avatar username="devplace" size="64"></dp-avatar>
    </div>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppAvatar.js";
</script>

---

<a id="doc-component-dp-code"></a>
# dp-code

A syntax-highlighted code block with a copy button and optional line numbers, wrapping the
`CodeBlock` helper (highlight.js plus the copy/line-number affordances).

Source: `static/js/components/AppCode.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `language` | string | (auto) | Sets `language-{value}` on the inner `<code>` for highlighting. |
| `line-numbers` | boolean | off | When present, renders a line-number gutter. |

## Usage

Provide a `<pre><code>` child, or plain text and the element wraps it:

```html
&lt;dp-code language="python" line-numbers&gt;
&lt;pre&gt;&lt;code&gt;def hello():
    return "world"&lt;/code&gt;&lt;/pre&gt;
&lt;/dp-code&gt;
```

On connection the element enhances its content once: highlighting, a copy button, and (when
`line-numbers` is set) a gutter.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <dp-code language="javascript" line-numbers><pre><code>export class Greeter {
    greet(name) {
        return `Hello, ${name}`;
    }
}</code></pre></dp-code>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppCode.js";
</script>

---

<a id="doc-component-dp-content"></a>
# dp-content

Renders user-supplied text as safe rich content: emoji shortcodes, GitHub-flavored markdown,
`DOMPurify` sanitisation, then media embeds (images, video, YouTube), mentions, and
autolinks. It exposes the shared `contentRenderer` engine (`static/js/ContentRenderer.js`),
the same pipeline used by the site's `data-render` attribute, as a custom element.

> Server-rendered content is now produced on the **backend** for SEO (the `render_content`
> Jinja global, `devplacepy/rendering.py`, full feature parity built on mistune). This client
> component is retained for **live, client-generated content only** - for example the DeepSearch
> chat answers and the planning report, which do not exist when the page is first rendered. Do not
> use it for content that is known at request time; call `render_content` in the template instead.

Source: `static/js/components/AppContent.js`.

## Behaviour

- On connection the element reads its own text, renders it once, and replaces its contents with
  the sanitised HTML; code blocks are highlighted.
- Sanitisation is fail-closed: rendering throws if `DOMPurify` is unavailable rather than
  emitting unsanitised HTML.
- Equivalent to `data-render` on a server-rendered element, but as a self-contained element you
  drop in directly.
- A copy button appears in the top-right corner on hover (and on focus) that copies the element's
  original markdown source to the clipboard, mirroring the code-block copy button. Add the
  `no-copy` boolean attribute to suppress it.

## Usage

Put markdown (or plain text) as the element's text. Escape any literal HTML you do not want
treated as live markup:

```html
&lt;dp-content&gt;
# Hello
Visit **DevPlace** :rocket: and watch https://youtu.be/dQw4w9WgXcQ
&lt;/dp-content&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <dp-content>## Markdown, rendered live

This is **bold**, this is _italic_, and here is a shortcode :rocket: :fire:.

- a list item
- another with `inline code`
- a mention of @alice_test and a link to https://example.com

```js
const answer = 42;
```
</dp-content>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppContent.js";
</script>

---

<a id="doc-component-dp-title"></a>
# dp-title

Renders a single-line **title** as safe inline rich text: emoji shortcodes, inline
GitHub-flavored markdown (bold, italic, inline code, strikethrough), and `DOMPurify`
sanitisation. It is the title-sized companion to [dp-content](#doc-component-dp-content):
both expose the shared `contentRenderer` engine (`static/js/ContentRenderer.js`), but `dp-title`
calls its `renderInline` path, so it never produces block elements, links, images, or media embeds
and stays on one line.

> Server-rendered titles are now produced on the **backend** for SEO (the `render_title` Jinja
> global, `devplacepy/rendering.py`, same inline behaviour). This client component is retained for
> **live, client-generated** titles only and for future use; do not wrap server-known titles in it.

Source: `static/js/components/AppTitle.js`.

## Behaviour

- On connection the element reads its own text, renders it once, and replaces its contents with
  the sanitised inline HTML.
- The renderer is inline-only: `marked.parseInline` is used (not the block parser), and
  sanitisation restricts output to inline formatting tags (`b`, `strong`, `i`, `em`, `code`,
  `del`, `s`, `mark`, `sub`, `sup`, `span`, `br`). Anchors, images, and block tags are stripped,
  so a `dp-title` placed inside an existing link or heading never produces nested or block markup.
- Sanitisation is fail-closed: rendering throws if `DOMPurify` is unavailable rather than emitting
  unsanitised HTML.
- The element is `display: inline`, so it drops in directly inside an `h1`/`h3`, an anchor, or a
  span without changing layout.
- The server still emits the raw title text as the element's content, so no-JS clients see the
  title as plain text (only the emoji/markdown enhancement is client-side).

## Where it is used

Server-rendered titles (posts, projects, gists, news, issues, profile tabs, listings, saved,
leaderboard, back-links) are rendered on the backend via the `render_title` Jinja global. This
client component is used only for titles created live in the browser and is available for future
client-side use.

## Usage

Put the title text as the element's content. Escape any literal HTML you do not want treated as
live markup:

```html
&lt;h3 class="post-title"&gt;&lt;dp-title&gt;Shipped **v2** today :rocket:&lt;/dp-title&gt;&lt;/h3&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <h3><dp-title>Shipping **dp-title** everywhere :rocket: :fire:</dp-title></h3>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppTitle.js";
</script>

---

<a id="doc-component-dp-upload"></a>
# dp-upload

A tunable file-upload button: a compact button with a selected-count badge and a row of
removable filename chips, no thumbnail grid. It replaces the native `<input type="file">`
and the legacy attachment uploader everywhere in the app.

Source: `static/js/components/AppUpload.js`.

## Modes

Set with the `mode` attribute:

| Mode | Behaviour |
|---|---|
| `attachment` (default) | On select, each file is uploaded to `endpoint` immediately; the returned UIDs are kept in a hidden `<input name="attachment_uids">` that submits with the surrounding form. |
| `direct` | Each file is uploaded to `endpoint` immediately with `field-name` plus any `extraFields`; emits events instead of writing a form field. Used for direct-to-app uploads (e.g. the project file browser). |
| `field` | No AJAX. Wraps a real `<input type="file" name="{field-name}">` whose files submit with the form (e.g. the create-post inline image). |

## Attributes

| Attribute | Default | Description |
|---|---|---|
| `mode` | `attachment` | `attachment`, `direct`, or `field`. |
| `label` | (none) | Optional button text shown beside the icon. |
| `multiple` | off | Allow selecting more than one file. |
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
| `max-size` | `10` | Maximum size per file, in MB. |
| `max-files` | `10` | Maximum number of files. |
| `show-chips` | off | Show the per-file name chips. Off by default, so only the `(N)` count badge appears on the button and no file name is ever shown. |
| `endpoint` | `/uploads/upload` | Upload URL (attachment / direct). |
| `name` | `attachment_uids` | Hidden field name for collected UIDs (attachment mode). |
| `field-name` | `file` | File field name in upload requests / native field mode. |

## Methods and properties

| Member | Description |
|---|---|
| `open()` | Open the file picker programmatically. |
| `clear()` | Remove all selected files. |
| `extraFields` | Object of extra form fields sent with each upload (direct mode), e.g. `{ path }`. |

## Events

`dp-upload:uploaded` (per file, detail `{file, result}`), `dp-upload:error` (detail
`{file, message}`), `dp-upload:done` (direct mode, after a batch), `dp-upload:change`
(detail `{count}`), `dp-upload:busy` (detail `{busy}`, fired once when uploads start
and once when all in-flight uploads finish, for driving a host loader/disabled state).

## Usage

```html
&lt;dp-upload multiple max-size="10" max-files="5" allowed-types=".jpg,.png,.pdf"&gt;&lt;/dp-upload&gt;
&lt;dp-upload mode="field" field-name="image" accept="image/*" label="Add image"&gt;&lt;/dp-upload&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - select files to see the count and chips</div>
    <dp-upload mode="field" field-name="demo" multiple label="Choose files"></dp-upload>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppUpload.js";
</script>

---

<a id="doc-component-dp-toast"></a>
# dp-toast

A transient notification host. It stacks short messages in the bottom-right corner and fades
each one out. `Application.js` creates a single instance, reachable as `app.toast`.

Source: `static/js/components/AppToast.js`.

## Methods

| Method | Description |
|---|---|
| `show(message, options)` | Display `message`. Options: `type` (`info`, `success`, `warning`, `error`; default `info`), `ms` (lifetime in milliseconds, default `3000`), and either `url` (navigate on click) or `onClick` (handler) to make the toast clickable. Returns the toast element. |

## Usage

```html
&lt;dp-toast&gt;&lt;/dp-toast&gt;
```

```javascript
app.toast.show("Saved", { type: "success" });
app.toast.show("Something went wrong", { type: "error", ms: 5000 });
```

The element renders nothing until `show` is called.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-secondary" data-toast="info">Info</button>
        <button type="button" class="btn btn-primary" data-toast="success">Success</button>
        <button type="button" class="btn btn-danger" data-toast="error">Error</button>
    </div>
    <dp-toast id="toast-demo"></dp-toast>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppToast.js";
    const toast = document.getElementById("toast-demo");
    const messages = { info: "Heads up", success: "Saved", error: "Something went wrong" };
    document.querySelectorAll("[data-toast]").forEach((btn) => {
        btn.addEventListener("click", () => {
            const type = btn.getAttribute("data-toast");
            toast.show(messages[type], { type });
        });
    });
</script>

---

<a id="doc-component-dp-dialog"></a>
# dp-dialog

A promise-based modal for confirmations, prompts, and alerts, replacing the browser's blocking
`confirm`/`prompt`/`alert`. `Application.js` creates a single instance reachable as `app.dialog`.

Source: `static/js/components/AppDialog.js`.

## Methods

Each returns a Promise resolving when the user responds.

| Method | Resolves with |
|---|---|
| `confirm(options)` | `true` if confirmed, `false` if cancelled or dismissed. |
| `prompt(options)` | the entered string, or `null` if cancelled. |
| `alert(options)` | `undefined` once acknowledged. |

`options`: `title`, `message`, `confirmLabel`, `cancelLabel`, `danger` (red confirm button),
`links`, and for `prompt`: `label`, `value`, `placeholder`.

`links` is an optional array of `{href, label}` rendered as a row of links between the message and
the buttons, each opening in a new tab so the dialog and the pending action survive the click. Use it
when the user is being asked to agree to something they must be able to read first.

## Usage

```html
&lt;dp-dialog&gt;&lt;/dp-dialog&gt;
```

```javascript
const ok = await app.dialog.confirm({
    title: "Delete post",
    message: "This cannot be undone.",
    danger: true,
});
if (ok) {
    const name = await app.dialog.prompt({ label: "New name", value: "untitled" });
}
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" id="dlg-confirm">Confirm</button>
        <button type="button" class="btn btn-secondary" id="dlg-prompt">Prompt</button>
        <button type="button" class="btn btn-secondary" id="dlg-alert">Alert</button>
        <span id="dlg-result" class="text-muted"></span>
    </div>
    <dp-dialog id="dialog-demo"></dp-dialog>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppDialog.js";
    const dialog = document.getElementById("dialog-demo");
    const result = document.getElementById("dlg-result");
    document.getElementById("dlg-confirm").addEventListener("click", async () => {
        const ok = await dialog.confirm({ title: "Confirm", message: "Proceed with this action?" });
        result.textContent = `confirm returned: ${ok}`;
    });
    document.getElementById("dlg-prompt").addEventListener("click", async () => {
        const value = await dialog.prompt({ title: "Rename", label: "New name", value: "untitled" });
        result.textContent = `prompt returned: ${JSON.stringify(value)}`;
    });
    document.getElementById("dlg-alert").addEventListener("click", async () => {
        await dialog.alert({ title: "Notice", message: "This is an alert." });
        result.textContent = "alert acknowledged";
    });
</script>

---

<a id="doc-component-dp-context-menu"></a>
# dp-context-menu

A custom right-click (and long-press on touch) context menu. A single instance is created by
`Application.js` and reachable as `app.contextMenu`.

Source: `static/js/components/AppContextMenu.js`.

## Methods

| Method | Description |
|---|---|
| `attach(host, builder)` | Bind to an element. `builder(event)` returns the item array to show, or an empty array to suppress. Handles desktop right-click and a 500ms touch long-press. |
| `open(x, y, items)` | Open the menu at viewport coordinates with the given items. |
| `close()` | Hide the menu. |

Each item: `{ label, onSelect, icon?, danger?, disabled?, separator? }`. Use `{ separator: true }`
for a divider. The menu repositions to stay within the viewport and auto-closes on selection,
outside click, scroll, resize, or Escape.

## Usage

```html
&lt;dp-context-menu&gt;&lt;/dp-context-menu&gt;
```

```javascript
app.contextMenu.attach(myElement, (event) => [
    { label: "Open", onSelect: () => open() },
    { separator: true },
    { label: "Delete", danger: true, onSelect: () => remove() },
]);
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - right-click (or long-press) the box</div>
    <div class="component-demo-stage">
        <div id="ctx-target" class="card docs-demo-ctx-target">
            Right-click here
        </div>
        <span id="ctx-result" class="text-muted"></span>
    </div>
    <dp-context-menu id="ctx-menu"></dp-context-menu>
</div>
<script type="module">
    import "/static/v1786510968/js/components/AppContextMenu.js";
    const menu = document.getElementById("ctx-menu");
    const target = document.getElementById("ctx-target");
    const result = document.getElementById("ctx-result");
    menu.attach(target, () => [
        { label: "Open", icon: "\u{1F4C2}", onSelect: () => { result.textContent = "Open selected"; } },
        { label: "Rename", icon: "\u{270F}", onSelect: () => { result.textContent = "Rename selected"; } },
        { separator: true },
        { label: "Delete", icon: "\u{1F5D1}", danger: true, onSelect: () => { result.textContent = "Delete selected"; } },
    ]);
</script>

---

<a id="doc-component-dp-lightbox"></a>
# dp-lightbox

A full-screen image viewer opened by clicking a thumbnail. `Application.js` creates a single
instance, reachable as `app.lightbox`. Every image marked with the `data-lightbox` attribute
opens in the same overlay, so thumbnails behave identically across the site.

Source: `static/js/components/AppLightbox.js`.

## The `data-lightbox` contract

Mark any thumbnail with `data-lightbox` to make it clickable. The viewer shows the value of
`data-full` when present (a distinct full-resolution URL), otherwise the image's own source. The
image `alt` is reused as the caption.

```html
&lt;img src="thumb.jpg" data-lightbox data-full="full.jpg" alt="A diagram"&gt;
```

Images rendered from markdown content (`data-render`) are marked automatically, so embedded
images are clickable with no extra markup.

## Methods

| Method | Behaviour |
|---|---|
| `open(src, { alt })` | Open the overlay showing `src`, with optional caption `alt`. |
| `close()` | Close the overlay. |

The overlay also closes on the close button, a click outside the image, or the `Escape` key.

```javascript
app.lightbox.open("/static/img/diagram.png", { alt: "Architecture" });
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <img src="/avatar/multiavatar/devplace?size=96" width="96" height="96" data-lightbox data-full="/avatar/multiavatar/devplace?size=512" alt="Sample thumbnail" class="docs-demo-thumb">
        <span class="text-muted">Click the thumbnail to open the lightbox.</span>
    </div>

---

<a id="doc-component-devii-terminal"></a>
# devii-terminal

The draggable terminal window for the Devii assistant. It connects to `/devii/ws`, renders
markdown replies, keeps input history, and supports closed, normal, maximized, and fullscreen
states with geometry persisted to `localStorage`. The `DeviiTerminal` controller (`app.devii`)
creates it and appends it to the page, so you do not place it by hand.

It extends the shared `FloatingWindow` base (`static/js/components/FloatingWindow.js`), reusing the
container terminals' drag, resize, geometry-persistence, and window chrome, and adds its own
launcher button, closed state, and socket/markdown/history.

Source: `static/js/devii/devii-terminal.js`.

## Attributes

| Attribute | Type | Description |
|---|---|---|
| `open` | boolean | Open the terminal on connection. |
| `avatar` | string | Id or selector of an associated `devii-avatar` element. |

## Methods and properties

| Member | Description |
|---|---|
| `open()` / `close()` / `toggle()` | Control visibility. |
| `state` | `closed`, `normal`, `maximized`, or `fullscreen`. |
| `geometry` | `{ width, height, left, top }` window placement. |

## Usage

Any element marked `data-devii-open` opens the terminal (the controller delegates these clicks),
so you rarely instantiate it directly:

```html
&lt;button data-devii-open&gt;Ask Devii&lt;/button&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" data-devii-open>Open Devii terminal</button>
    </div>

---

<a id="doc-component-devii-avatar"></a>
# devii-avatar

The animated assistant avatar (a vendored md-clippy character). It is a controller element: it
renders no markup of its own but drives the on-screen character, which can speak, animate, and move.
The `DeviiTerminal` controller creates one as `#devii` and links it to the terminal.

Source: `static/js/devii/devii-avatar.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `character` | string | `Clippy` | Which character to display. |
| `tts` | boolean | off | Enable text-to-speech. |
| `autoshow` | boolean | off | Show the character on connection. |

## Methods

| Method | Description |
|---|---|
| `execute(action, args)` | Run an avatar action: `show`, `hide`, `speak`, `play_animation`, `random_animation`, `move_to`, `gesture_at`, `stop`, `switch_character`, `list_animations`, `list_characters`, `get_viewport`. |
| `ask(text)` | Send a prompt to the connected assistant. |

## Usage

```html
&lt;devii-avatar character="Clippy" autoshow&gt;&lt;/devii-avatar&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - shows the avatar and plays an animation</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" id="avatar-demo-btn">Show avatar</button>
        <span id="avatar-demo-result" class="text-muted"></span>
    </div>
</div>
<script type="module">
    const result = document.getElementById("avatar-demo-result");
    document.getElementById("avatar-demo-btn").addEventListener("click", async () => {
        const avatar = document.querySelector("devii-avatar");
        if (!avatar || typeof avatar.execute !== "function") {
            result.textContent = "Avatar is not available on this page.";
            return;
        }
        try {
            await avatar.execute("show");
            await avatar.execute("random_animation");
            result.textContent = "Played a random animation.";
        } catch (error) {
            result.textContent = "Avatar could not start.";
        }
    });
</script>

---

<a id="doc-component-emoji-picker"></a>
# emoji-picker

A vendored third-party emoji picker (`emoji-picker-element`) with category browsing, search, and
skin-tone selection. `EmojiPickerElement` (`static/js/EmojiPickerElement.js`) is the single wrapper
that lazily loads the vendor module and builds a configured element; two consumers use it. The
`EmojiPicker` wrapper inserts the chosen emoji at the cursor of textareas marked
`.emoji-picker-target`, and the reaction bar opens it from the `+` button next to the quick-pick
palette so any emoji can be used as a reaction.

Source: `static/vendor/emoji-picker-element/`.

## Attributes

| Attribute | Description |
|---|---|
| `locale` | Language for emoji labels (for example `en`). |
| `skin-tone-emoji` | Default skin-tone emoji. |
| `emoji-version` | Restrict to a Unicode emoji version. |

## Events

| Event | Detail |
|---|---|
| `emoji-click` | `{ unicode, emoji, ... }` for the chosen emoji (primary event). |
| `skin-tone-change` | The selected skin tone. |

## Usage

```html
&lt;emoji-picker&gt;&lt;/emoji-picker&gt;
```

```javascript
picker.addEventListener("emoji-click", (event) => {
    insert(event.detail.unicode);
});
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - pick an emoji</div>
    <div class="component-demo-stage">
        <emoji-picker></emoji-picker>
        <span id="emoji-demo-result" class="text-muted">No emoji selected yet.</span>
    </div>
</div>
<script type="module">
    import "/static/v1786510968/vendor/emoji-picker-element/index.js";
    const result = document.getElementById("emoji-demo-result");
    document.querySelector(".component-demo emoji-picker").addEventListener("emoji-click", (event) => {
        result.textContent = `Selected: ${event.detail.unicode}`;
    });
</script>

---

<a id="doc-styles"></a>
# Design system

DevPlace has one visual language: hand-written vanilla CSS driven by design tokens, with no preprocessor, no CSS-in-JS, and no utility framework. This section is the canonical reference for how the interface looks and is structured, and states the rules every page must follow.

> The feed / posts page is the reference implementation. When two pages disagree, the feed page wins and the other gets fixed. The pages below describe the system; the [Consistency rules](#doc-styles-consistency) page turns it into hard requirements.

## The four pages

| Page | What it covers |
|------|----------------|
| [Colors](#doc-styles-colors) | The palette, every token and what it is for, what not to do, and the vision behind a dark, single-accent theme. |
| [Layout](#doc-styles-layout) | The page shell, the canonical multi-column and single-column layouts, and which compositions are acceptable. |
| [Responsiveness](#doc-styles-responsiveness) | The goal, the standard breakpoint ladder, and the exact techniques used to reach it. |
| [Consistency rules](#doc-styles-consistency) | Hard, non-negotiable structural rules for the header, footer, breadcrumb, and content root, taken from the posts page. |

## Principles

- **Tokens over literals.** Every colour, space, radius, and shadow is a CSS custom property defined once in `variables.css`. Stylesheets reference the token, never a raw hex or pixel value.
- **One layout per page.** A page has a single top-level layout container inside the shared content root; it never nests competing wrappers or re-declares the maximum width.
- **Mobile is not an afterthought.** Multi-column layouts collapse to one fluid column, touch targets grow, and nothing scrolls horizontally by accident.
- **Structure is shared, content is per page.** The header, breadcrumb, content root, and footer come from `base.html`. A page supplies only its own content and its own stylesheet.

---

<a id="doc-styles-colors"></a>
# Colors

The palette is defined once, as CSS custom properties on `:root` in `static/css/variables.css`. Every stylesheet references those tokens; no file hardcodes a hex value, so changing the theme means editing one file.

## The vision

DevPlace is kept open for hours, often at night. The theme is therefore **dark, low-glare, and deliberately quiet**: deep violet-black backgrounds, soft lavender text, and a *single* warm accent.

- **One accent, used sparingly.** A single burnt orange (`--accent`, `#b73f1e`) marks what matters: the primary action, the active navigation item, links, and the current vote. Because nothing else competes for it, the accent always means "act on this". A second strong colour used decoratively breaks it.
- **Backgrounds layer by elevation.** A near-black page sits behind slightly lighter cards, behind a lighter hover state. Depth comes from these few background steps plus a soft shadow, not from borders alone.
- **Text is a three-step hierarchy.** Primary for content, secondary for supporting text, muted for metadata. There is no fourth shade.
- **Colour with meaning is reserved.** Status colours (success, warning, danger, info) and topic colours carry semantics. They never "brighten up" a layout.

## Background and surface

Layered from the page backdrop up to interactive surfaces.

| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--bg-primary` | `#271b5b` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
| `--bg-secondary` | `#1a1736` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
| `--bg-card` | `#13112a` | Cards, panels, the standard content surface. | Use for the page backdrop. |
| `--bg-card-hover` | `#1a1736` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
| `--bg-input` | `#1a1736` | Inputs, textareas, selects. | Use as a content card surface. |
| `--bg-modal` | `#13112a` | Modal card body. | Use outside modals. |
| `--bg-gradient` | violet gradient | Large hero / landing surfaces only. | Apply to small components. |

## Accent

| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--accent` | `#b73f1e` | The single primary action, active nav link, links, the voted star, focus border. | A second decorative colour, large fills, or more than one primary action per view. |
| `--accent-hover` | `#af3050` | Hover state of accent surfaces and links. | Resting state. |
| `--accent-light` | `rgba(255,107,53,.12)` | The tinted background behind an *active* nav item or self-highlight row. | Body text (too low contrast). |
| `--accent-rgb` | `255, 107, 53` | Composing any other accent tint: `rgba(var(--accent-rgb), 0.3)`. | Writing `rgba(255, 107, 53, ...)` literally anywhere. |

## Text

A strict three-step hierarchy. Never introduce a fourth text shade.

| Token | Value | Use it for |
|-------|-------|-----------|
| `--text-primary` | `#e9e4ff` | Headings and body content. |
| `--text-secondary` | `#c8c3e5` | Supporting copy, labels, secondary buttons. |
| `--text-muted` | `#9b93c9` | Timestamps, counts, hints, metadata. |

## Borders, radius, and shadow

| Token | Value | Use it for |
|-------|-------|-----------|
| `--border` | `#11101f` | The default hairline on cards, inputs, dividers. |
| `--border-light` | `#2f2a55` | Hover/emphasis borders. |
| `--radius` | `8px` | Buttons, inputs, small controls. |
| `--radius-lg` | `12px` | Cards and panels. |
| `--radius-xl` | `16px` | Large feature surfaces. |
| `--shadow-sm` / `--shadow` / `--shadow-lg` | (shadows) | Card hover, raised panels, modals respectively. |

## Status colours (semantic only)

These encode meaning and must only be used to convey it.

| Token | Value | Means |
|-------|-------|-------|
| `--success` | `#34d399` | Success, healthy, online. |
| `--warning` | `#fbbf24` | Caution; also the colour of a cast vote star. |
| `--danger` | `#f87171` | Errors, destructive actions, the create-post button. |
| `--info` | `#60a5fa` | Neutral information. |

## Topic colours (badges only)

Used exclusively for topic badges (`.badge-devlog`, `.badge-showcase`, …) and the matching sidebar accents. Do not reuse them as general UI colours.

| Token | Value | Topic |
|-------|-------|-------|
| `--topic-devlog` | `#7c4dff` | Devlog |
| `--topic-showcase` | `#00bfa5` | Showcase |
| `--topic-question` | `#448aff` | Question |
| `--topic-rant` | `#ff5252` | Rant |
| `--topic-fun` | `#ffab00` | Fun |
| `--topic-politics` | `#00bcd4` | Politics |

## Rules

1. **Never hardcode a colour.** Use the token: `color: var(--text-secondary)`, never `color: #c8c3e5`. If a value is missing, add a token to `variables.css` rather than inlining a hex. Accent tints at other alphas compose the channel token: `rgba(var(--accent-rgb), 0.3)`.
2. **Never give a global token a fallback.** Write `var(--accent)`, never `var(--accent, #hex)`. A fallback silently masks a dead or misspelled token: the page keeps rendering, but from a value nobody maintains.
3. **Feature palettes are file-scoped token blocks.** A page with its own semantic palette (the Devii terminal, the AI-usage grades) defines it once as custom properties at the top of its own stylesheet (`devii.css` scopes `--devii-*` on `devii-terminal`; `isslop.css` scopes `--isslop-*` on `:root`) and references only those below. Raw hex values may exist in exactly two places: `variables.css` and these file-scoped blocks.
4. **One accent per view.** Exactly one primary action should carry `--accent`. Everything else is `--btn-secondary`/ghost.
5. **Status and topic colours are semantic.** Do not use `--danger` for a non-destructive button or a topic colour as a background flourish. Status is always `--success`/`--warning`/`--danger`/`--info`; never repurpose a topic token for a status.
6. **Respect the text hierarchy.** Three shades only: primary, secondary, muted.
7. **Surfaces step up, never sideways.** Page → card → card-hover. Do not place a card on `--bg-primary` without a surface token, and do not use `--bg-secondary` as a content card.

The reference below is rendered live from the tokens, so it always reflects the current theme.

</div>

<style>
.sg-swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem; }
.sg-swatch { display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
.sg-chip { width: 40px; height: 40px; border-radius: var(--radius); border: 1px solid var(--border-light); flex-shrink: 0; }
.sg-swatch-info { display: flex; flex-direction: column; min-width: 0; }
.sg-swatch-name { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-primary); }
.sg-swatch-hex { font-size: 0.6875rem; color: var(--text-muted); }
.sg-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.sg-row-spaced { margin-top: 0.75rem; }
.sg-note { font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.5rem; }
.sg-chip-bg-primary { background: var(--bg-primary); }
.sg-chip-bg-secondary { background: var(--bg-secondary); }
.sg-chip-bg-card { background: var(--bg-card); }
.sg-chip-bg-card-hover { background: var(--bg-card-hover); }
.sg-chip-accent { background: var(--accent); }
.sg-chip-accent-hover { background: var(--accent-hover); }
.sg-chip-text-primary { background: var(--text-primary); }
.sg-chip-text-secondary { background: var(--text-secondary); }
.sg-chip-text-muted { background: var(--text-muted); }
.sg-chip-border { background: var(--border); }
.sg-chip-success { background: var(--success); }
.sg-chip-warning { background: var(--warning); }
.sg-chip-danger { background: var(--danger); }
.sg-chip-info { background: var(--info); }
</style>

<div class="component-demo">
    <div class="component-demo-title">Live palette</div>
    <div class="sg-swatch-grid">
        <div class="sg-swatch"><span class="sg-chip sg-chip-bg-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#271b5b</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-bg-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#1a1736</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-bg-card"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#13112a</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-bg-card-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#1a1736</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-accent"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent</span><span class="sg-swatch-hex">#b73f1e</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-accent-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#af3050</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-text-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#e9e4ff</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-text-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-secondary</span><span class="sg-swatch-hex">#c8c3e5</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-text-muted"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-muted</span><span class="sg-swatch-hex">#9b93c9</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-border"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--border</span><span class="sg-swatch-hex">rgba(255,255,255,.08)</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-success"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--success</span><span class="sg-swatch-hex">#34d399</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-warning"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--warning</span><span class="sg-swatch-hex">#fbbf24</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-danger"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--danger</span><span class="sg-swatch-hex">#f87171</span></span></div>
        <div class="sg-swatch"><span class="sg-chip sg-chip-info"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--info</span><span class="sg-swatch-hex">#60a5fa</span></span></div>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">Topic badges (the only place topic colours appear)</div>
    <div class="sg-row">
        <span class="badge badge-devlog">Devlog</span>
        <span class="badge badge-showcase">Showcase</span>
        <span class="badge badge-question">Question</span>
        <span class="badge badge-rant">Rant</span>
        <span class="badge badge-fun">Fun</span>
        <span class="badge badge-politics">Politics</span>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">One accent per view: correct vs misuse</div>
    <div class="sg-row">
        <button type="button" class="btn btn-primary">Primary action</button>
        <button type="button" class="btn btn-secondary">Secondary</button>
        <button type="button" class="btn btn-ghost">Ghost</button>
    </div>
    <p class="sg-note">Correct: a single accent-filled primary action, with supporting actions de-emphasised.</p>
    <div class="sg-row sg-row-spaced">
        <button type="button" class="btn btn-primary">Save</button>
        <button type="button" class="btn btn-primary">Publish</button>
        <button type="button" class="btn btn-primary">Share</button>
    </div>
    <p class="sg-note">Misuse: three accent buttons compete, so none reads as the primary action.</p>

---

<a id="doc-styles-layout"></a>
# Layout

Layout uses CSS Grid and Flexbox: one shared page shell with a small set of approved content layouts inside it. New pages pick one of these rather than inventing a structure.

## The page shell

Every page is wrapped by `base.html` in the same four-part shell, from top to bottom:

1. **Fixed top nav** (`.topnav`, height `--nav-height` = `56px`, `position: fixed`, `z-index: var(--z-nav)`). Always on screen, never re-implemented per page.
2. **Breadcrumb** (`.breadcrumb`, optional but expected). It renders only when the page supplies two or more crumbs. Clearing the fixed nav is handled once by `body { padding-top: var(--nav-height) }`, so every page sits below the header whether or not it has a breadcrumb. See [Consistency rules](#doc-styles-consistency).
3. **Content root** (`<main class="page">`, `max-width: var(--max-content)` = `1200px`, centred, `padding: 1rem`). All page content lives inside this one element.
4. **Footer** (`.site-footer`), rendered from the `footer` block.

```text
+-----------------------------------------------+  .topnav (fixed, 56px)
|  Dev[Place]   Home Posts News ...   bell user |
+-----------------------------------------------+
|  Home / Feed                                  |  .breadcrumb (clears the nav)
|  +-----------------------------------------+  |
|  |              <main class="page">        |  |  max 1200px, centred
|  |   [ one approved layout goes here ]     |  |
|  +-----------------------------------------+  |
|                  site footer                   |
+-----------------------------------------------+
```

A page never re-declares `max-width: 1200px`, never adds a second centred wrapper, and never rebuilds the nav or footer. It supplies exactly one layout container inside `.page`.

## Approved layouts

### 1. Three-column app layout (the default)

The feed / posts page: a fixed-width left sidebar, a fluid main column, and a fixed-width right rail.

```css
.feed-layout {
    display: grid;
    grid-template-columns: var(--sidebar-width) 1fr 280px; /* 240px | fluid | 280px */
    gap: 1.5rem;
    align-items: start;
}
@media (max-width: 1024px) {
    .feed-layout { grid-template-columns: 1fr; } /* collapse to one column */
    .feed-right  { display: none; }              /* drop the right rail */
}
```

```text
&lt;div class="feed-layout"&gt;
  &lt;aside class="sidebar-card"&gt; ... &lt;/aside&gt;   left: navigation/filters (240px)
  &lt;div class="feed-main"&gt; ... &lt;/div&gt;          centre: primary content (fluid)
  &lt;aside class="feed-right"&gt; ... &lt;/aside&gt;     right: stats/context (280px, sticky)
&lt;/div&gt;
```

Rules for this layout: the two side columns are `<aside>`; the centre column must set `min-width: 0` so long content (code, URLs) cannot blow out the grid; the right rail is `position: sticky; top: calc(var(--nav-height) + 1rem)`; at `1024px` it collapses to a single fluid column and the right rail is hidden.

### 2. Three-column with two equal rails

The leaderboard uses `280px 1fr 280px` with both `<aside>` columns sticky. Same collapse behaviour at `1024px`.

### 3. Single-column list

A vertical stack of cards in a flex column, used inside the main column or on its own:

```css
.feed-posts { display: flex; flex-direction: column; gap: 1rem; }
```

### 4. Card grid

Auto-filling responsive grid for galleries such as projects:

```css
.projects-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 1rem;
}
```

## Cards

A card is the standard content surface: `--bg-card` background, a `--border` hairline, `--radius-lg` corners, and `1rem` padding. The shared `.card` class encodes that; `.post-card` / `.project-card` use the same recipe. On hover a card lifts its border to `--border-light` and adds `--shadow-sm`, without changing its background.

## Spacing

Use the spacing scale, not arbitrary values. Card padding is `1rem`; the gap between cards is `1rem`; the gap between layout columns is `1.5rem`.

| Token | Value |
|-------|-------|
| `--space-xs` | `0.25rem` |
| `--space-sm` | `0.5rem` |
| `--space-md` | `0.75rem` |
| `--space-lg` | `1rem` |
| `--space-xl` | `1.5rem` |
| `--space-2xl` | `2rem` |

## Layering (z-index)

Every `z-index` is a token from `variables.css`; a literal z-index value is forbidden. There are two bands:

| Band | Tokens (bottom to top) | Purpose |
|------|------------------------|---------|
| Content | `--z-fab` (900), `--z-nav-overlay` (998), `--z-nav-panel` (999), `--z-nav` (1000), `--z-nav-drop` (1001), `--z-modal` (2000), `--z-popover` (2100), `--z-lightbox` (10000) | Normal page chrome: FAB, mobile nav, top nav, dropdowns, modals, autocomplete popovers, the image lightbox. |
| Chrome (user-CSS-proof) | `--z-chrome-fw`, `--z-chrome-devii`, `--z-chrome-toast`, `--z-chrome-menu`, `--z-chrome-dialog` | Floating windows, the Devii terminal, toasts, the context menu, and confirm dialogs. These sit in the `2147480000+` range so per-user injected CSS can never stack above them, and each element is also layer-promoted with `will-change: transform`. |

A new stacked element picks the token matching its role; if none fits, add a token between two existing stops in `variables.css` rather than writing a number at the use site. Local ladders inside an already-stacked component (small `z-index: 1` steps inside a card) stay literal.

## Acceptable and not

- **Acceptable:** one layout container inside `.page`; CSS Grid for columns; `<aside>` for side columns; `min-width: 0` on the fluid column; `minmax(0, 1fr)` on card-grid tracks; sticky rails; cards built from tokens.
- **Not acceptable:** a second `max-width`/centred wrapper inside `.page`; absolute positioning to fake columns; fixed pixel heights on content; a side column that does not collapse on small screens; a main column without `min-width: 0`, or a card grid on a bare `1fr` track (both overflow on long code lines - the track's automatic minimum is the item's min-content width, and a code block never wraps).

</div>

<style>
.sg-layout { display: grid; grid-template-columns: 90px 1fr 110px; gap: 0.75rem; align-items: start; }
.sg-box { border: 1px solid var(--border-light); border-radius: var(--radius); background: var(--bg-card); padding: 0.625rem; color: var(--text-secondary); font-size: 0.75rem; text-align: center; }
.sg-box-main { min-height: 96px; display: flex; align-items: center; justify-content: center; }
.sg-box-accent { border-color: var(--accent); color: var(--accent); }
.sg-stack { display: flex; flex-direction: column; gap: 0.625rem; }
.sg-tag { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-muted); display: block; margin-top: 0.25rem; }
@media (max-width: 600px) {
    .sg-layout { grid-template-columns: 1fr; }
    .sg-box-right { display: none; }
}
</style>

<div class="component-demo">
    <div class="component-demo-title">Three-column app layout (resize the window below 600px to see it collapse, mirroring the real 1024px rule)</div>
    <div class="sg-layout">
        <div class="sg-box">Sidebar<span class="sg-tag">&lt;aside&gt; 240px</span></div>
        <div class="sg-box sg-box-main sg-box-accent">Main content<span class="sg-tag">1fr, min-width: 0</span></div>
        <div class="sg-box sg-box-right">Right rail<span class="sg-tag">280px, sticky</span></div>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">Single-column card list</div>
    <div class="sg-stack">
        <div class="card">A card. <code>--bg-card</code>, <code>--border</code>, <code>--radius-lg</code>, padding <code>1rem</code>.</div>
        <div class="card">Another card. Stacked with a <code>1rem</code> gap.</div>
        <div class="card">A third card.</div>
    </div>

---

<a id="doc-styles-responsiveness"></a>
# Responsiveness

The interface works from a 320px phone to a wide desktop without a separate mobile site. The same HTML reflows; only the CSS changes per breakpoint.

## The goal

- **One readable column on small screens.** Multi-column layouts collapse to a single fluid column; secondary rails are dropped, not squeezed.
- **No accidental horizontal scroll.** Long content (code, URLs, tables) is contained, never pushing the page wider than the viewport.
- **Comfortable touch targets.** On touch devices every interactive element is at least `44 x 44px`.
- **No iOS zoom-on-focus.** Form controls are at least `16px` on touch devices so Safari does not zoom when they are focused.
- **Respect device safe areas.** Floating controls keep clear of notches and home indicators with `env(safe-area-inset-*)`.

The viewport is declared once in `base.html` and must not be changed:

```html
&lt;meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"&gt;
```

## The standard breakpoint ladder

Use these four breakpoints. They are `max-width` (desktop-first refinements) and the ones the shared shell already targets, so a page that uses them stays in step with the nav, breadcrumb, and footer.

| Breakpoint | What changes |
|-----------|--------------|
| `1024px` | Multi-column layouts collapse to one column; the right rail is hidden; the top nav links collapse into the hamburger menu; the page padding tightens. |
| `768px` | Horizontal tab strips (feed tabs, project tabs) switch to a single scrolling row; labels shrink. |
| `480px` | Compact paddings; secondary actions hidden; post/detail typography steps down; `grid-2col` becomes one column; floating buttons honour safe-area insets. |
| `360px` | Headers allowed to wrap; timestamps drop to their own line; the comment avatar is hidden so the input keeps room. |

> The ladder is exact and enforced: every stylesheet uses only `1024 / 768 / 480 / 360` (plus the capability queries below). The one-off breakpoints that used to exist (900, 720, 640, 600, 560, 520) were consolidated by snapping each up to the next canonical stop. **Do not add new ad-hoc breakpoints**; if a component genuinely breaks between two stops, raise it in `static/css/CLAUDE.md` first.

## How it is achieved

**Collapse a grid to one column.** The whole mobile story for a multi-column page:

```css
@media (max-width: 1024px) {
    .feed-layout { grid-template-columns: 1fr; }
    .feed-right  { display: none; }
}
```

**Keep the fluid column from overflowing.** A grid/flex child does not shrink below its content unless told to. Every fluid main column sets:

```css
.feed-main, .leaderboard-main { min-width: 0; }
```

**Let tab strips scroll instead of wrapping or overflowing:**

```css
@media (max-width: 768px) {
    .feed-nav { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
    .feed-nav-btn { flex-shrink: 0; }
}
```

**Guarantee touch targets and prevent zoom.** One shared rule in `base.css` covers every page, so pages do not repeat it:

```css
@media (hover: none) and (pointer: coarse) {
    .btn, .topnav-link, .sidebar-link, .post-action-btn, .profile-tab { min-height: 44px; }
    .topnav-icon, .emoji-toggle-btn { min-width: 44px; min-height: 44px; }
    .modal-card input, .modal-card textarea, .modal-card select { font-size: 16px; }
}
```

**Keep floating controls off the notch / home bar:**

```css
@media (max-width: 480px) {
    .feed-fab {
        bottom: calc(1rem + env(safe-area-inset-bottom));
        right:  calc(1rem + env(safe-area-inset-right));
    }
}
```

**Respect reduced motion, globally.** One rule at the end of `base.css` collapses every animation and transition to a near-zero duration under `prefers-reduced-motion: reduce` (near-zero rather than `none`, so JavaScript `transitionend`/`animationend` handlers still fire). Pages never need their own reduced-motion query; a page-level one is only for content that must *also* change visually (for example stopping an indeterminate progress bar).

## Rules

1. **Use the four standard breakpoints** (`1024 / 768 / 480 / 360`); do not invent new ones without a written reason.
2. **Every fluid grid/flex column sets `min-width: 0`.** This single line prevents almost all horizontal-overflow issues.
3. **Collapse, do not cram.** Below `1024px` a page is one fluid column; drop side rails with `display: none` rather than shrinking them to slivers.
4. **Never override the viewport meta or the shared touch-target rule.** They are global; rely on them.
5. **Test the small end.** The hard cases are `360px` (wrapping) and long unbroken strings inside a card.

</div>

<style>
.sg-resp { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.625rem; }
.sg-resp .sg-cell { border: 1px solid var(--border-light); border-radius: var(--radius); background: var(--bg-card); padding: 0.75rem; font-size: 0.8125rem; color: var(--text-secondary); text-align: center; }
.sg-resp-note { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.625rem; }
@media (max-width: 768px) {
    .sg-resp { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
    .sg-resp { grid-template-columns: 1fr; }
}
</style>

<div class="component-demo">
    <div class="component-demo-title">Live reflow (three columns to two to one as the viewport narrows)</div>
    <div class="sg-resp">
        <div class="sg-cell">Item one</div>
        <div class="sg-cell">Item two</div>
        <div class="sg-cell">Item three</div>
        <div class="sg-cell">Item four</div>
        <div class="sg-cell">Item five</div>
        <div class="sg-cell">Item six</div>
    </div>
    <p class="sg-resp-note">This grid uses the standard 768px and 480px breakpoints. Narrow the browser to watch it step from three columns to two to one.</p>

---

<a id="doc-styles-consistency"></a>
# Consistency rules

These are hard, structural rules, not suggestions. They are derived from the **posts / feed page**, the reference implementation and the most structurally and visually correct page in the application. Other pages are refactored to match it; new pages follow these rules from the start.

The header, breadcrumb, content root, and footer are *shared infrastructure*. A page supplies its content and its stylesheet; it never rebuilds the frame.

## The reference skeleton

This is the posts page reduced to its structure. A content page looks like this and nothing else:

```html
{% extends "base.html" %}

{% block extra_head %}
&lt;link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}"&gt;
{% endblock %}

{% block content %}
&lt;h1 class="sr-only"&gt;Feed&lt;/h1&gt;

&lt;div class="feed-layout"&gt;            {# exactly one layout container #}
    &lt;aside class="sidebar-card"&gt; ... &lt;/aside&gt;
    &lt;div class="feed-main"&gt; ... &lt;/div&gt;
    &lt;aside class="feed-right"&gt; ... &lt;/aside&gt;
&lt;/div&gt;
{% endblock %}
```

The header, breadcrumb, `<main class="page">` wrapper, and footer are all supplied by `base.html`. The template above never appears to contain them, and never should.

## Hard rules

### 1. Extend the base template

Every page is `{% extends "base.html" %}`. Page CSS is added only through `{% block extra_head %}`; page JS only through `{% block extra_js %}`. A page never includes its own `<html>`, `<head>`, or `<body>`, and never links `variables.css` / `base.css` / `components.css` (the base already does).

### 2. The header is shared, never re-implemented

The top nav is `base.html`'s `.topnav`. Pages do not build their own header or navigation bar; the active link is derived from the request path inside `base.html`. A page-level toolbar (tabs, filters) lives *inside the content*, like the feed's `.feed-nav` strip, below the breadcrumb, never as a second site header.

### 3. Breadcrumbs are mandatory (this is the most-broken rule)

A page **must** pass at least two breadcrumbs (Home plus the current page) through its SEO context:

```python
seo_ctx = list_page_seo(
    request,
    title="Projects",
    breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Projects", "url": "/projects"}],
)
```

This is primarily for SEO and orientation. Clearing the fixed top nav is handled once, globally, by `body { padding-top: var(--nav-height) }`, so a page is never hidden behind the header whether or not it has a breadcrumb. Still supply two or more crumbs on any nested page for navigation and breadcrumb structured data.

### 4. All content lives in the single content root

`base.html` already wraps the `content` block in `<main class="page">` (`max-width: 1200px`, centred, `padding: 1rem`). Therefore a page:

- puts everything inside one top-level layout container (a grid like `.feed-layout`, or a single column);
- never adds a second centred / `max-width` wrapper inside `.page`;
- never re-declares the maximum content width.

See [Layout](#doc-styles-layout) for the approved layout containers.

### 5. Multi-column structure is grid, and it collapses

Side columns are `<aside>`; the fluid centre column sets `min-width: 0`. The grid collapses to a single fluid column at `1024px` and the right rail is hidden. See [Responsiveness](#doc-styles-responsiveness).

### 6. One H1 per page

Each page has exactly one `<h1>`. When the heading is visually redundant (the feed's title), keep it for accessibility with `class="sr-only"` rather than removing it.

### 7. The footer is shared

The footer is `base.html`'s `.site-footer`, rendered from the `footer` block. Pages do not build their own footer. Override the `footer` block only with a deliberate reason (for example, a full-bleed tool page) and document why.

### 8. Cards and surfaces use tokens

Content sits on cards built from tokens: `--bg-card`, a `--border` hairline, `--radius-lg`, `1rem` padding (the shared `.card` recipe). Colours, spacing, radii, shadows, and z-indexes are always tokens, never literals: global tokens are referenced bare (`var(--accent)`, never `var(--accent, #hex)`), accent tints compose `rgba(var(--accent-rgb), α)`, and stacking uses the `--z-*` scale. See [Colors](#doc-styles-colors) and [Layout](#doc-styles-layout).

## Page author checklist

- [ ] `{% extends "base.html" %}`; CSS via `extra_head`, JS via `extra_js`.
- [ ] Two or more breadcrumbs passed in the SEO context (content clears the nav).
- [ ] Exactly one layout container inside `.page`; no second `max-width` wrapper.
- [ ] Multi-column = grid with `<aside>` rails, fluid column has `min-width: 0`, card-grid tracks are `minmax(0, 1fr)`, collapses at `1024px`.
- [ ] One `<h1>` (use `.sr-only` if visually redundant).
- [ ] No custom header or footer; both come from `base.html`.
- [ ] Only tokens for colour, spacing, radius, shadow, and z-index; no `var()` fallback values.
- [ ] Standard breakpoints only (`1024 / 768 / 480 / 360`).
- [ ] No page-level `prefers-reduced-motion` query unless the page must change content, not just motion (the global `base.css` rule already covers motion).

</div>

<style>
.sg-anatomy { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.sg-anatomy-row { padding: 0.625rem 0.875rem; font-size: 0.8125rem; border-bottom: 1px solid var(--border); }
.sg-anatomy-row:last-child { border-bottom: none; }
.sg-anatomy-nav { background: var(--bg-secondary); color: var(--text-primary); font-weight: 600; }
.sg-anatomy-crumb { background: var(--bg-primary); color: var(--text-muted); font-size: 0.75rem; }
.sg-anatomy-main { background: var(--bg-card); color: var(--text-secondary); padding-top: 1rem; padding-bottom: 1rem; }
.sg-anatomy-foot { background: var(--bg-primary); color: var(--text-muted); text-align: center; font-size: 0.75rem; }
.sg-anatomy code { font-size: 0.75rem; color: var(--accent); }
.sg-anatomy-tag { float: right; font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-muted); }
.sg-anatomy-brand-accent { color: var(--accent); }
</style>

<div class="component-demo">
    <div class="component-demo-title">Anatomy of a page (what base.html owns vs what the page supplies)</div>
    <div class="sg-anatomy">
        <div class="sg-anatomy-row sg-anatomy-nav">Dev<span class="sg-anatomy-brand-accent">Place</span> &middot; Home Posts News Gists Projects<span class="sg-anatomy-tag">base.html: .topnav (fixed)</span></div>
        <div class="sg-anatomy-row sg-anatomy-crumb">Home / Projects<span class="sg-anatomy-tag">base.html: .breadcrumb (clears the nav)</span></div>
        <div class="sg-anatomy-row sg-anatomy-main">Your content, inside one layout container.<span class="sg-anatomy-tag">page: block content, inside .page</span></div>
        <div class="sg-anatomy-row sg-anatomy-foot">DevPlace &middot; footer links<span class="sg-anatomy-tag">base.html: .site-footer</span></div>
    </div>

---

<a id="doc-authentication"></a>
# Authentication

DevPlace accepts four interchangeable authentication methods. The website uses the
session cookie; the other three authenticate **any request** - any page or action -
without a browser login, ideal for scripts and automation. See
[Conventions & Errors](#doc-conventions) for the base URL, request bodies,
content negotiation, pagination, and status codes that every endpoint shares.


> You are not logged in. Examples below use placeholders. [Log in](/auth/login) and
> reload this page to see ready-to-copy examples with your own API key.


Your API key is a UUID shown on your [profile page](/profile/YOUR_USERNAME).
You can regenerate it there at any time; the previous key stops working immediately.

The interactive panels throughout the [API reference](#doc-index) pre-fill your API
key, so you can run user-level calls directly from these pages. Vote, reaction, bookmark, and
poll endpoints require an `X-Requested-With: fetch` header to return JSON instead of a
redirect; the panels and generated snippets add it automatically.

## 1. Session cookie

The website signs you in with a `session` cookie after you log in. Browsers send it
automatically - nothing to configure.

## 2. X-API-KEY header

Send your API key in the `X-API-KEY` header:

```bash
curl -H "X-API-KEY: YOUR_API_KEY" \
  https://devplace.net/notifications
```

## 3. Bearer token

The same API key also works as a Bearer token, supported out of the box by many HTTP clients:

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://devplace.net/notifications
```

## 4. HTTP Basic

Authenticate with your username (or email) and password. `curl -u` base64-encodes
the credentials for you:

```bash
curl -u YOUR_USERNAME:YOUR_PASSWORD \
  https://devplace.net/notifications
```

This sends an `Authorization: Basic base64(username:password)` header. You can also
build the header yourself:

```bash
curl -H "Authorization: Basic $(printf '%s' 'YOUR_USERNAME:YOUR_PASSWORD' | base64)" \
  https://devplace.net/notifications
```

## 5. Access token (native)

DevPlace's own token endpoint issues short-lived access tokens that work as
Bearer / `X-API-KEY` credentials on every endpoint. Request a token once, use
it everywhere until it expires.

```bash
curl -X POST https://devplace.net/auth/token \
  -H "Content-Type: application/json" \
  -d '{"email": "YOUR_EMAIL", "password": "YOUR_PASSWORD"}'
```

The response is an OAuth2-style JSON object:

```json
{
  "access_token": "<64-char hex token>",
  "token_type": "bearer",
  "expires_in": 604800
}
```

Use the returned `access_token` as a Bearer token:

```bash
TOKEN="<access_token from above>"
curl -H "Authorization: Bearer $TOKEN" \
  https://devplace.net/notifications
```

Or with the `X-API-KEY` header:

```bash
curl -H "X-API-KEY: $TOKEN" \
  https://devplace.net/notifications
```

Tokens are valid for the configured session lifetime (default 7 days).  Manage
tokens with the CLI: `devplace token issue <user>`, `devplace token list <user>`,
`devplace token revoke <uid>`, `devplace token revoke-all <user>`.

## 6. DevRant auth token

If you have a DevRant auth token (the `key` field from the `POST /users/auth-token`
response), you can also use it as a Bearer token or `X-API-KEY` header on **any**
DevPlace API endpoint. The token must not be expired (default 7 days). See
[DevRant authentication](#doc-devrant-auth) for how to obtain one.

```bash
curl -H "Authorization: Bearer YOUR_DEVRANT_TOKEN_KEY" \
  https://devplace.net/notifications
```

Or with the `X-API-KEY` header:

```bash
curl -H "X-API-KEY: YOUR_DEVRANT_TOKEN_KEY" \
  https://devplace.net/notifications
```

## Performing actions

The same methods work on POST actions. For example, follow another user:

```bash
curl -X POST -H "X-API-KEY: YOUR_API_KEY" \
  https://devplace.net/follow/SOME_USERNAME
```

Create a post:

```bash
curl -X POST -H "X-API-KEY: YOUR_API_KEY" \
  -d "topic=devlog" -d "title=Hello" -d "content=Posted from a script" \
  https://devplace.net/posts/create
```

With a DevRant auth token:

```bash
curl -X POST -H "Authorization: Bearer YOUR_DEVRANT_TOKEN_KEY" \
  -d "topic=devlog" -d "title=Hello" -d "content=Posted from a script" \
  https://devplace.net/posts/create
```

## Errors

Invalid credentials make protected endpoints respond with `401 Unauthorized`. Requests
with no credentials are treated as anonymous, and protected actions redirect to the login
page as in the browser. The full status-code and error-shape reference lives in
[Conventions & Errors](#doc-conventions).

---

<a id="doc-xmlrpc"></a>
# XML-RPC API

DevPlace exposes its **entire REST API over XML-RPC** at `https://devplace.net/xmlrpc`. A standalone
forking XML-RPC server generates one method per documented endpoint straight from the API
reference, so every capability available over HTTP is callable over XML-RPC with no extra
work. Combined with the standard-library `xmlrpc.client`, this makes DevPlace fully
automatable from a few lines of Python: post, comment, vote, react, message, follow, read the
feed, and watch your notifications.

This page is a complete, copy-paste guide: the calling convention, all three authentication
methods, a reusable client wrapper, error handling, pagination, ready-made automation recipes,
and a full **bot that replies to mentions**. See also [Authentication](#doc-authentication)
and [Conventions & Errors](#doc-conventions).

## Requirements

Nothing to install. `xmlrpc.client` is part of the Python standard library:

```python
import xmlrpc.client
```

Always create the proxy with `allow_none=True` so `null` values round-trip:

```python
proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)
```

## Endpoint

Point any XML-RPC client at:

```
https://devplace.net/xmlrpc
```

The app reverse-proxies this path to the forking XML-RPC server (in production nginx forwards
`/xmlrpc` the same way). A plain `GET https://devplace.net/xmlrpc` returns a short usage banner.

## Method names

Every endpoint becomes a method named after its API id with dots. A few you will use often:

| Method | REST route | Auth |
|--------|-----------|------|
| `feed.list` | `GET /feed` | public |
| `posts.detail` | `GET /posts/{post_slug}` | public |
| `posts.create` | `POST /posts/create` | user |
| `comments.create` | `POST /comments/create` | user |
| `votes.cast` | `POST /votes/{target_type}/{target_uid}` | user |
| `reactions.toggle` | `POST /reactions/{target_type}/{target_uid}` | user |
| `notifications.list` | `GET /notifications` | user |
| `notifications.mark.read` | `POST /notifications/mark-read/{notification_uid}` | user |
| `messages.send` | `POST /messages/send` | user |
| `follow.user` | `POST /follow/{username}` | user |
| `profile.detail` | `GET /profile/{username}` | public |

Discover the rest with introspection (below) - the list is generated from the same source as
this documentation, so it is always current.

## Calling convention

Each method takes **one struct** (a dictionary) of named parameters. The XML-RPC API routes each
key to the path, query string, or form body using the API reference, substitutes path
parameters such as `{username}`, and returns the same JSON payload the REST endpoint would,
decoded into a native Python value (`dict`, `list`, `str`, `int`, `float`, `bool`, or `None`).

```python
import xmlrpc.client

proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)

# A public read - no credentials needed
feed = proxy.feed.list({})
for item in feed["posts"]:
    print(item["author"]["username"], "-", item["post"]["title"])

# An authenticated write - pass api_key inside the struct
result = proxy.posts.create({"content": "Hello from XML-RPC", "api_key": "YOUR_API_KEY"})
print(result["ok"], result["redirect"])
```

Pass an empty struct (`{}`) for methods that take no parameters. The dotted method name can
also be built dynamically with `getattr`, which is handy for generic wrappers:

```python
getattr(proxy, "notifications.list")({"api_key": "YOUR_API_KEY"})
```

## Authentication

Authenticate in any of three interchangeable ways. Public endpoints need none.

### 1. API key inside the struct

The simplest method - works with a vanilla `ServerProxy`, no custom transport:

```python
proxy.posts.create({"content": "posted with an API key", "api_key": "YOUR_API_KEY"})
```

Your API key is shown on your [profile page](/profile/YOUR_USERNAME); you can regenerate it there at any time.

### 2. API key as an HTTP header

Send `X-API-KEY` (or `Authorization: Bearer`) on the transport, exactly like the REST API.
Use this to keep credentials out of the call arguments. A small custom transport adds the
header to every request:

```python
import xmlrpc.client

class ApiKeyTransport(xmlrpc.client.SafeTransport):  # SafeTransport = HTTPS
    def __init__(self, api_key):
        super().__init__()
        self._api_key = api_key

    def send_headers(self, connection, headers):
        super().send_headers(connection, headers)
        connection.putheader("X-API-KEY", self._api_key)

proxy = xmlrpc.client.ServerProxy(
    "https://devplace.net/xmlrpc", transport=ApiKeyTransport("YOUR_API_KEY"), allow_none=True
)
proxy.posts.create({"content": "posted with a header API key"})
```

Use `xmlrpc.client.Transport` instead of `SafeTransport` for a plain `http://` endpoint.

### 3. Username and password in the URL (HTTP Basic)

Put your **username (or email) and password** in the address. The XML-RPC API forwards the Basic
credentials to the normal login, so no API key is needed:

```python
proxy = xmlrpc.client.ServerProxy(
    "https://YOUR_USERNAME:YOUR_PASSWORD@devplace.net/xmlrpc", allow_none=True
)
proxy.posts.create({"content": "authenticated by username and password"})
```

> Always use `https://` in production so the API key or password is encrypted in transit.

## Return values

- **Reads** return the page payload as a struct - the same shape the JSON API returns. For
  example `feed.list` returns `{"posts": [...], "next_cursor": "...", ...}`, where each post is
  `{"post": {...}, "author": {...}, "comment_count": 3, ...}`.
- **Writes** return the action envelope `{"ok": true, "redirect": "/posts/...", "data": {...}}`.
  `data` carries the created resource where applicable.

## Error handling

Any REST error surfaces as an `xmlrpc.client.Fault`. The `faultCode` carries the HTTP status
code and `faultString` carries the message:

| `faultCode` | Meaning |
|-------------|---------|
| `401` | Missing or invalid credentials |
| `403` | Authenticated but not allowed (for example a non-admin calling an admin method) |
| `404` | Target not found |
| `422` | Validation failed (a field is missing, too short, or invalid) |
| `5xx` | Server error |
| `-32001` | A required parameter was missing (raised before the call is made) |

```python
import xmlrpc.client

try:
    proxy.posts.create({"content": "x"})            # no credentials
except xmlrpc.client.Fault as fault:
    print("failed:", fault.faultCode, fault.faultString)
```

## Introspection

The XML-RPC API supports the full XML-RPC introspection set, so a client can discover and document
every method at runtime:

```python
proxy.system.listMethods()                  # every available method name
proxy.system.methodHelp("posts.create")     # title, route, auth, and parameter list
proxy.system.methodSignature("posts.create")
```

`methodHelp` returns the endpoint summary, the HTTP method and path, the required role, and
every parameter with its location and type. Print the help for everything:

```python
proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)
for name in sorted(proxy.system.listMethods()):
    print("###", name)
    print(proxy.system.methodHelp(name))
    print()
```

## Batching with multicall

`system.multicall` batches several calls into a single HTTP round trip:

```python
batch = xmlrpc.client.MultiCall(proxy)
batch.feed.list({})
batch.profile.detail({"username": "YOUR_USERNAME"})
batch.notifications.counts({})
feed, profile, counts = tuple(batch())
```

## A reusable client wrapper

This small helper injects your credentials, supports all three auth styles, and lets you call
methods by dotted name. Drop it into any project:

```python
import xmlrpc.client
from urllib.parse import quote, urlsplit, urlunsplit


class DevPlace:
    def __init__(self, base_url, api_key=None, username=None, password=None):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        endpoint = self.base_url + "/xmlrpc"
        if username and password:
            parts = urlsplit(self.base_url)
            creds = f"{quote(username, safe='')}:{quote(password, safe='')}"
            endpoint = urlunsplit((parts.scheme, f"{creds}@{parts.netloc}", "/xmlrpc", "", ""))
        self.proxy = xmlrpc.client.ServerProxy(endpoint, allow_none=True)

    def call(self, method, **params):
        if self.api_key and "api_key" not in params:
            params["api_key"] = self.api_key
        return getattr(self.proxy, method)(params)


# usage
dp = DevPlace("https://devplace.net", api_key="YOUR_API_KEY")
dp.call("posts.create", content="posted through the wrapper")
dp.call("feed.list")
```

## Pagination

List endpoints page with an opaque cursor. Pass the `next_cursor` from one page as `before` on
the next, until it comes back empty:

```python
def all_feed_posts(dp):
    cursor = None
    while True:
        page = dp.call("feed.list", before=cursor) if cursor else dp.call("feed.list")
        for item in page["posts"]:
            yield item
        cursor = page.get("next_cursor")
        if not cursor:
            return
```

The same `before`/`next_cursor` pattern works for `posts`, `gists`, `news`, `notifications.list`,
and bookmarks.

## Automation recipes

All of these assume the `DevPlace` wrapper above as `dp`.

```python
# Create a post (content is required; title and topic are optional)
post = dp.call("posts.create", content="Shipping the XML-RPC API today.", title="Release")
post_slug = post["data"]["slug"]

# Reply to a post with a comment (target_type defaults to "post")
detail = dp.call("posts.detail", post_slug=post_slug)
dp.call("comments.create", content="Nice work!", target_uid=detail["post"]["uid"])

# Upvote a post (value 1 to upvote, -1 to downvote)
dp.call("votes.cast", target_type="post", target_uid=detail["post"]["uid"], value="1")

# React with an emoji
dp.call("reactions.toggle", target_type="post", target_uid=detail["post"]["uid"], emoji="rocket")

# Follow a user
dp.call("follow.user", username="alice")

# Send a direct message (receiver_uid is the recipient's user uid)
dp.call("messages.send", content="Hi there", receiver_uid="USER_UID")

# Read your unread notifications
notifications = dp.call("notifications.list")
```

## Full example: a bot that replies to mentions

When someone writes `@yourname` in a post or comment, DevPlace creates a **mention**
notification whose `target_url` points at the post. This bot polls notifications, replies once
to each new mention, and marks it read so it is never processed twice. Save it as
`mention_bot.py`, set your credentials, and run it.

```python
#!/usr/bin/env python3
"""A DevPlace bot that replies to every @mention."""

import re
import time
import xmlrpc.client

URL = "https://devplace.net"
API_KEY = "YOUR_API_KEY"
POLL_SECONDS = 30
REPLY = "Thanks for the mention! This is an automated reply via the XML-RPC API."

POST_SLUG = re.compile(r"/posts/([^/#?]+)")


class DevPlace:
    def __init__(self, base_url, api_key):
        self.api_key = api_key
        self.proxy = xmlrpc.client.ServerProxy(base_url.rstrip("/") + "/xmlrpc", allow_none=True)

    def call(self, method, **params):
        params["api_key"] = self.api_key
        return getattr(self.proxy, method)(params)


def unread_mentions(dp):
    result = dp.call("notifications.list")
    for group in result.get("notification_groups", []):
        for entry in group.get("entries", []):
            note = entry["notification"]
            if note.get("type") == "mention" and not note.get("read"):
                yield note


def reply_to_mention(dp, note):
    match = POST_SLUG.search(note.get("target_url") or "")
    if not match:
        return
    slug = match.group(1)
    detail = dp.call("posts.detail", post_slug=slug)
    dp.call("comments.create", content=REPLY, target_uid=detail["post"]["uid"], target_type="post")


def run():
    dp = DevPlace(URL, API_KEY)
    print("Mention bot started; polling every", POLL_SECONDS, "seconds.")
    while True:
        try:
            for note in unread_mentions(dp):
                try:
                    reply_to_mention(dp, note)
                    print("Replied to mention:", note.get("message"))
                except xmlrpc.client.Fault as fault:
                    print("Could not reply:", fault.faultCode, fault.faultString)
                finally:
                    dp.call("notifications.mark.read", notification_uid=note["uid"])
        except xmlrpc.client.Fault as fault:
            print("Poll error:", fault.faultCode, fault.faultString)
        except OSError as error:
            print("Network error, retrying:", error)
        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    run()
```

How it works:

1. `notifications.list` returns notifications grouped for display; the bot flattens them and
   keeps the unread ones of type `mention`.
2. The mention's `target_url` is the post link (for example `/posts/abc-my-post` or
   `/posts/abc-my-post#comment-...`). A regex pulls the slug out of either form.
3. `posts.detail` resolves the slug to the post `uid`, and `comments.create` posts the reply.
4. `notifications.mark.read` is called in a `finally` block, so a mention is marked read even
   if the reply failed - the bot never loops on the same item.

The bot is intentionally resilient: a `Fault` on one mention is logged and skipped, and network
errors are retried on the next poll.

### Running it continuously

- **Quick start:** `python mention_bot.py` (it loops forever).
- **cron** is not ideal for a long-running loop; for a scheduled one-shot pass, remove the
  `while True` loop and run the body once, then schedule it with `cron`.
- **systemd** (recommended for a daemon): create a unit that runs `python mention_bot.py` with
  `Restart=always`, so it survives reboots and crashes.

```ini
[Unit]
Description=DevPlace mention bot
After=network-online.target

[Service]
ExecStart=/usr/bin/python3 /opt/devplace-bot/mention_bot.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

## Direct messages

DevPlace has a full private-messaging API, which makes it ideal for interactive bots and chat
clients. Two methods do everything:

- `messages.inbox` (`GET /messages`) - with no parameters it returns your `conversations`
  (each `{other_user, last_message, last_message_at, unread}`); with `with_uid` set to another
  user's uid it returns that thread's `messages` (each `{message, sender, is_mine, time_ago}`)
  **and marks those messages read**.
- `messages.send` (`POST /messages/send`) - send `content` to a `receiver_uid`.

Resolve a username to a uid with `profile.detail`:

```python
uid = dp.call("profile.detail", username="alice")["profile_user"]["uid"]
dp.call("messages.send", content="Hi Alice", receiver_uid=uid)

# read the conversation (this marks it read)
thread = dp.call("messages.inbox", with_uid=uid)
for item in thread["messages"]:
    who = "me" if item["is_mine"] else item["sender"]["username"]
    print(who, ":", item["message"]["content"])
```

## Full example: a chat bot over direct messages

This bot listens for incoming DMs and answers commands - a live, interactive assistant your
users reach by messaging it. It polls for **unread** conversations, opens each thread (which
marks it read), reads the latest incoming message, and replies. The `respond` function is the
only thing you change to give the bot a personality or new commands; here it serves `help`,
`feed`, `echo`, and `whoami`, pulling live data from DevPlace over the same XML-RPC API.

```python
#!/usr/bin/env python3
"""A DevPlace chat bot that answers direct messages."""

import os
import time
import xmlrpc.client

URL = os.environ.get("DEVPLACE_URL", "https://devplace.net")
API_KEY = os.environ.get("DEVPLACE_API_KEY", "YOUR_API_KEY")
POLL_SECONDS = 10


class DevPlace:
    def __init__(self, base_url, api_key):
        self.api_key = api_key
        self.proxy = xmlrpc.client.ServerProxy(base_url.rstrip("/") + "/xmlrpc", allow_none=True)

    def call(self, method, **params):
        params["api_key"] = self.api_key
        return getattr(self.proxy, method)(params)


def respond(dp, text, sender):
    text = (text or "").strip()
    command = text.lower()
    name = sender.get("username", "there")
    if command in ("hi", "hello", "hey", "help", "/help"):
        return f"Hi @{name}! Try: 'feed' for the latest posts, 'echo your message', or 'whoami'."
    if command == "whoami":
        return f"You are @{name} (uid {sender.get('uid')})."
    if command == "feed":
        posts = dp.call("feed.list").get("posts", [])[:3]
        lines = [f"- {p['post'].get('title') or p['post']['uid']} by @{p['author']['username']}" for p in posts]
        return "Latest posts:\n" + "\n".join(lines) if lines else "The feed is empty."
    if command.startswith("echo "):
        return text[5:]
    return "I did not understand that. Send 'help' for commands."


def run():
    dp = DevPlace(URL, API_KEY)
    answered = set()
    print("Chat bot started.")
    while True:
        try:
            for conversation in dp.call("messages.inbox").get("conversations", []):
                if not conversation.get("unread"):
                    continue
                other = conversation.get("other_user") or {}
                if not other.get("uid"):
                    continue
                thread = dp.call("messages.inbox", with_uid=other["uid"])  # marks read
                incoming = [m for m in thread.get("messages", []) if not m.get("is_mine")]
                if not incoming:
                    continue
                last = incoming[-1]
                if last["message"]["uid"] in answered:
                    continue
                answered.add(last["message"]["uid"])
                reply = respond(dp, last["message"]["content"], other)
                dp.call("messages.send", content=reply, receiver_uid=other["uid"])
                print("Replied to @" + other.get("username", "?"))
        except xmlrpc.client.Fault as fault:
            print("error:", fault.faultCode, fault.faultString)
        except OSError as error:
            print("network error, retrying:", error)
        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    run()
```

How it works:

1. `messages.inbox` (no args) lists conversations; the bot only opens ones flagged `unread`.
2. Opening a thread with `with_uid` returns the messages and **marks them read**, so the same
   message is never answered twice (an extra `answered` set guards against re-polling within a
   single run).
3. `is_mine` distinguishes the bot's own messages from the user's; the bot replies to the last
   incoming one.
4. `respond` can call any other XML-RPC API method (here `feed.list`) to answer with live data, or
   you can route the text to an LLM to make it a true AI assistant.

To run it forever, the same systemd unit shown for the mention bot works - just point
`ExecStart` at `chatbot.py`.

## Interactive terminal chat client

For a two-way chat from your terminal, run a background poll loop that prints incoming messages
while the main loop reads what you type and sends it. The full version is
`examples/xmlrpc/dm_client.py`; the core is:

```python
import threading, time, xmlrpc.client

dp = ...  # the DevPlace wrapper
peer_uid = dp.call("profile.detail", username="alice")["profile_user"]["uid"]

def receive(stop, seen=set(), primed=[False]):
    while not stop.is_set():
        for item in dp.call("messages.inbox", with_uid=peer_uid).get("messages", []):
            mid = item["message"]["uid"]
            if mid in seen:
                continue
            seen.add(mid)
            if primed[0] and not item["is_mine"]:
                print(f"\n{item['sender']['username']}> {item['message']['content']}\nyou> ", end="")
        primed[0] = True
        time.sleep(3)

stop = threading.Event()
threading.Thread(target=receive, args=(stop,), daemon=True).start()
while True:
    line = input("you> ")
    if line.strip():
        dp.call("messages.send", content=line, receiver_uid=peer_uid)
```

## More recipes

These build on the same `dp` wrapper. Each is shipped as a complete, runnable script in
`examples/xmlrpc/`.

### Deploy a local folder as a project

Create a project and push every text file into its virtual filesystem (`project.files.write`
creates parent directories automatically):

```python
from pathlib import Path

slug = dp.call("projects.create", title="My App", description="Deployed via XML-RPC",
               project_type="software")["data"]["slug"]
root = Path("./my-app")
for path in root.rglob("*"):
    if path.is_file():
        dp.call("project.files.write", project_slug=slug,
                path=path.relative_to(root).as_posix(), content=path.read_text())
```

### Back up your gists to disk

`gists.list` already includes each gist's `source_code`, so one paginated pass is enough:

```python
cursor = None
while True:
    page = dp.call("gists.list", before=cursor) if cursor else dp.call("gists.list")
    for item in page["gists"]:
        gist = item["gist"]
        open(gist["slug"] + ".txt", "w").write(gist.get("source_code") or "")
    cursor = page.get("next_cursor")
    if not cursor:
        break
```

### Mirror an RSS feed into posts

Parse a feed with the standard library and create a post per new entry:

```python
import urllib.request, xml.etree.ElementTree as ET

root = ET.fromstring(urllib.request.urlopen("https://example.com/feed.xml").read())
for node in root.iter():
    if node.tag.split("}")[-1] == "item":
        title = node.findtext("title") or ""
        link = node.findtext("link") or ""
        dp.call("posts.create", content=f"{title}\n\n{link}", title=title[:120], topic="devlog")
```

### Build and post a digest

Combine several reads into one summary and publish it:

```python
posts = dp.call("feed.list")["posts"][:5]
leaders = dp.call("leaderboard")["entries"][:5]
news = dp.call("news.list")["articles"][:5]
digest = "# Digest\n" + "\n".join(f"- {p['post'].get('title')}" for p in posts)
dp.call("posts.create", content=digest, title="Daily digest", topic="devlog")
```

### Watch the feed live and auto-upvote

Poll the feed, print new posts, and upvote ones matching your keywords:

```python
import time

seen, keywords = set(), ["rust", "python"]
while True:
    for item in dp.call("feed.list")["posts"]:
        post = item["post"]
        if post["uid"] in seen:
            continue
        seen.add(post["uid"])
        text = f"{post.get('title') or ''} {post.get('content') or ''}".lower()
        if any(word in text for word in keywords):
            dp.call("votes.cast", target_type="post", target_uid=post["uid"], value="1")
    time.sleep(20)
```

### Export all your data

```python
me = "alice"
export = {
    "posts": dp.call("profile.detail", username=me, tab="posts")["posts"],
    "gists": dp.call("profile.detail", username=me, tab="gists")["gists"],
    "projects": dp.call("profile.detail", username=me, tab="projects")["projects"],
    "bookmarks": dp.call("bookmarks.saved")["items"],
}
```

### An AI assistant in your DMs

Read direct messages over XML-RPC and answer with the DevPlace AI gateway (OpenAI-compatible,
authenticated with the same API key). The `ai_chatbot.py` example wires this into the DM loop;
the core call is:

```python
import json, urllib.request

def ask_ai(base_url, api_key, messages):
    body = json.dumps({"model": "molodetz", "messages": messages}).encode()
    request = urllib.request.Request(
        f"{base_url}/openai/v1/chat/completions", data=body,
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.loads(response.read())["choices"][0]["message"]["content"]
```

## Best practices

- **Use HTTPS** so credentials are never sent in clear text.
- **Be polite to the rate limiter.** Mutating calls are rate limited per account; a 30-60s
  poll interval and a single reply per event keep you well within limits. A burst of writes can
  raise a `429` fault - back off and retry.
- **Mark notifications read** after handling them so you do not reprocess events.
- **Handle `Fault` per item**, not per batch, so one bad event does not stop the loop.
- **Keep replies useful**, and avoid `@`-mentioning people in automated replies unless you
  intend to notify them, to prevent notification noise.

## Runnable examples

Complete, runnable versions of everything on this page live in the repository under
`examples/xmlrpc/`, all configured with environment variables and needing nothing beyond the
Python standard library:

- `client.py` - the reusable `DevPlace` wrapper (all three auth styles, dotted calls, introspection)
- `introspect.py` - prints every method with its help text
- `recipes.py` - posting, replying, voting, reacting, messaging, following, pagination
- `mention_bot.py` - replies to every `@mention`
- `chatbot.py` - command-driven direct-message bot
- `ai_chatbot.py` - AI-powered DM bot (gateway-backed replies)
- `dm_client.py` - two-way terminal chat
- `feed_watch.py` - live feed ticker with keyword auto-upvote
- `digest.py` - builds and posts a digest
- `gist_backup.py` - downloads all your gists
- `deploy_project.py` - pushes a local folder into a project
- `rss_to_posts.py` - mirrors an RSS feed into posts
- `export_data.py` - exports your account data to JSON

---

<a id="doc-devrant"></a>
# devRant API overview

DevPlace exposes a second REST protocol under `https://devplace.net/api` that reproduces the public
[devRant](https://devrant.com) API shape on DevPlace data, so legacy devRant clients can run
against this server unchanged. Rants are DevPlace posts, comments and votes are the native
engagement layer, and every devRant action is funnelled through the same audited helpers as the
website (so XP, notifications, and soft-delete all apply).

This reference is split across focused pages:

- [Authentication & accounts](#doc-devrant-auth) - login, registration, the token triple.
- [Rants](#doc-devrant-rants) - feed, single rant, create, edit, delete, vote, favorite, search.
- [Comments](#doc-devrant-comments) - read, post, edit, delete, vote.
- [Users & avatars](#doc-devrant-users) - profiles, username lookup, profile edit, avatars.
- [Notifications](#doc-devrant-notifications) - the notification feed.
- [Client scripts](#doc-devrant-clients) - ready-to-run Python and JavaScript clients.

## Base URL and shape

Every endpoint lives under `https://devplace.net/api` and returns JSON with a `success` boolean. On
success the payload sits beside it; on a logical failure the response is
`{ "success": false, "error": "..." }`. HTTP status is `200` for logical failures, except a bad
login which returns `400` (matching devRant). Requests accept parameters as query string
(`GET`/`DELETE`) or as a form body or JSON body (`POST`).

## Integer IDs

devRant identifies everything by integer. DevPlace maps those directly onto the auto-increment
`id` that every table already carries, so `rant_id` is a post's `id`, `comment_id` is a comment's
`id`, and `user_id` is a user's `id`. There is no separate id space to track.

## Authentication model

Write operations need the devRant token triple. `POST /api/users/auth-token` with a username (or
email) and password returns an `auth_token` object; you then send `user_id`, `token_id`, and
`token_key` with every request. Read endpoints (feed, single rant, search, profiles) work without
authentication. See [Authentication & accounts](#doc-devrant-auth).

## Field mapping at a glance

| devRant concept | DevPlace mapping |
|-----------------|------------------|
| `rant` | post (topic forced to `rant`; `text` is `title` + body when a title exists) |
| `tags` | stored verbatim on the post and returned as-is (falls back to `[topic]`) |
| `comment` | comment with `target_type = post` |
| `vote` (`1`/`-1`/`0`) | upvote / downvote / clear on the native vote layer |
| `favorite` / `unfavorite` | bookmark add / remove |
| `user_avatar` | a real PNG rendered from the username (see [Users & avatars](#doc-devrant-users)) |
| `profile.skills` | derived from the user bio (DevPlace has no separate skills field) |

## Availability

The protocol is toggled by the `devrant_api_enabled` site setting (default on); when off, every
`/api` path returns `404`. Legacy clients hard-coded to `devrant.com` reach this server only
through host routing (DNS / reverse-proxy), which is an infrastructure concern.

---

<a id="doc-devrant-auth"></a>
# devRant API: Authentication & accounts

Write operations authenticate with the devRant token triple. Log in once below and every
authenticated widget across these pages becomes runnable (the token is kept in your browser
only). Read operations ([rants](#doc-devrant-rants),
[profiles](#doc-devrant-users)) need no authentication.

`POST /api/users/auth-token` returns an `auth_token` whose `id` is the `token_id`, `key` is the
`token_key`, and `user_id` is the integer user id; those three are sent automatically by these
widgets (query params for `GET`/`DELETE`, form body for `POST`). A bad login returns HTTP `400`.
See the [overview](#doc-devrant) for the response envelope.

A DevRant auth token (the `key` field) also works on the **main DevPlace API**: use it as a
Bearer token or `X-API-KEY` header on any DevPlace endpoint. See
[Authentication](#doc-authentication) for details.

DevPlace also has its own native token endpoint at `POST /auth/token` - see the
[Authentication](#doc-authentication) page.
</div>

<div class="devrant-login-bar" data-devrant-login></div>

<section class="endpoint-card" id="devrant-login">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/users/auth-token</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Log in</h3>
    <p class="endpoint-summary">Authenticate with username (or email) and password. Running this here logs you in for every widget on these pages.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "form", "id": "devrant-login", "method": "POST", "min_role": "Public", "params": [{"description": "Username or email.", "example": "YOUR_USERNAME", "location": "body", "name": "username", "required": true, "type": "string"}, {"description": "Account password.", "example": "", "location": "body", "name": "password", "required": true, "type": "password"}], "path": "/api/users/auth-token", "sample_response": {"auth_token": {"expire_time": 1782024794, "id": 18966518, "key": "z6uXRZrQ...", "user_id": 42}, "success": true}, "summary": "Authenticate with username (or email) and password. Running this here logs you in for every widget on these pages.", "title": "Log in"}'></div>
</section>

<section class="endpoint-card" id="devrant-register">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/users</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Register</h3>
    <p class="endpoint-summary">Create a new account. Returns an auth token (you are logged in immediately).</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "form", "id": "devrant-register", "method": "POST", "min_role": "Public", "params": [{"description": "3-32 letters, numbers, hyphens, underscores.", "example": "newdev", "location": "body", "name": "username", "required": true, "type": "string"}, {"description": "Valid email address.", "example": "newdev@example.com", "location": "body", "name": "email", "required": true, "type": "string"}, {"description": "At least 6 characters.", "example": "", "location": "body", "name": "password", "required": true, "type": "password"}], "path": "/api/users", "sample_response": {"auth_token": {"expire_time": 1782024794, "id": 18966518, "key": "z6uXRZrQ...", "user_id": 42}, "success": true}, "summary": "Create a new account. Returns an auth token (you are logged in immediately).", "title": "Register"}'></div>
</section>

<section class="endpoint-card" id="devrant-delete-account">
    <header class="endpoint-head">
        <span class="method-badge method-delete">DELETE</span>
        <code class="endpoint-path">/api/users/me</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Deactivate account</h3>
    <p class="endpoint-summary">Deactivate the logged-in account and revoke its tokens.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": true, "encoding": "none", "id": "devrant-delete-account", "method": "DELETE", "min_role": "Member", "params": [], "path": "/api/users/me", "sample_response": {"success": true}, "summary": "Deactivate the logged-in account and revoke its tokens.", "title": "Deactivate account"}'></div>
</section>

---

<a id="doc-devrant-rants"></a>
# devRant API: Rants

A rant is a DevPlace post. Reads are public; writes need the token triple - log in with the bar
below (or on the [Authentication](#doc-devrant-auth) page) and run any widget live. The
token triple is injected automatically. `text` is the post's title and body combined; devRant
`tags` round-trip verbatim. Posting a comment lives here too; editing and voting on a comment is
on the [Comments](#doc-devrant-comments) page.
</div>

<div class="devrant-login-bar" data-devrant-login></div>

<section class="endpoint-card" id="devrant-feed">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/devrant/rants</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Rant feed</h3>
    <p class="endpoint-summary">List rants. Sort by recent, top, or algo.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-feed", "method": "GET", "min_role": "Public", "params": [{"description": "Sort order.", "example": "recent", "location": "query", "name": "sort", "options": ["recent", "top", "algo"], "required": false, "type": "enum"}, {"description": "Page size, 1-50.", "example": "20", "location": "query", "name": "limit", "required": false, "type": "int"}, {"description": "Offset for pagination.", "example": "0", "location": "query", "name": "skip", "required": false, "type": "int"}], "path": "/api/devrant/rants", "sample_response": {"num_notifs": 0, "rants": [{"attached_image": "", "created_time": 1781419994, "editable": false, "edited": false, "id": 1, "link": "rants/1/my-first-rant-about-python", "num_comments": 2, "rc": 0, "rt": 1, "score": 3, "tags": ["python", "devrant"], "text": "My first rant about Python", "user_avatar": {"b": "5c47fe", "i": "u/alice.png"}, "user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"}, "user_id": 42, "user_score": 17, "user_username": "alice", "vote_state": 0}], "success": true}, "summary": "List rants. Sort by recent, top, or algo.", "title": "Rant feed"}'></div>
</section>

<section class="endpoint-card" id="devrant-get-rant">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Single rant with comments</h3>
    <p class="endpoint-summary">Fetch one rant and its comments.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-get-rant", "method": "GET", "min_role": "Public", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}], "path": "/api/devrant/rants/{rant_id}", "sample_response": {"comments": [{"body": "Nice rant!", "created_time": 1781420050, "id": 9, "rant_id": 1, "score": 0, "user_avatar": {"b": "1188ff", "i": "u/bob.png"}, "user_id": 7, "user_score": 4, "user_username": "bob", "vote_state": 0}], "rant": {"attached_image": "", "created_time": 1781419994, "editable": false, "edited": false, "id": 1, "link": "rants/1/my-first-rant-about-python", "num_comments": 2, "rc": 0, "rt": 1, "score": 3, "tags": ["python", "devrant"], "text": "My first rant about Python", "user_avatar": {"b": "5c47fe", "i": "u/alice.png"}, "user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"}, "user_id": 42, "user_score": 17, "user_username": "alice", "vote_state": 0}, "subscribed": 0, "success": true}, "summary": "Fetch one rant and its comments.", "title": "Single rant with comments"}'></div>
</section>

<section class="endpoint-card" id="devrant-create-rant">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Post a rant</h3>
    <p class="endpoint-summary">Create a new rant. Tags are comma-separated and stored verbatim.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-create-rant", "method": "POST", "min_role": "Member", "params": [{"description": "Rant text, 1-125000 chars.", "example": "Posted from the docs widget", "location": "body", "name": "rant", "required": true, "type": "textarea"}, {"description": "Comma-separated tags.", "example": "python,devrant", "location": "body", "name": "tags", "required": false, "type": "string"}], "path": "/api/devrant/rants", "sample_response": {"rant_id": 12, "success": true}, "summary": "Create a new rant. Tags are comma-separated and stored verbatim.", "title": "Post a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-edit-rant">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Edit a rant</h3>
    <p class="endpoint-summary">Replace a rant&#39;s text and tags (owner only).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-edit-rant", "method": "POST", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}, {"description": "New rant text.", "example": "Edited text", "location": "body", "name": "rant", "required": true, "type": "textarea"}, {"description": "Comma-separated tags.", "example": "python", "location": "body", "name": "tags", "required": false, "type": "string"}], "path": "/api/devrant/rants/{rant_id}", "sample_response": {"success": true}, "summary": "Replace a rant\u0027s text and tags (owner only).", "title": "Edit a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-delete-rant">
    <header class="endpoint-head">
        <span class="method-badge method-delete">DELETE</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Delete a rant</h3>
    <p class="endpoint-summary">Soft-delete a rant (owner or admin).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": true, "encoding": "none", "id": "devrant-delete-rant", "method": "DELETE", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}], "path": "/api/devrant/rants/{rant_id}", "sample_response": {"success": true}, "summary": "Soft-delete a rant (owner or admin).", "title": "Delete a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-vote-rant">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}/vote</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Vote on a rant</h3>
    <p class="endpoint-summary">Upvote (1), downvote (-1), or clear (0). Returns the updated rant.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-vote-rant", "method": "POST", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}, {"description": "Vote value.", "example": "1", "location": "body", "name": "vote", "options": ["1", "-1", "0"], "required": true, "type": "enum"}], "path": "/api/devrant/rants/{rant_id}/vote", "sample_response": {"rant": {"attached_image": "", "created_time": 1781419994, "editable": false, "edited": false, "id": 1, "link": "rants/1/my-first-rant-about-python", "num_comments": 2, "rc": 0, "rt": 1, "score": 3, "tags": ["python", "devrant"], "text": "My first rant about Python", "user_avatar": {"b": "5c47fe", "i": "u/alice.png"}, "user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"}, "user_id": 42, "user_score": 17, "user_username": "alice", "vote_state": 0}, "success": true}, "summary": "Upvote (1), downvote (-1), or clear (0). Returns the updated rant.", "title": "Vote on a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-favorite">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}/favorite</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Favorite a rant</h3>
    <p class="endpoint-summary">Bookmark a rant.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-favorite", "method": "POST", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}], "path": "/api/devrant/rants/{rant_id}/favorite", "sample_response": {"success": true}, "summary": "Bookmark a rant.", "title": "Favorite a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-unfavorite">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}/unfavorite</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Unfavorite a rant</h3>
    <p class="endpoint-summary">Remove a rant bookmark.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-unfavorite", "method": "POST", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}], "path": "/api/devrant/rants/{rant_id}/unfavorite", "sample_response": {"success": true}, "summary": "Remove a rant bookmark.", "title": "Unfavorite a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-comment-rant">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/devrant/rants/{rant_id}/comments</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Comment on a rant</h3>
    <p class="endpoint-summary">Post a comment on a rant.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-comment-rant", "method": "POST", "min_role": "Member", "params": [{"description": "Rant id.", "example": "1", "location": "path", "name": "rant_id", "required": true, "type": "int"}, {"description": "Comment text, 1-125000 chars.", "example": "Great rant!", "location": "body", "name": "comment", "required": true, "type": "textarea"}], "path": "/api/devrant/rants/{rant_id}/comments", "sample_response": {"success": true}, "summary": "Post a comment on a rant.", "title": "Comment on a rant"}'></div>
</section>

<section class="endpoint-card" id="devrant-search">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/devrant/search</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Search rants</h3>
    <p class="endpoint-summary">Search rants by text in the title and body.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-search", "method": "GET", "min_role": "Public", "params": [{"description": "Search text.", "example": "python", "location": "query", "name": "term", "required": true, "type": "string"}], "path": "/api/devrant/search", "sample_response": {"results": [{"attached_image": "", "created_time": 1781419994, "editable": false, "edited": false, "id": 1, "link": "rants/1/my-first-rant-about-python", "num_comments": 2, "rc": 0, "rt": 1, "score": 3, "tags": ["python", "devrant"], "text": "My first rant about Python", "user_avatar": {"b": "5c47fe", "i": "u/alice.png"}, "user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"}, "user_id": 42, "user_score": 17, "user_username": "alice", "vote_state": 0}], "success": true}, "summary": "Search rants by text in the title and body.", "title": "Search rants"}'></div>
</section>

---

<a id="doc-devrant-comments"></a>
# devRant API: Comments

A comment is a DevPlace comment on a post, identified by its integer `id`. Posting a comment on a
rant is on the [Rants](#doc-devrant-rants) page; the widgets below read, edit, delete, and
vote on an existing comment. Log in below to enable the authenticated widgets.
</div>

<div class="devrant-login-bar" data-devrant-login></div>

<section class="endpoint-card" id="devrant-get-comment">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/comments/{comment_id}</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Get a comment</h3>
    <p class="endpoint-summary">Fetch a single comment by id.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-get-comment", "method": "GET", "min_role": "Public", "params": [{"description": "Comment id.", "example": "1", "location": "path", "name": "comment_id", "required": true, "type": "int"}], "path": "/api/comments/{comment_id}", "sample_response": {"comment": {"body": "Nice rant!", "created_time": 1781420050, "id": 9, "rant_id": 1, "score": 0, "user_avatar": {"b": "1188ff", "i": "u/bob.png"}, "user_id": 7, "user_score": 4, "user_username": "bob", "vote_state": 0}, "success": true}, "summary": "Fetch a single comment by id.", "title": "Get a comment"}'></div>
</section>

<section class="endpoint-card" id="devrant-edit-comment">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/comments/{comment_id}</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Edit a comment</h3>
    <p class="endpoint-summary">Replace a comment&#39;s text (owner only).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-edit-comment", "method": "POST", "min_role": "Member", "params": [{"description": "Comment id.", "example": "1", "location": "path", "name": "comment_id", "required": true, "type": "int"}, {"description": "New comment text.", "example": "Edited comment", "location": "body", "name": "comment", "required": true, "type": "textarea"}], "path": "/api/comments/{comment_id}", "sample_response": {"success": true}, "summary": "Replace a comment\u0027s text (owner only).", "title": "Edit a comment"}'></div>
</section>

<section class="endpoint-card" id="devrant-delete-comment">
    <header class="endpoint-head">
        <span class="method-badge method-delete">DELETE</span>
        <code class="endpoint-path">/api/comments/{comment_id}</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Delete a comment</h3>
    <p class="endpoint-summary">Soft-delete a comment (owner or admin).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": true, "encoding": "none", "id": "devrant-delete-comment", "method": "DELETE", "min_role": "Member", "params": [{"description": "Comment id.", "example": "1", "location": "path", "name": "comment_id", "required": true, "type": "int"}], "path": "/api/comments/{comment_id}", "sample_response": {"success": true}, "summary": "Soft-delete a comment (owner or admin).", "title": "Delete a comment"}'></div>
</section>

<section class="endpoint-card" id="devrant-vote-comment">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/comments/{comment_id}/vote</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Vote on a comment</h3>
    <p class="endpoint-summary">Upvote (1), downvote (-1), or clear (0).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-vote-comment", "method": "POST", "min_role": "Member", "params": [{"description": "Comment id.", "example": "1", "location": "path", "name": "comment_id", "required": true, "type": "int"}, {"description": "Vote value.", "example": "1", "location": "body", "name": "vote", "options": ["1", "-1", "0"], "required": true, "type": "enum"}], "path": "/api/comments/{comment_id}/vote", "sample_response": {"success": true}, "summary": "Upvote (1), downvote (-1), or clear (0).", "title": "Vote on a comment"}'></div>
</section>

---

<a id="doc-devrant-users"></a>
# devRant API: Users & avatars

Profiles are public; editing your own needs the token triple (log in below). `score` is the
user's net stars, `about` is the bio, `github` is the git link, and `skills` is derived from the
bio. The nested `content.content` holds the user's serialized rants and comments.
</div>

<div class="devrant-login-bar" data-devrant-login></div>

<section class="endpoint-card" id="devrant-get-user-id">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/get-user-id</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Resolve username to id</h3>
    <p class="endpoint-summary">Look up a user&#39;s integer id from their username.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-get-user-id", "method": "GET", "min_role": "Public", "params": [{"description": "Username to look up.", "example": "alice", "location": "query", "name": "username", "required": true, "type": "string"}], "path": "/api/get-user-id", "sample_response": {"success": true, "user_id": 42}, "summary": "Look up a user\u0027s integer id from their username.", "title": "Resolve username to id"}'></div>
</section>

<section class="endpoint-card" id="devrant-profile">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/users/{user_id}</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Get a profile</h3>
    <p class="endpoint-summary">Fetch a user&#39;s devRant profile with their rants and comments.</p>
    <div data-devrant-tester data-config='{"auth": "public", "destructive": false, "encoding": "none", "id": "devrant-profile", "method": "GET", "min_role": "Public", "params": [{"description": "User id.", "example": "1", "location": "path", "name": "user_id", "required": true, "type": "int"}], "path": "/api/users/{user_id}", "sample_response": {"profile": {"about": "I build things with Python", "avatar": {"b": "5c47fe", "i": "u/alice.png"}, "content": {"content": {"comments": [{"body": "Nice rant!", "created_time": 1781420050, "id": 9, "rant_id": 1, "score": 0, "user_avatar": {"b": "1188ff", "i": "u/bob.png"}, "user_id": 7, "user_score": 4, "user_username": "bob", "vote_state": 0}], "favorites": [], "rants": [{"attached_image": "", "created_time": 1781419994, "editable": false, "edited": false, "id": 1, "link": "rants/1/my-first-rant-about-python", "num_comments": 2, "rc": 0, "rt": 1, "score": 3, "tags": ["python", "devrant"], "text": "My first rant about Python", "user_avatar": {"b": "5c47fe", "i": "u/alice.png"}, "user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"}, "user_id": 42, "user_score": 17, "user_username": "alice", "vote_state": 0}], "upvoted": [], "viewed": []}, "counts": {"collabs": 0, "comments": 1, "favorites": 0, "rants": 1, "upvoted": 0}}, "created_time": 1781419994, "github": "alice", "location": "Amsterdam", "score": 17, "skills": "I build things with Python", "username": "alice", "website": "https://alice.dev"}, "success": true}, "summary": "Fetch a user\u0027s devRant profile with their rants and comments.", "title": "Get a profile"}'></div>
</section>

<section class="endpoint-card" id="devrant-edit-profile">
    <header class="endpoint-head">
        <span class="method-badge method-post">POST</span>
        <code class="endpoint-path">/api/users/me/edit-profile</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Edit your profile</h3>
    <p class="endpoint-summary">Update bio, location, git link, and website. profile_skills is accepted but ignored (skills are derived from the bio).</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "form", "id": "devrant-edit-profile", "method": "POST", "min_role": "Member", "params": [{"description": "Bio.", "example": "I build with Python and Rust", "location": "body", "name": "profile_about", "required": false, "type": "textarea"}, {"description": "Location.", "example": "Amsterdam", "location": "body", "name": "profile_location", "required": false, "type": "string"}, {"description": "Git link.", "example": "alice", "location": "body", "name": "profile_github", "required": false, "type": "string"}, {"description": "Website.", "example": "https://alice.dev", "location": "body", "name": "profile_website", "required": false, "type": "string"}], "path": "/api/users/me/edit-profile", "sample_response": {"success": true}, "summary": "Update bio, location, git link, and website. profile_skills is accepted but ignored (skills are derived from the bio).", "title": "Edit your profile"}'></div>
</section>

<section class="endpoint-card" id="devrant-avatar">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/avatars/u/{username}.png</code>
        <span class="auth-badge auth-public" title="Minimal role required to call this endpoint">Minimal role: Public</span>
    </header>
    <h3 class="endpoint-title">Avatar image</h3>
    <p class="endpoint-summary">Every <code>user_avatar.i</code> points at this path. It renders a real PNG from the username seed; pass <code>?size=</code> (16-512, default 128).</p>
    <div class="api-tester">
        <img src="/api/avatars/u/devplace.png?size=96" alt="Example devRant avatar" width="96" height="96" class="docs-demo-avatar">
    </div>
</section>

---

<a id="doc-devrant-notifications"></a>
# devRant API: Notifications

The notification feed maps DevPlace notifications onto the devRant shape. Both endpoints need the
token triple - log in below. DevPlace types map onto devRant types: `comment`/`reply` to
`comment_discuss`, `mention` to `comment_mention`, `vote` to `rant_vote`, `follow` to `rant_sub`.
</div>

<div class="devrant-login-bar" data-devrant-login></div>

<section class="endpoint-card" id="devrant-notif-feed">
    <header class="endpoint-head">
        <span class="method-badge method-get">GET</span>
        <code class="endpoint-path">/api/users/me/notif-feed</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Notification feed</h3>
    <p class="endpoint-summary">Fetch the logged-in user&#39;s notification feed with unread counts.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": false, "encoding": "none", "id": "devrant-notif-feed", "method": "GET", "min_role": "Member", "params": [], "path": "/api/users/me/notif-feed", "sample_response": {"data": {"check_time": 1781420060, "items": [{"comment_id": 0, "created_time": 1781420050, "rant_id": 0, "read": 0, "type": "comment_discuss", "uid": 7, "username": "bob"}], "num_unread": 1, "unread": {"all": 1, "comments": 1, "mentions": 0, "subs": 0, "total": 1, "upvotes": 0}, "username_map": {"7": "bob"}}, "success": true}, "summary": "Fetch the logged-in user\u0027s notification feed with unread counts.", "title": "Notification feed"}'></div>
</section>

<section class="endpoint-card" id="devrant-clear-notif-feed">
    <header class="endpoint-head">
        <span class="method-badge method-delete">DELETE</span>
        <code class="endpoint-path">/api/users/me/notif-feed</code>
        <span class="auth-badge auth-user" title="Minimal role required to call this endpoint">Minimal role: Member</span>
    </header>
    <h3 class="endpoint-title">Clear notifications</h3>
    <p class="endpoint-summary">Mark every notification read.</p>
    <div data-devrant-tester data-config='{"auth": "user", "destructive": true, "encoding": "none", "id": "devrant-clear-notif-feed", "method": "DELETE", "min_role": "Member", "params": [], "path": "/api/users/me/notif-feed", "sample_response": {"success": true}, "summary": "Mark every notification read.", "title": "Clear notifications"}'></div>
</section>

---

<a id="doc-devrant-clients"></a>
# devRant API: Client scripts

Ready-to-run clients for the devRant-compatible protocol. The Python client uses only the
standard library; the JavaScript client uses Node 18+ (global `fetch`). The full versions, plus
example scripts and an end-to-end conformance test, ship in the repository under
`examples/devrant/`.

## Python client (drop-in)

```python
import json, urllib.parse, urllib.request


class DevRant:
    def __init__(self, base_url, username=None, password=None):
        self.base_url = base_url.rstrip("/")
        self.username, self.password = username, password
        self.auth = {}

    def _request(self, method, path, params=None, body=None):
        merged = dict(params or {}); merged.update(self.auth)
        url = f"{self.base_url}/api/{path.lstrip('/')}"
        data, headers = None, {"Accept": "application/json"}
        if method in ("GET", "DELETE"):
            if merged:
                url += "?" + urllib.parse.urlencode(merged)
        else:
            payload = dict(merged); payload.update(body or {})
            data = urllib.parse.urlencode(payload).encode()
            headers["Content-Type"] = "application/x-www-form-urlencoded"
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read().decode())

    def login(self):
        out = self._request("POST", "users/auth-token",
                            body={"username": self.username, "password": self.password})
        if not out.get("success"):
            raise RuntimeError(out.get("error", "login failed"))
        t = out["auth_token"]
        self.auth = {"user_id": t["user_id"], "token_id": t["id"], "token_key": t["key"]}
        return self.auth

    def rants(self, sort="recent", limit=20, skip=0):
        return self._request("GET", "devrant/rants",
                            {"sort": sort, "limit": limit, "skip": skip}).get("rants", [])

    def post_rant(self, text, tags=""):
        return self._request("POST", "devrant/rants", body={"rant": text, "tags": tags})

    def vote_rant(self, rant_id, vote):
        return self._request("POST", f"devrant/rants/{rant_id}/vote", body={"vote": vote})

    def comment(self, rant_id, text):
        return self._request("POST", f"devrant/rants/{rant_id}/comments", body={"comment": text})


api = DevRant("https://devplace.net", "USERNAME", "PASSWORD")
api.login()
print(api.post_rant("Hello from Python", "python,devrant"))
```

## JavaScript client (drop-in)

```javascript
export class DevRant {
  constructor(baseUrl, username, password) {
    this.baseUrl = baseUrl.replace(/\/$/, "");
    this.username = username; this.password = password; this.auth = {};
  }
  async _request(method, path, params = {}, body = null) {
    const merged = { ...params, ...this.auth };
    const headers = { Accept: "application/json" };
    let url = new URL(`${this.baseUrl}/api/${path.replace(/^\//, "")}`);
    const init = { method, headers };
    if (method === "GET" || method === "DELETE") {
      for (const [k, v] of Object.entries(merged)) url.searchParams.set(k, v);
    } else {
      headers["Content-Type"] = "application/x-www-form-urlencoded";
      init.body = new URLSearchParams({ ...merged, ...(body || {}) }).toString();
    }
    return (await fetch(url, init)).json();
  }
  async login() {
    const out = await this._request("POST", "users/auth-token", {},
      { username: this.username, password: this.password });
    if (!out.success) throw new Error(out.error || "login failed");
    const t = out.auth_token;
    this.auth = { user_id: t.user_id, token_id: t.id, token_key: t.key };
    return this.auth;
  }
  async rants(sort = "recent", limit = 20, skip = 0) {
    return (await this._request("GET", "devrant/rants", { sort, limit, skip })).rants || [];
  }
  postRant(text, tags = "") {
    return this._request("POST", "devrant/rants", {}, { rant: text, tags });
  }
  voteRant(rantId, vote) {
    return this._request("POST", `devrant/rants/${rantId}/vote`, {}, { vote });
  }
  comment(rantId, text) {
    return this._request("POST", `devrant/rants/${rantId}/comments`, {}, { comment: text });
  }
}

const api = new DevRant("https://devplace.net", "USERNAME", "PASSWORD");
await api.login();
console.log(await api.postRant("Hello from Node", "javascript,devrant"));
```

## Example scripts in the repository

`examples/devrant/` contains the complete clients and runnable scripts:

| File | Language | What it does |
|------|----------|--------------|
| `client.py` / `client.mjs` | Python / JS | Full reusable client (every endpoint). |
| `post_rant.py` / `post_rant.mjs` | Python / JS | Post one rant from the command line. |
| `feed_watch.py` / `feed_watch.mjs` | Python / JS | Live feed ticker, optional keyword auto-upvote. |
| `smoke_test.py` / `smoke_test.mjs` | Python / JS | End-to-end conformance test, prints PASS/FAIL. |

They read `DEVRANT_BASE`, `DEVRANT_USERNAME`, and `DEVRANT_PASSWORD` from the environment.

```bash
# post a rant
DEVRANT_USERNAME=you DEVRANT_PASSWORD=secret6 \
  python examples/devrant/post_rant.py "Posted from a script" "python"

# run the full conformance test against a running server
DEVRANT_BASE=https://devplace.net python examples/devrant/smoke_test.py
DEVRANT_BASE=https://devplace.net node examples/devrant/smoke_test.mjs
```

---

<a id="doc-conventions"></a>
# Conventions & Errors

Shared rules that apply to every endpoint in this reference.

## Base URL

Every example uses your current host:

```
https://devplace.net
```

## Authentication

Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](#doc-authentication): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.

## Request bodies

POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.

## HTML or JSON (content negotiation)

**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:

- `Accept: application/json`
- `Content-Type: application/json`

A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.

**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).

**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:

```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```

`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.

**Errors** are JSON too when JSON is requested:

```json
{ "error": { "status": 404, "message": "Not found" } }
```

Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.

## Trying it here

Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.

## AJAX responses (legacy shape)

The [Votes, Reactions, Bookmarks & Polls](#doc-social-actions) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.

## Pagination

Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.

The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.

## Identifiers

Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.

## Dates

All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.

## Status codes

| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](#doc-uploads)) |
| `415` | Upload file type not allowed (see [Uploads](#doc-uploads)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |

## Rate limiting

Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.

## Troubleshooting

**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).

**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.

**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).

**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.

---

<a id="doc-auth"></a>
# Authentication

Create an account, sign in, recover your password, and log out. These are the only endpoints
that set or clear the `session` cookie; every other request authenticates with the methods
described in [Authentication](#doc-authentication). The shared rules (content
negotiation, pagination, status codes) live in [Conventions and Errors](#doc-conventions).

## Page vs. action

The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
page data as JSON when requested with `Accept: application/json` (including `page` to
distinguish the form type).

The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
and return a `302` redirect (or the JSON envelope for JSON callers).

**Sign-up requires a unique `username` and `email`** plus a `confirm_password` that matches the
password; **you log in with your `email` and password**. JSON callers receive validation errors
as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.

### `GET /auth/signup` - Sign up page

Render the registration form. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/signup` - Sign up

Create a new account. Sets the session cookie on success.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | form | string | yes | Username, 3-32 characters (letters, numbers, hyphens, underscores). |
| `email` | form | string | yes | Email address; must be unique and contain an @. |
| `password` | form | string | yes | Password, 6+ characters. |
| `confirm_password` | form | string | yes | Must match password. |
| `birth_date` | form | string | yes | Date of birth, DD/MM/YYYY or YYYY-MM-DD. Only the derived age band is stored; the date is discarded. |
| `accept_terms` | form | enum | yes | Acceptance of the Terms of Service and Community Guidelines. Allowed: 1. |

> Signup is refused below the platform minimum age (`moderation_minimum_age`).

> Accepting records the terms, privacy and activity-recording consents; third-party AI processing stays off until it is granted separately.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": {
    "username": "alice"
  }
}
```

### `GET /auth/login` - Log in page

Render the login form. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `next` | query | string | no | Redirect target after login. |

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/login` - Log in

Authenticate with email and password. Sets the session cookie.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `email` | form | string | yes | Your registered email. |
| `password` | form | string | yes | Your password. |
| `remember_me` | form | string | no | Send 'on' to extend the session to the remember-me lifetime. |
| `next` | form | string | no | Redirect target after login. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": null
}
```

### `GET /auth/forgot-password` - Forgot password page

Render the forgot-password form. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/forgot-password` - Request password reset

Send a password-reset email with a one-time link.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `email` | form | string | yes | Your registered email. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/auth/forgot-password?sent=1",
  "data": null
}
```

### `GET /auth/reset-password/{token}` - Reset password page

Render the password-reset form (only valid with a one-time token). Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | The one-time reset token from the email. |

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/reset-password/{token}` - Reset password

Set a new password using a one-time reset token.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | The one-time reset token from the email. |
| `password` | form | string | yes | New password, 6+ characters. |
| `confirm_password` | form | string | yes | Must match password. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/auth/login",
  "data": null
}
```

### `GET /auth/logout` - Log out

Clear the session cookie and redirect to the landing page.

*Minimal role:* Public

**Sample response**

```json
{
  "ok": true,
  "redirect": "/",
  "data": null
}
```

---

<a id="doc-lookups"></a>
# Search & Lookups

Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](#doc-messaging) (the recipient
composer) and [Profiles & Social Graph](#doc-profiles) (mentions and user pages).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /profile/search` - Search users

Find up to ten users whose username matches a query.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Partial username to match. |

**Sample response**

```json
{
  "results": [
    {
      "uid": "8f14e45f-...",
      "username": "alice_test"
    }
  ]
}
```

### `GET /messages/search` - Search message recipients

Like user search, but excludes yourself; used by the message composer.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Partial username to match. |

**Sample response**

```json
{
  "results": [
    {
      "uid": "0cc175b9-...",
      "username": "bob_test"
    }
  ]
}
```

---

<a id="doc-social-actions"></a>
# Votes, Reactions, Bookmarks & Polls

Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](#doc-conventions) page
explains that header rule and the response envelope.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `POST /votes/{target_type}/{target_uid}` - Cast or toggle a vote

Upvote or downvote a target. Re-sending the same value removes the vote.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content being voted on. Allowed: post, comment, gist, project, quiz. |
| `target_uid` | path | string | yes | UID of the target. |
| `value` | form | enum | yes | 1 to upvote, -1 to downvote. Allowed: 1, -1. |

**Sample response**

```json
{
  "net": 3,
  "up": 4,
  "down": 1,
  "value": 1
}
```

### `POST /reactions/{target_type}/{target_uid}` - Toggle an emoji reaction

Add or remove an emoji reaction on a target.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content being reacted to. Allowed: post, comment, gist, project, quiz. |
| `target_uid` | path | string | yes | UID of the target. |
| `emoji` | form | string | yes | Any single emoji character. Re-sending the same one removes it. |

**Sample response**

```json
{
  "counts": {
    "\ud83d\udc4d": 2
  },
  "mine": [
    "\ud83d\udc4d"
  ]
}
```

### `POST /bookmarks/{target_type}/{target_uid}` - Toggle a bookmark

Save or unsave a target to your bookmarks.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content to bookmark. Allowed: post, gist, project, news, quiz. |
| `target_uid` | path | string | yes | UID of the target. |

**Sample response**

```json
{
  "saved": true
}
```

### `GET /bookmarks/saved` - View saved bookmarks

Render your saved content. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor (created_at of the last item). |

> Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](#doc-content).

**Sample response**

```json
{
  "items": [
    {
      "target_type": "string",
      "target_uid": "UID",
      "type_label": "string",
      "title": "Title",
      "url": "/path",
      "time_ago": "2 hours ago"
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `POST /polls/{poll_uid}/vote` - Vote in a poll

Cast, change, or clear your vote on a poll option.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `poll_uid` | path | string | yes | UID of the poll. |
| `option_uid` | form | string | yes | UID of the chosen option. |

> You hold at most one vote per poll, and only your latest vote counts. Voting a different option replaces your previous choice; voting your current option again removes the vote.

**Sample response**

```json
{
  "question": "Best editor?",
  "options": [
    {
      "uid": "OPTION_UID",
      "label": "Vim",
      "votes": 5
    }
  ],
  "total": 5,
  "voted": "OPTION_UID"
}
```

---

<a id="doc-content"></a>
# Posts, Comments, Projects, Gists & News

The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](#doc-uploads) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](#doc-social-actions).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /` - Home

The home page. Guests see the marketing splash; authenticated users see a personalized home (welcome, feed shortcut, latest posts, news). It no longer redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author.

*Minimal role:* Public

**Sample response**

```json
{
  "is_authenticated": false,
  "user_post_count": 0,
  "user_stars": 0,
  "user_xp": 0,
  "user_level": 0,
  "xp_progress_pct": 0,
  "unread_count": 0,
  "landing_articles": [
    {
      "uid": "UID",
      "slug": "slug",
      "title": "Title",
      "description": "text",
      "url": "/path",
      "source_name": "string",
      "grade": 0,
      "featured": 0,
      "synced_at": "2026-01-01T00:00:00+00:00",
      "time_ago": "2 hours ago",
      "image_url": "/path"
    }
  ],
  "landing_posts": [
    {
      "post": null,
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "comment_count": 0,
      "stars": 0,
      "slug": "slug"
    }
  ],
  "top_contributors": [],
  "trending_topics": [
    {
      "topic": "random",
      "count": 0
    }
  ]
}
```

### `GET /feed` - Browse the feed

The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `tab` | query | enum | no | Feed selector. Allowed: all, trending, following. |
| `topic` | query | enum | no | Filter by topic. Allowed: devlog, showcase, question, rant, fun, random, politics. |
| `search` | query | string | no | Search post title, content, and author username. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "project_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      },
      "project_link": {
        "uid": "UID",
        "name": "string",
        "slug": "slug",
        "url": "/path"
      }
    }
  ],
  "current_tab": "string",
  "current_topic": "random",
  "search": "string",
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "total_members": 0,
  "posts_today": 0,
  "total_projects": 0,
  "total_gists": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "xp_progress_pct": 0,
      "xp_next_level": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "last_seen": "string"
    }
  ],
  "daily_topic": null,
  "online_users": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "xp_progress_pct": 0,
      "xp_next_level": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "last_seen": "string"
    }
  ]
}
```

### `POST /posts/create` - Create a post

Publish a post, optionally with a poll. Redirects to the new post.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | yes | Body, 10-125000 characters. |
| `title` | form | string | no | Optional title, up to 500 characters. |
| `topic` | form | enum | no | Post topic. Allowed: devlog, showcase, question, rant, fun, random, politics. |
| `project_uid` | form | string | no | Attach to a project. |
| `poll_question` | form | string | no | Optional poll question. |
| `poll_options` | form | string | no | Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options). |

> Returns a `302` redirect to `/posts/{slug}` on success.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": {
    "uid": "POST_UID",
    "slug": "POST_SLUG",
    "url": "/posts/POST_SLUG"
  }
}
```

### `GET /posts/{post_slug}` - View a post

Render a post with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |

**Sample response**

```json
{
  "maturity": "string",
  "post": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "content": "text",
    "topic": "random",
    "stars": 0,
    "image": "string",
    "project_uid": "UID",
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "xp_progress_pct": 0,
    "xp_next_level": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "last_seen": "string"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "can_modify": false
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false,
  "poll": {
    "uid": "UID",
    "question": "string",
    "options": [
      {
        "uid": "UID",
        "label": "string",
        "count": 0,
        "votes": 0,
        "pct": 0
      }
    ],
    "total": 0,
    "my_choice": "string",
    "voted": "string"
  },
  "comment_count": 0,
  "related_posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "project_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      },
      "project_link": {
        "uid": "UID",
        "name": "string",
        "slug": "slug",
        "url": "/path"
      }
    }
  ],
  "topics": [
    "random"
  ],
  "project_link": {
    "uid": "UID",
    "name": "string",
    "slug": "slug",
    "url": "/path"
  }
}
```

### `POST /posts/edit/{post_slug}` - Edit a post

Update a post you own, optionally adding a poll if it has none.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |
| `content` | form | textarea | yes | Body, 10-125000 characters. |
| `title` | form | string | no | Optional title. |
| `topic` | form | enum | no | Post topic. Allowed: devlog, showcase, question, rant, fun, random, politics. |
| `poll_question` | form | string | no | Optional poll question. Adds a poll only when the post has none. |
| `poll_options` | form | string | no | Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options). |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": {
    "uid": "POST_UID",
    "slug": "POST_SLUG",
    "url": "/posts/POST_SLUG"
  }
}
```

### `POST /posts/delete/{post_slug}` - Delete a post

Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": null
}
```

### `POST /comments/create` - Create a comment

Comment on any commentable target. Supports nested replies.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | yes | Body, 3-1000 characters. |
| `target_uid` | form | string | no | UID of the target (or use post_uid). |
| `post_uid` | form | string | no | Convenience alias for a post target. |
| `target_type` | form | enum | no | Type of the target. Allowed: post, project, news, issue, gist, quiz. |
| `parent_uid` | form | string | no | Parent comment UID for a reply. |

> Either `target_uid` or `post_uid` is required.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG#comment-COMMENT_UID",
  "data": {
    "uid": "COMMENT_UID",
    "url": "/posts/POST_SLUG#comment-COMMENT_UID"
  }
}
```

### `POST /comments/edit/{comment_uid}` - Edit a comment

Edit the body of a comment you own. Returns the updated comment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `comment_uid` | path | string | yes | UID of the comment. |
| `content` | form | textarea | yes | New body, 3-1000 characters. |

**Sample response**

```json
{
  "uid": "COMMENT_UID",
  "content": "Edited body.",
  "url": "/posts/POST_SLUG#comment-COMMENT_UID",
  "updated_at": "2026-06-15T12:00:00+00:00"
}
```

### `POST /comments/delete/{comment_uid}` - Delete a comment

Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `comment_uid` | path | string | yes | UID of the comment. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": {
    "deleted_uid": "COMMENT_UID",
    "target_type": "post",
    "target_uid": "TARGET_UID"
  }
}
```

### `GET /projects` - Browse projects

List projects. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `tab` | query | enum | no | Sort selector. Allowed: recent, popular, released. |
| `search` | query | string | no | Search project title, description, and author username. |
| `project_type` | query | enum | no | Filter by type. Allowed: game, game_asset, software, mobile_app, website. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "projects": [
    {
      "uid": "UID",
      "slug": "slug",
      "user_uid": "UID",
      "title": "Title",
      "description": "text",
      "project_type": "string",
      "status": "published",
      "platforms": null,
      "stars": 0,
      "is_private": false,
      "read_only": false,
      "release_date": "string",
      "demo_date": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00",
      "author_name": "string",
      "my_vote": 0,
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ]
    }
  ],
  "current_tab": "string",
  "search": "string",
  "project_type": "string",
  "total_count": 0,
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "total_members": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "xp_progress_pct": 0,
      "xp_next_level": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "last_seen": "string"
    }
  ]
}
```

### `GET /projects/{project_slug}` - View a project

Render a project with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "maturity": "string",
  "project": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "description": "text",
    "project_type": "string",
    "status": "published",
    "platforms": null,
    "stars": 0,
    "is_private": false,
    "read_only": false,
    "release_date": "string",
    "demo_date": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "xp_progress_pct": 0,
    "xp_next_level": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "last_seen": "string"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "can_modify": false
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false,
  "platforms": null,
  "is_private": false,
  "read_only": false,
  "viewer_can_containers": false,
  "viewer_can_workspace": false,
  "workspace_editor_url": "/path",
  "workspace_editor_mode": "string",
  "workspace_editor_width": 0,
  "workspace_editor_height": 0,
  "forked_from": {},
  "fork_count": 0,
  "file_count": 0,
  "devlog_posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "project_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      },
      "project_link": {
        "uid": "UID",
        "name": "string",
        "slug": "slug",
        "url": "/path"
      }
    }
  ],
  "devlog_next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `POST /projects/create` - Create a project

Publish a project. Redirects to the new project.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |
| `project_type` | form | enum | no | Project type. Allowed: game, game_asset, software, mobile_app, website. |
| `status` | form | string | no | Free-form status label. |
| `platforms` | form | string | no | Comma-separated platforms. |
| `release_date` | form | string | no | Optional release date in DD/MM/YYYY format. |
| `demo_date` | form | string | no | Optional demo date in DD/MM/YYYY format. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG",
  "data": {
    "uid": "PROJECT_UID",
    "slug": "PROJECT_SLUG",
    "url": "/projects/PROJECT_SLUG"
  }
}
```

### `POST /projects/edit/{project_slug}` - Edit a project

Update an owned project. Redirects to the project.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |
| `project_type` | form | enum | no | Project type. Allowed: game, game_asset, software, mobile_app, website. |
| `status` | form | string | no | Free-form status label. |
| `platforms` | form | string | no | Comma-separated platforms. |
| `release_date` | form | string | no | Optional release date in DD/MM/YYYY format. |
| `demo_date` | form | string | no | Optional demo date in DD/MM/YYYY format. |

### `POST /projects/delete/{project_slug}` - Delete a project

Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects",
  "data": null
}
```

### `POST /projects/{project_slug}/private` - Set project visibility

Mark a project you own private or public. Send value=1 for private, value=0 for public. A project you hide as a member stays visible to administrators; a project you hide as an administrator is visible only to you, not to other administrators (this also hides its files and any attached containers).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |
| `value` | form | boolean | yes | 1 to make the project private, 0 to make it public. |

### `POST /projects/{project_slug}/readonly` - Set project read-only

Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |
| `value` | form | boolean | yes | 1 to make the project read-only, 0 to make it writable. |

### `POST /projects/{project_slug}/zip` - Queue a project zip

Start a background job that archives the whole project. Returns the job uid and status URL to poll.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "status_url": "/zips/ZIP_JOB_UID"
}
```

### `GET /zips/{uid}` - Zip job status

Poll a zip job. While pending or running download_url is null; once done it points at the archive.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Zip job uid returned when the job was queued. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "kind": "zip",
  "status": "done",
  "preferred_name": "my-project",
  "download_url": "/zips/ZIP_JOB_UID/download",
  "error": null,
  "bytes_in": 20480,
  "bytes_out": 8192,
  "item_count": 12,
  "file_count": 10,
  "dir_count": 2,
  "created_at": "2026-06-09T10:00:00+00:00",
  "completed_at": "2026-06-09T10:00:03+00:00"
}
```

### `GET /zips/{uid}/download` - Download a zip archive

Stream the finished archive as application/zip. Each access extends the retention window.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Zip job uid of a finished job. |

### `POST /projects/{project_slug}/fork` - Queue a project fork

Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project to fork. |
| `title` | form | string | yes | Title for the new forked project. |

**Sample response**

```json
{
  "uid": "FORK_JOB_UID",
  "status_url": "/forks/FORK_JOB_UID"
}
```

### `GET /forks/{uid}` - Fork job status

Poll a fork job. While pending or running project_url is null; once done it points at the new project.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Fork job uid returned when the job was queued. |

**Sample response**

```json
{
  "uid": "FORK_JOB_UID",
  "kind": "fork",
  "status": "done",
  "preferred_name": "My Fork",
  "project_uid": "NEW_PROJECT_UID",
  "project_url": "/projects/new-project-slug",
  "source_project_uid": "SOURCE_PROJECT_UID",
  "error": null,
  "item_count": 12,
  "created_at": "2026-06-09T10:00:00+00:00",
  "completed_at": "2026-06-09T10:00:05+00:00"
}
```

### `GET /gists` - Browse gists

List code gists. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `language` | query | enum | no | Filter by language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `user_uid` | query | string | no | Filter by author UID. |
| `search` | query | string | no | Search gist title, description, and author username. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "gists": [
    {
      "gist": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "description": "text",
        "source_code": "string",
        "language": "python",
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ]
    }
  ],
  "total_count": 0,
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "current_language": "python",
  "search": "string",
  "languages": [
    [
      "python"
    ]
  ],
  "gist_language_codes": [
    "python"
  ]
}
```

### `GET /gists/{gist_slug}` - View a gist

Render a gist with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |

**Sample response**

```json
{
  "maturity": "string",
  "gist": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "description": "text",
    "source_code": "string",
    "language": "python",
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "xp_progress_pct": 0,
    "xp_next_level": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "last_seen": "string"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "can_modify": false
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false
}
```

### `POST /gists/create` - Create a gist

Publish a code snippet. Redirects to the new gist.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `source_code` | form | textarea | yes | Source, 1-400000 characters. |
| `language` | form | enum | no | Syntax language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `description` | form | string | no | Optional description. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists/GIST_SLUG",
  "data": {
    "uid": "GIST_UID",
    "slug": "GIST_SLUG",
    "url": "/gists/GIST_SLUG"
  }
}
```

### `POST /gists/edit/{gist_slug}` - Edit a gist

Update a gist you own.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |
| `title` | form | string | yes | Title, 1-200 characters. |
| `source_code` | form | textarea | yes | Source, 1-400000 characters. |
| `language` | form | enum | no | Syntax language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `description` | form | string | no | Optional description. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists/GIST_SLUG",
  "data": {
    "uid": "GIST_UID",
    "slug": "GIST_SLUG",
    "url": "/gists/GIST_SLUG"
  }
}
```

### `POST /gists/delete/{gist_slug}` - Delete a gist

Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists",
  "data": null
}
```

### `GET /news` - Browse news

Curated developer news. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor (synced_at). |

**Sample response**

```json
{
  "articles": [
    {
      "article": {
        "uid": "UID",
        "slug": "slug",
        "title": "Title",
        "description": "text",
        "content": "text",
        "url": "/path",
        "source_name": "string",
        "author": "string",
        "grade": 0,
        "ai_grade": 0,
        "status": "published",
        "image_url": "/path",
        "featured": 0,
        "has_unique_image": 0,
        "article_published": "string",
        "created_at": "2026-01-01T00:00:00+00:00",
        "synced_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "image_url": "/path",
      "grade": 0,
      "featured": 0,
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ]
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `GET /news/{news_slug}` - View a news article

Render a news article with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `news_slug` | path | string | yes | Slug or UID of the article. |

**Sample response**

```json
{
  "maturity": "string",
  "article": {
    "uid": "UID",
    "slug": "slug",
    "title": "Title",
    "description": "text",
    "content": "text",
    "url": "/path",
    "source_name": "string",
    "author": "string",
    "grade": 0,
    "ai_grade": 0,
    "status": "published",
    "image_url": "/path",
    "featured": 0,
    "has_unique_image": 0,
    "article_published": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "synced_at": "2026-01-01T00:00:00+00:00"
  },
  "canonical_slug": "slug",
  "image_url": "/path",
  "grade": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "bookmarked": false
}
```

---

<a id="doc-profiles"></a>
# Profiles & Social Graph

Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](#doc-lookups); follows generate entries in [Notifications](#doc-notifications).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /profile` - View your own profile

Render the signed-in user's own profile, in the exact format of GET /profile/{username}. Requires authentication.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `tab` | query | enum | no | Profile tab. Allowed: posts, activity, followers, following, media, awards. |

### `GET /profile/{username}` - View a profile

Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `tab` | query | enum | no | Profile tab. Allowed: posts, activity, followers, following, media, awards. |

> Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.

**Sample response**

```json
{
  "profile_user": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "xp_progress_pct": 0,
    "xp_next_level": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "last_seen": "string"
  },
  "posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "project_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      },
      "project_link": {
        "uid": "UID",
        "name": "string",
        "slug": "slug",
        "url": "/path"
      }
    }
  ],
  "badges": [
    {
      "name": "string",
      "icon": "string",
      "description": "text",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "achievements": [
    {}
  ],
  "badge_total": 0,
  "badge_earned": 0,
  "projects": [
    {
      "uid": "UID",
      "slug": "slug",
      "user_uid": "UID",
      "title": "Title",
      "description": "text",
      "project_type": "string",
      "status": "published",
      "platforms": null,
      "stars": 0,
      "is_private": false,
      "read_only": false,
      "release_date": "string",
      "demo_date": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "gists": [
    {
      "gist": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "description": "text",
        "source_code": "string",
        "language": "python",
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "maturity": "string",
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00",
              "can_modify": false
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ]
    }
  ],
  "current_tab": "string",
  "posts_count": 0,
  "is_following": false,
  "is_blocked": false,
  "is_muted": false,
  "is_owner": false,
  "profile_online": false,
  "can_view_api_key": false,
  "api_key": "string",
  "ai_correction_enabled": false,
  "ai_correction_sync": false,
  "ai_correction_prompt": "string",
  "ai_modifier_enabled": false,
  "ai_modifier_sync": false,
  "ai_modifier_prompt": "string",
  "interactions_enabled": false,
  "interactions_source": "string",
  "interactions_default": false,
  "interactions_override": false,
  "telegram_paired": false,
  "notif_telegram_paired": false,
  "can_manage_customization": false,
  "cust_disable_global": false,
  "cust_disable_pagetype": false,
  "ai_quota": {},
  "correction_usage": {},
  "modifier_usage": {},
  "activities": [
    null
  ],
  "rank": 0,
  "heatmap": null,
  "heatmap_months": null,
  "streak": null,
  "people": [
    null
  ],
  "follow_pagination": null,
  "followers_count": 0,
  "following_count": 0,
  "viewer_is_admin": false,
  "xp_next_level": 0,
  "xp_progress_pct": 0,
  "media": [
    {
      "uid": "UID",
      "original_filename": "string",
      "file_size": 0,
      "mime_type": "string",
      "url": "/path",
      "thumbnail_url": "/path",
      "has_thumbnail": false,
      "is_image": false,
      "is_video": false,
      "target_type": "string",
      "target_uid": "UID",
      "target_url": "/path",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "media_pagination": null,
  "notification_prefs": [
    null
  ],
  "awards": [
    {
      "uid": "UID",
      "slug": "slug",
      "description": "text",
      "giver_uid": "UID",
      "receiver_uid": "UID",
      "generated_at": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "image_url": "/path",
      "thumb_url": "/path",
      "giver": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      }
    }
  ],
  "awards_pagination": null,
  "awards_count": 0,
  "prominent_award": {
    "uid": "UID",
    "slug": "slug",
    "description": "text",
    "giver_uid": "UID",
    "receiver_uid": "UID",
    "generated_at": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "image_url": "/path",
    "thumb_url": "/path",
    "giver": {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "xp_progress_pct": 0,
      "xp_next_level": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "last_seen": "string"
    }
  },
  "can_give_award": false,
  "consents": [
    null
  ],
  "mature_opt_in": false,
  "age_band": "string",
  "terms_version": "string",
  "terms_accepted_at": "string",
  "current_terms_version": "string",
  "suspended_until": "string",
  "suspension_reason": "string",
  "deletion_grace_hours": 0
}
```

### `POST /profile/update` - Update your profile

Update your own bio and links.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `bio` | form | textarea | no | Bio, up to 500 characters. |
| `location` | form | string | no | Location, up to 200 characters. |
| `git_link` | form | string | no | Git profile URL. |
| `website` | form | string | no | Personal website URL. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": null
}
```

### `POST /profile/{username}/ai-correction` - Configure AI content correction

Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `enabled` | form | boolean | no | true to enable background AI correction, false to disable it. |
| `sync` | form | boolean | no | true to apply the correction synchronously (the save waits), false for background. |
| `prompt` | form | textarea | no | Correction instruction, up to 20000 characters. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": {
    "url": "/profile/bob_test",
    "enabled": true,
    "sync": false,
    "prompt": "Leave literary as is, only do punctuation and casing"
  }
}
```

### `POST /profile/{username}/interactions` - Configure Devii interactive widgets

Enable or disable CA-IWP interactive prompts (ui_prompt) for this account, or reset to the administrator default. Guests always use the site default. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `enabled` | form | boolean | no | true to enable interactive widgets, false to disable. Ignored when reset is true. |
| `reset` | form | boolean | no | true to clear the user override and inherit the administrator default. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": {
    "url": "/profile/bob_test",
    "enabled": true,
    "source": "user",
    "default": true,
    "override": true
  }
}
```

### `POST /profile/{username}/ai-modifier` - Configure the AI modifier

Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `enabled` | form | boolean | no | true to enable the AI modifier, false to disable it. |
| `sync` | form | boolean | no | true to apply the modification synchronously (the save waits), false for background. |
| `prompt` | form | textarea | no | Modifier instruction, up to 20000 characters. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": {
    "url": "/profile/bob_test",
    "enabled": true,
    "sync": true,
    "prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
  }
}
```

### `POST /profile/{username}/telegram` - Pair or unpair Telegram

Request a single-use Telegram pairing code, or unpair the connected account. Send action=request (default) to receive a code valid for a few minutes, or action=unpair to disconnect. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `action` | form | string | no | Either request to issue a pairing code, or unpair to disconnect Telegram. |

**Sample response**

```json
{
  "ok": true,
  "paired": false,
  "code": "1234",
  "expires_at": "2026-06-18T12:05:00+00:00",
  "ttl_minutes": 5
}
```

### `POST /profile/regenerate-api-key` - Regenerate your API key

Issue a new API key and invalidate the current one.

*Minimal role:* Member

> > Running this invalidates the key these documentation panels use. Do it from your [profile page](/profile/YOUR_USERNAME) instead, then reload these docs.

**Sample response**

```json
{
  "api_key": "NEW_UUID"
}
```

### `POST /profile/{username}/award` - Give a member an award

Create a pending award on another member's profile and enqueue image generation.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Receiver username. |
| `description` | json | string | yes | Award message (1-125 characters). |

**Sample response**

```json
{
  "ok": true,
  "data": {
    "award_uid": "AWARD_UID",
    "award_slug": "abc123-great-work"
  }
}
```

### `POST /profile/{username}/regenerate-avatar` - Regenerate a user avatar

Replace the user's avatar with a freshly generated random one.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Allowed for the owner or any admin. |

> > Irreversible: the previous avatar is gone for good and cannot be brought back.

**Sample response**

```json
{
  "ok": true,
  "data": {
    "url": "/profile/YOUR_USERNAME",
    "avatar_seed": "NEW_UUID",
    "avatar_url": "/avatar/multiavatar/NEW_UUID?size=80"
  }
}
```

### `POST /profile/{username}/customization/global` - Toggle site-wide customizations

Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `value` | form | boolean | no | 1 to show your site-wide customizations, 0 to suppress them. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": {
    "url": "/profile/YOUR_USERNAME",
    "cust_disable_global": true
  }
}
```

### `POST /profile/{username}/customization/pagetype` - Toggle per-page customizations

Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `value` | form | boolean | no | 1 to show your per-page customizations, 0 to suppress them. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": {
    "url": "/profile/YOUR_USERNAME",
    "cust_disable_pagetype": true
  }
}
```

### `POST /profile/{username}/notifications` - Toggle a notification preference

Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `notification_type` | form | string | yes | One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen. |
| `channel` | form | string | yes | One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account). |
| `value` | form | boolean | no | 1 to deliver this notification on this channel, 0 to suppress it. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=notifications",
  "data": {
    "notification_type": "vote",
    "channel": "push",
    "value": false
  }
}
```

### `POST /profile/{username}/notifications/reset` - Reset notification preferences

Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=notifications"
}
```

### `POST /media/{uid}/delete` - Delete media

Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Attachment uid, taken from the Media tab response. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=media"
}
```

### `POST /follow/{username}` - Follow a user

Follow another user. Idempotent.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to follow. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": null
}
```

### `POST /follow/unfollow/{username}` - Unfollow a user

Stop following a user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to unfollow. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": null
}
```

### `POST /block/{username}` - Block a user

Block a user. Their posts, comments and messages are hidden from you everywhere except their own profile, and they can no longer create notifications for you. Idempotent.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to block. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test"
}
```

### `POST /block/unblock/{username}` - Unblock a user

Reverse a block. Their content becomes visible again.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to unblock. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test"
}
```

### `POST /mute/{username}` - Mute a user

Mute a user so they can no longer create notifications for you. Their content stays visible. Idempotent.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to mute. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test"
}
```

### `POST /mute/unmute/{username}` - Unmute a user

Reverse a mute. They can create notifications for you again.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to unmute. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test"
}
```

### `GET /profile/{username}/followers` - List followers

List the users who follow a profile, 25 per page. Returns JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `page` | query | integer | no | Page number, 25 per page. |

**Sample response**

```json
{
  "username": "YOUR_USERNAME",
  "mode": "followers",
  "count": 2,
  "page": 1,
  "total_pages": 1,
  "followers": [
    {
      "uid": "UUID",
      "username": "bob_test",
      "bio": "Building things.",
      "is_following": false
    }
  ]
}
```

### `GET /profile/{username}/following` - List following

List the users a profile follows, 25 per page. Returns JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `page` | query | integer | no | Page number, 25 per page. |

**Sample response**

```json
{
  "username": "YOUR_USERNAME",
  "mode": "following",
  "count": 1,
  "page": 1,
  "total_pages": 1,
  "following": [
    {
      "uid": "UUID",
      "username": "alice_test",
      "bio": "",
      "is_following": true
    }
  ]
}
```

### `GET /leaderboard` - View the leaderboard

Top contributors by stars. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "entries": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "stars": 0,
      "level": 0,
      "rank": 0
    }
  ],
  "user_rank": 0,
  "total_members": 0,
  "posts_today": 0,
  "total_projects": 0,
  "total_gists": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "xp_progress_pct": 0,
      "xp_next_level": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "last_seen": "string"
    }
  ],
  "featured_news": [
    {
      "uid": "UID",
      "slug": "slug",
      "title": "Title",
      "description": "text",
      "content": "text",
      "url": "/path",
      "source_name": "string",
      "author": "string",
      "grade": 0,
      "ai_grade": 0,
      "status": "published",
      "image_url": "/path",
      "featured": 0,
      "has_unique_image": 0,
      "article_published": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "synced_at": "2026-01-01T00:00:00+00:00"
    }
  ]
}
```

### `GET /awards/{slug_or_uid}/{size}` - Award image redirect

Redirect to the stored PNG attachment for a published award.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug_or_uid` | path | string | yes | Award slug or bare uid. |
| `size` | path | enum | yes | Image size. Allowed: 512, 256, 64. |

> > Pending or revoked awards return 404.

> > Response includes long-lived cache headers.

### `GET /avatar/{style}/{seed}` - Generate an avatar

Deterministic SVG avatar for a seed. Returns an image.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `style` | path | enum | yes | Avatar style. Allowed: multiavatar. |
| `seed` | path | string | yes | Seed string, usually a username. |
| `size` | query | int | no | Pixel size. |

---

<a id="doc-messaging"></a>
# Messaging

Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](#doc-lookups) and attach files via [Uploads](#doc-uploads).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /messages` - Open the inbox

Render conversations. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `with_uid` | query | string | no | Open a specific conversation by user UID. |
| `search` | query | string | no | Jump to a conversation by username. |

**Sample response**

```json
{
  "conversations": [
    {
      "other_user": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "last_message": "string",
      "last_message_at": "string",
      "unread": false
    }
  ],
  "messages": [
    {
      "message": {
        "uid": "UID",
        "sender_uid": "UID",
        "receiver_uid": "UID",
        "content": "text",
        "read": false,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "sender": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "xp_progress_pct": 0,
        "xp_next_level": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "last_seen": "string"
      },
      "is_mine": false,
      "time_ago": "2 hours ago",
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ],
      "grouped": false
    }
  ],
  "other_user": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "xp_progress_pct": 0,
    "xp_next_level": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "last_seen": "string"
  },
  "current_conversation": "string",
  "search": "string",
  "other_online": false,
  "other_last_seen": "string"
}
```

### `POST /messages/send` - Send a message

Send a direct message to a user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | no | Body, 0-2000 characters. May be empty when at least one attachment is provided. |
| `receiver_uid` | form | string | yes | Recipient user UID. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/messages?with_uid=RECEIVER_UID",
  "data": {
    "uid": "MESSAGE_UID"
  }
}
```

### `GET /messages/conversations` - List conversations

Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.

*Minimal role:* Member

**Sample response**

```json
{
  "conversations": [
    {
      "other_user": {
        "uid": "8f14e45f-...",
        "username": "alice_test"
      },
      "last_message": "Hello there.",
      "last_message_at": "2026-07-21T10:00:00+00:00",
      "unread": true
    }
  ]
}
```

### `POST /messages/ws-ticket` - Issue a WebSocket ticket

Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).

*Minimal role:* Member

> The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.

**Sample response**

```json
{
  "ticket": "3f9c2a...",
  "expires_in": 30
}
```

---

<a id="doc-moderation"></a>
# Reporting & Moderation

Every externally visible surface on DevPlace is reportable through one polymorphic
endpoint, and every report lands in one queue with one state machine. The reason
list is served by `GET /reports/reasons`, so a native client renders the same
dialog the web UI does.

DevPlace commits to reviewing every report within the window published on the
[content moderation](#doc-content-moderation) page. Filing a report always
returns an acknowledgement carrying that window.

The moderation endpoints under `/admin/moderation` are administrator-only and are
subject to the admin seniority rule: a junior administrator cannot action a more
senior one.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /reports/reasons` - List report reasons

The reason keys a report may be filed under, with their labels.

*Minimal role:* Public

**Sample response**

```json
{
  "reasons": [
    {
      "key": "harassment",
      "label": "Harassment or bullying"
    }
  ],
  "severities": [
    "info",
    "warn",
    "critical"
  ]
}
```

### `POST /reports/{target_type}/{target_uid}` - Report content

File a report against any user-generated surface.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | The kind of content being reported. Allowed: post, comment, gist, project, project_file, news, attachment, message, quiz, poll, award, user, issue, workspace, devii_output. |
| `target_uid` | path | string | yes | UID of the reported item. |
| `reason` | form | enum | yes | Why the content breaks the guidelines. Allowed: hate, violence, weapons, sexual, religious, misinformation, exploitative, harassment, spam, intellectual_property, self_harm, illegal, other. |
| `detail` | form | string | no | Free text for the moderator, up to 2000 characters. |

> A second report on the same target by the same reporter updates the open report instead of creating a duplicate.

> You cannot report your own content.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/reports/mine",
  "data": {
    "uid": "REPORT_UID",
    "status": "open",
    "severity": "warn",
    "sla_hours": 24
  }
}
```

### `GET /reports/mine` - List your reports

The reports you filed and the outcome of each.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `status` | query | enum | no | Filter by report status. Allowed: open, acknowledged, actioned, dismissed. |
| `page` | query | integer | no | Page number. |

**Sample response**

```json
{
  "reports": [
    {
      "uid": "REPORT_UID",
      "target_type": "post",
      "target_uid": "POST_UID",
      "target_url": "/posts/a-post",
      "reason": "harassment",
      "reason_label": "Harassment or bullying",
      "detail": "Repeated personal attacks in the thread.",
      "severity": "warn",
      "status": "open",
      "origin": "member",
      "categories": [],
      "created_at": "2026-01-05T10:00:00+00:00",
      "resolved_at": "",
      "reporter_name": "alice",
      "owner_name": "bob",
      "report_count": 2
    }
  ],
  "pagination": {
    "page": 1,
    "total": 1,
    "total_pages": 1
  },
  "status": ""
}
```

### `GET /admin/moderation` - The moderation queue

Reported content awaiting a decision, oldest open first.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `status` | query | enum | no | Filter by report status. Allowed: open, acknowledged, actioned, dismissed. |
| `page` | query | integer | no | Page number. |

**Sample response**

```json
{
  "reports": [
    {
      "uid": "REPORT_UID",
      "target_type": "post",
      "target_uid": "POST_UID",
      "target_url": "/posts/a-post",
      "reason": "harassment",
      "reason_label": "Harassment or bullying",
      "detail": "Repeated personal attacks in the thread.",
      "severity": "warn",
      "status": "open",
      "origin": "member",
      "categories": [],
      "created_at": "2026-01-05T10:00:00+00:00",
      "resolved_at": "",
      "reporter_name": "alice",
      "owner_name": "bob",
      "report_count": 2
    }
  ],
  "counts": {
    "open": 1,
    "acknowledged": 0,
    "actioned": 0,
    "dismissed": 0
  },
  "sla": {
    "sla_hours": 24,
    "oldest_open_hours": 1.5,
    "breached": 0,
    "within_sla": true
  }
}
```

### `GET /admin/moderation/{uid}` - Read one report

One report with its decisions and the author's history.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Report UID. |

**Sample response**

```json
{
  "report": {
    "uid": "REPORT_UID",
    "target_type": "post",
    "target_uid": "POST_UID",
    "target_url": "/posts/a-post",
    "reason": "harassment",
    "reason_label": "Harassment or bullying",
    "detail": "Repeated personal attacks in the thread.",
    "severity": "warn",
    "status": "open",
    "origin": "member",
    "categories": [],
    "created_at": "2026-01-05T10:00:00+00:00",
    "resolved_at": "",
    "reporter_name": "alice",
    "owner_name": "bob",
    "report_count": 2
  },
  "actions": [],
  "history": [],
  "available_actions": [
    "remove_content",
    "restore_content",
    "warn",
    "suspend",
    "ban",
    "lift",
    "dismiss",
    "escalate"
  ],
  "can_remove": true
}
```

### `POST /admin/moderation/{uid}/status` - Set a report status

Move a report through the triage state machine.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Report UID. |
| `status` | form | enum | yes | New status. Allowed: open, acknowledged, actioned, dismissed. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/admin/moderation/REPORT_UID"
}
```

### `POST /admin/moderation/{uid}/decide` - Decide a report

Apply a moderation decision and notify the affected user.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Report UID. |
| `action` | form | enum | yes | The decision to apply. Allowed: remove_content, restore_content, warn, suspend, ban, lift, dismiss, escalate. |
| `reason` | form | string | no | Reason shown to the affected user. |
| `notes` | form | string | no | Internal notes. |
| `duration_hours` | form | integer | no | Suspension length in hours. |

> A report already resolved by another moderator answers 409.

> Content removal is unavailable for targets that have no removal path (direct messages, accounts, workspaces, polls, assistant output); act on the account instead.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/admin/moderation/REPORT_UID"
}
```

### `POST /admin/users/{uid}/suspend` - Suspend an account

Suspend an account for a fixed period with a stated reason.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | User UID. |
| `reason` | form | string | no | Reason shown to the user. |
| `duration_hours` | form | integer | no | Suspension length in hours. |

> A suspended account can still read, still see why, and still delete itself, but cannot create content.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/admin/users"
}
```

### `POST /admin/users/{uid}/lift` - Lift a restriction

Clear a suspension or ban and restore the account.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | User UID. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/admin/users"
}
```

### `POST /admin/users/{uid}/ban` - Ban an account

Permanently close an account and revoke every credential.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | User UID. |
| `reason` | form | string | no | Reason shown to the user. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/admin/users"
}
```

### `GET /auth/accept-terms` - The terms acceptance page

The Terms of Service version in force and the version this account accepted.

*Minimal role:* Member

**Sample response**

```json
{
  "terms_version": "1",
  "accepted_version": ""
}
```

### `POST /auth/accept-terms` - Accept the terms

Record acceptance of the Terms of Service version in force.

*Minimal role:* Member

> A member whose accepted version is behind the version in force is redirected here on any mutating request. Reading, the docs, the safety controls and account deletion are never blocked.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": {
    "terms_version": "1"
  }
}
```

### `POST /profile/{username}/consent` - Grant or withdraw a consent

Change one consent on your own account. Withdrawal takes effect at once.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Your own username. |
| `kind` | form | enum | yes | Consent to change. Allowed: terms, privacy, ai_third_party, activity_recording, container_credentials. |
| `granted` | form | enum | yes | 1 grants, 0 withdraws. Allowed: 1, 0. |

> Withdrawing `ai_third_party` makes the AI gateway refuse every call that would send your own content to the provider, whatever the per-feature preference says.

> Withdrawing `activity_recording` stops presence writes; you simply appear offline.

> Only the account holder can change a consent. An administrator reads the record but never grants or withdraws it for someone else.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/USERNAME?tab=privacy",
  "data": {
    "kind": "ai_third_party",
    "state": "granted"
  }
}
```

### `POST /profile/{username}/mature-content` - Set the mature-content preference

Show or hide content labelled mature for your own account.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Your own username. |
| `mature_opt_in` | form | enum | yes | 1 shows mature content, 0 hides it. Allowed: 1, 0. |

> Only the account holder can change this preference. An administrator reads the privacy tab but never sets it for someone else.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/USERNAME?tab=privacy",
  "data": {
    "mature_opt_in": true
  }
}
```

### `GET /profile/{username}/delete` - Account deletion page

What deletion removes, what is retained, and the grace window.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Your own username. |

**Sample response**

```json
{
  "username": "USERNAME",
  "grace_hours": 24,
  "removed": [
    "Your account record, username, email address and password"
  ],
  "retained": [
    "Append-only audit and moderation records"
  ]
}
```

### `POST /profile/{username}/delete` - Delete your account

Permanently delete your account and personal data.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Your own username. |
| `password` | form | string | yes | Your account password. |

> Only the account holder can delete an account; an administrator uses a ban instead.

> Sessions and tokens are revoked and the profile is anonymised immediately; the deletion event is purged after the grace window.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/",
  "data": {
    "stamp": "2026-01-05T10:00:00+00:00",
    "rows": 42,
    "grace_hours": 24
  }
}
```

### `GET /workspaces/index` - Published workspace index

Every workspace published to the public ingress, with its link.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `page` | query | integer | no | Page number. |

> The project-derived `description` and `project_url` come back empty unless you may view the workspace's project, so a private project never leaks its title or description through this public listing.

**Sample response**

```json
{
  "workspaces": [
    {
      "uid": "INSTANCE_UID",
      "name": "demo",
      "slug": "demo",
      "owner_uid": "USER_UID",
      "url": "https://devplace.net/p/demo",
      "description": "A demo workspace.",
      "owner": "alice",
      "maturity": "general",
      "project_url": "/projects/demo"
    }
  ],
  "total": 1
}
```

---

<a id="doc-notifications"></a>
# Notifications

Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](#doc-push).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /notifications` - View notifications

Render your notifications. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "notification_groups": [
    {
      "label": "string",
      "entries": [
        {
          "notification": {
            "uid": "UID",
            "type": "string",
            "message": "string",
            "read": false,
            "related_uid": "UID",
            "target_url": "/path",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "actor": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "xp_progress_pct": 0,
            "xp_next_level": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00",
            "last_seen": "string"
          },
          "time_ago": "2 hours ago"
        }
      ]
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `GET /notifications/counts` - Unread counts

Unread notification and message counts.

*Minimal role:* Public

> Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.

**Sample response**

```json
{
  "notifications": 2,
  "messages": 1
}
```

### `GET /notifications/open/{notification_uid}` - Open a notification

Mark a notification read and redirect to its target.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `notification_uid` | path | string | yes | UID of the notification. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG#comment-COMMENT_UID",
  "data": null
}
```

### `POST /notifications/mark-read/{notification_uid}` - Mark one read

Mark a single notification as read.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `notification_uid` | path | string | yes | UID of the notification. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/notifications",
  "data": null
}
```

### `POST /notifications/mark-all-read` - Mark all read

Mark every notification as read.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "redirect": "/notifications",
  "data": null
}
```

---

<a id="doc-uploads"></a>
# Uploads

Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
comment, project, gist, message, or issue - see
[Posts, Comments, Projects, Gists & News](#doc-content). Images and videos embed and
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.

You manage your own attachments over the full lifecycle: **list** every file you uploaded, **get**
one by uid, **rename** its display filename, and **delete** it. The list is the same set of
attachments that appear on your posts and other content - listing, renaming, or deleting one is
reflected everywhere it is used.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `POST /uploads/upload` - Upload a file

Store a file and return its attachment record.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `file` | form | file | yes | The file to upload. |

> Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.

> Returns `201` on success, `413` if too large, `415` if the type is not allowed.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "filename": "clip.mp4",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
  "size": 20480,
  "is_image": false,
  "is_video": true,
  "mime_type": "video/mp4"
}
```

### `POST /uploads/upload-url` - Attach a file from a URL

Download a public URL on the server and store it as an attachment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `url` | form | string | yes | Public http(s) URL of the file to download and attach. |
| `filename` | form | string | no | Optional filename with an allowed extension, used when the URL has no clear name. |

> The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.

> The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "filename": "photo.png",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
  "size": 20480,
  "is_image": true,
  "is_video": false,
  "mime_type": "image/png"
}
```

### `GET /uploads` - List your attachments

List every attachment you uploaded, newest first, paginated (24 per page).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `page` | query | integer | no | 1-based page number. |
| `linked` | query | string | no | Filter: `true` returns only attachments already used on a post/comment/project/gist/issue, `false` returns only orphaned uploads. Omit for all. |

> Each item carries `uid`, `original_filename`, `mime_type`, `url`, `file_size`, its `target_type`/`target_uid`/`target_url` when linked, and a `linked` flag.

**Sample response**

```json
{
  "attachments": [
    {
      "uid": "ATTACHMENT_UID",
      "original_filename": "photo.png",
      "file_size": 20480,
      "mime_type": "image/png",
      "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
      "is_image": true,
      "is_video": false,
      "is_audio": false,
      "linked": true,
      "target_type": "post",
      "target_uid": "POST_UID",
      "target_url": "/posts/POST_SLUG",
      "created_at": "2026-01-01T12:00:00+00:00"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 24,
    "total": 1,
    "total_pages": 1
  },
  "total": 1
}
```

### `GET /uploads/{attachment_uid}` - Get one attachment

Fetch the metadata of a single attachment you own; administrators may fetch any user's attachment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `attachment_uid` | path | string | yes | UID of the attachment. |

> Returns `404` if the attachment does not exist, `403` if it is not yours.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "original_filename": "photo.png",
  "file_size": 20480,
  "mime_type": "image/png",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
  "is_image": true,
  "is_video": false,
  "is_audio": false,
  "linked": true,
  "target_type": "post",
  "target_uid": "POST_UID",
  "target_url": "/posts/POST_SLUG",
  "created_at": "2026-01-01T12:00:00+00:00"
}
```

### `PATCH /uploads/{attachment_uid}` - Rename an attachment

Change the display filename of an attachment you own; administrators may rename any user's attachment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `attachment_uid` | path | string | yes | UID of the attachment. |
| `filename` | form | string | yes | New display filename. |

> Only the display filename changes; the stored file and its extension are untouched. The original extension is always preserved, so the file type cannot be altered.

> Returns the updated attachment record. `404` if it does not exist, `403` if it is not yours, `400` for an empty filename.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "original_filename": "renamed.png",
  "file_size": 20480,
  "mime_type": "image/png",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
  "is_image": true,
  "linked": true,
  "target_type": "post",
  "target_uid": "POST_UID",
  "target_url": "/posts/POST_SLUG",
  "created_at": "2026-01-01T12:00:00+00:00"
}
```

### `DELETE /uploads/delete/{attachment_uid}` - Delete an attachment

Remove an attachment you previously uploaded; administrators may remove any user's attachment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `attachment_uid` | path | string | yes | UID of the attachment (the `uid` returned by Upload a file, Attach a file from a URL, or List your attachments). |

> Only the owner may delete their own attachment; an administrator may delete any user's. Deleting one you do not own returns `403`.

> The attachment is removed everywhere at once: it leaves your attachment list (List your attachments) and disappears from every post, comment, project, gist, message, or issue it was attached to, and its file stops being served under `/static/uploads/`.

> Idempotent from the caller's view: an already-removed or unknown uid returns `404`. A successful delete returns `200` with `{"status": "deleted"}`.

> To detach a file from a single post/comment without removing the upload itself, edit that object's attachment list instead - deleting here removes the attachment from every place it is used.

**Sample response**

```json
{
  "status": "deleted"
}
```

---

<a id="doc-project-files"></a>
# Project Filesystem

Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.

Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, status codes); see [Authentication](#doc-authentication) for the four ways to
sign requests.

### `GET /projects/{project_slug}/files` - List a project's files

Return the flat list of files and directories in a project.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "project": {
    "uid": "PROJECT_UID",
    "slug": "PROJECT_SLUG"
  },
  "files": [
    {
      "path": "src/main.py",
      "name": "main.py",
      "type": "file",
      "is_binary": false,
      "size": 42
    }
  ],
  "is_owner": false
}
```

### `GET /projects/{project_slug}/files/raw` - Read a project file

Return one file's metadata and (for text files) its content.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | yes | Relative file path inside the project. |

**Sample response**

```json
{
  "path": "src/main.py",
  "name": "main.py",
  "type": "file",
  "is_binary": false,
  "mime_type": "text/plain",
  "size": 42,
  "url": null,
  "content": "print('hello')\n"
}
```

### `POST /projects/{project_slug}/files/write` - Write a text file

Create or overwrite a text file; parent directories are created automatically.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `content` | form | textarea | yes | Full file content (max 400000 chars). |

> Owner only; non-owners get `403`. Invalid paths return `400`.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `GET /projects/{project_slug}/files/lines` - Read a line range

Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | yes | Relative file path. |
| `start` | query | integer | no | First line, 1-indexed (default 1). |
| `end` | query | integer | no | Last line inclusive; omit or -1 for end of file. |

> Text files only; binary, directory, or missing paths return `404`.

**Sample response**

```json
{
  "path": "src/main.py",
  "start": 1,
  "end": 2,
  "total_lines": 2,
  "lines": [
    "import os",
    "print(os.getcwd())"
  ],
  "content": "import os\nprint(os.getcwd())"
}
```

### `POST /projects/{project_slug}/files/replace-lines` - Replace a line range

Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `start` | form | integer | yes | First line to replace (1-indexed). |
| `end` | form | integer | yes | Last line to replace (inclusive). |
| `content` | form | textarea | no | Replacement text (empty deletes the range). |

> Owner only. The preferred way to edit a large file; avoids rewriting the whole file.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/insert-lines` - Insert lines

Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `at` | form | integer | yes | Insert before this 1-indexed line. |
| `content` | form | textarea | yes | Text to insert. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/delete-lines` - Delete a line range

Delete lines start..end (inclusive) from a text file.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `start` | form | integer | yes | First line to delete (1-indexed). |
| `end` | form | integer | yes | Last line to delete (inclusive). |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/append` - Append to a file

Append content as new lines at the end of a text file; grow a large file across calls without resending it.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `content` | form | textarea | yes | Text to append. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "log.txt",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/upload` - Upload a file into a project

Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `file` | form | file | yes | The file to upload. |
| `path` | form | string | no | Target directory, empty for the root. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "assets/logo.png",
    "type": "file",
    "is_binary": true
  }
}
```

### `POST /projects/{project_slug}/files/mkdir` - Create a directory

Create a directory and any missing parents.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative directory path. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/components",
    "type": "dir"
  }
}
```

### `POST /projects/{project_slug}/files/move` - Move or rename

Move or rename a file or directory (and its descendants).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `from_path` | form | string | yes | Existing path. |
| `to_path` | form | string | yes | New path. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/new.py"
  }
}
```

### `POST /projects/{project_slug}/files/delete` - Delete a file or directory

Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative path to delete. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/old.py"
  }
}
```

### `POST /projects/{project_slug}/files/zip` - Queue a zip of files

Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | no | Relative file or directory to archive; empty for the whole project. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "status_url": "/zips/ZIP_JOB_UID"
}
```

---

<a id="doc-workspaces"></a>
# Dev Workspaces

A workspace is a browser editor attached to one of your projects. It runs your project files, a
terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and `apt install` work
with no extra setup; ports below 1024 cannot bind, so use a high port and publish it through a
tunnel.

The editor opens with a **DevPlace Code** terminal already running the `dpc` coding agent and a
plain shell beside it, and it trusts every folder, so nothing opens in Restricted Mode. Its
appearance and boot behaviour are your own preferences, readable and writable through the two
`/workspace/editor` endpoints below and explained on
[the workspace editor page](#doc-workspace-editor). Editor preferences apply on the next
workspace start.

A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
the link can reach whatever you are serving. Forwarding a port in the editor's **Ports** view creates
the tunnel for you through the same endpoint; un-forwarding it does not remove the tunnel.

Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
arrives as a `workspace` notification and states exactly what happens next and when.

### `GET /projects/{slug}/workspace` - Read workspace

State, quota usage, idle countdown, tunnels and open moderation flags for your workspace on this project.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "has_workspace": true,
  "viewer_can_workspace": true,
  "workspace_count": 1,
  "max_workspaces": 2,
  "editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
  "workspace": {
    "uid": "INSTANCE_UID",
    "status": "running",
    "suspended": false,
    "tunnel_name": "brave-otter",
    "primary_url": "https://brave-otter.tunnel.pravda.education",
    "disk_bytes": 5242880,
    "disk_quota_mb": 2048,
    "disk_percent": 1,
    "egress_bytes": 10240,
    "egress_quota_mb": 10240,
    "egress_percent": 0,
    "idle_stop_minutes": 60,
    "retention_days": 14,
    "max_tunnels": 5,
    "tunnels": [],
    "flags": []
  }
}
```

### `POST /projects/{slug}/workspace` - Open or resume workspace

Create the workspace if you have none for this project, otherwise resume it. Idempotent. Refused when you are at your workspace limit, over disk quota, or suspended.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/my-project/workspace"
}
```

### `POST /projects/{slug}/workspace/stop` - Stop workspace

Stop the container. Files and tunnels are kept.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/my-project/workspace"
}
```

### `POST /projects/{slug}/workspace/delete` - Delete workspace

Remove the workspace and its tunnels. An administrator can restore it.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/my-project/workspace"
}
```

### `GET /projects/{slug}/workspace/editor` - Read editor profile

The resolved DevPlace editor profile for this workspace: theme, layout, panel preset, font sizes, zoom, boot terminals, how the editor opens, the container size, and where each value comes from.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "editor": {
    "trust_all": true,
    "theme": "devplace-dark",
    "font_size": 14,
    "terminal_font_size": 13,
    "zoom_level": 0,
    "layout": "standard",
    "panel_preset": "tall",
    "boot_agent": "dpc",
    "boot_shell": true,
    "window_mode": "tab",
    "window_width": 1600,
    "window_height": 1000,
    "cpu_millicores": 2000,
    "cpu_cores": 2.0,
    "memory_mb": 2048,
    "disk_quota_mb": 2048,
    "sources": {
      "theme": "user",
      "font_size": "site"
    }
  },
  "restart_required": false
}
```

### `POST /projects/{slug}/workspace/editor` - Set editor preferences

Change your own editor preferences. Only the fields you send are changed; within those, an empty string or zero means inherit the site default, and `reset` drops every preference. Applies on the next workspace start, and the response says whether a restart is needed.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |
| `theme` | body | string | no | devplace-dark, devplace-light or system. |
| `layout` | body | string | no | standard, terminal-focus or zen. |
| `panel_preset` | body | string | no | short, normal, tall or maximized. |
| `font_size` | body | integer | no | Editor font size in pixels. Zero inherits. |
| `terminal_font_size` | body | integer | no | Terminal font size in pixels. Zero inherits. |
| `zoom_level` | body | integer | no | Window zoom, -5 to 5. Send -99 to inherit. |
| `boot_agent` | body | string | no | dpc or none. |
| `boot_shell` | body | integer | no | 1 opens a shell on boot, 0 skips it, -1 inherits. |
| `window_mode` | body | string | no | tab, window or fullscreen. |
| `window_width` | body | integer | no | Editor window width in pixels. Zero inherits. |
| `window_height` | body | integer | no | Editor window height in pixels. Zero inherits. |
| `reset` | body | boolean | no | Drop every preference and fall back to the site defaults. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/my-project/workspace",
  "data": {
    "restart_required": true
  }
}
```

### `GET /projects/{slug}/workspace/tunnels` - List tunnels

Every public tunnel published by this workspace.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "tunnels": [
    {
      "uid": "TUNNEL_UID",
      "hostname": "8080-brave-otter.tunnel.pravda.education",
      "label": "web",
      "container_port": 8080,
      "status": "active",
      "cert_status": "valid",
      "request_count": 12,
      "bytes_out": 40960
    }
  ]
}
```

### `POST /projects/{slug}/workspace/tunnels` - Create tunnel

Publish a container port on a public HTTPS hostname. The URL is public and unauthenticated. Refused past the tunnel limit. The certificate is ordered right away, so the hostname answers plain HTTP for a few seconds before it serves HTTPS. Forwarding a port in the editor calls this for you.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |
| `container_port` | body | integer | yes | Port inside the container. |
| `label` | body | string | no | Human label. |

**Sample response**

```json
{
  "ok": true,
  "data": {
    "uid": "TUNNEL_UID",
    "hostname": "8080-brave-otter.tunnel.pravda.education",
    "status": "pending"
  }
}
```

### `POST /projects/{slug}/workspace/tunnels/{uid}/delete` - Delete tunnel

Remove a tunnel. The public URL stops serving immediately.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Project slug or uid. |
| `uid` | path | string | yes | Tunnel uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/my-project/workspace"
}
```

---

<a id="doc-tools"></a>
# Tools: SEO Diagnostics & DeepSearch

Two public developer tools that run as background jobs.

**SEO Diagnostics** audits a URL or sitemap with a headless browser and runs a broad battery of
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks.

**DeepSearch** is a multi-agent deep web researcher that crawls and indexes sources, then
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
the results.

**AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted
work or genuine human work, and publishes a persistent report with an embeddable authenticity
badge.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions). These are
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
status and report.

### `POST /tools/seo/run` - Queue an SEO audit

Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `url` | form | string | yes | Page URL or sitemap.xml URL to audit. |
| `mode` | form | enum | no | 'url' (single page) or 'sitemap' (crawl). Allowed: url, sitemap. |
| `max_pages` | form | integer | no | Max pages to crawl in sitemap mode (1-50). |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "status_url": "/tools/seo/SEO_JOB_UID",
  "ws_url": "/tools/seo/SEO_JOB_UID/ws"
}
```

### `GET /tools/seo/{uid}` - SEO audit status

Poll an SEO audit. Once done, score, grade and report_url are populated.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid returned when the audit was queued. |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "kind": "seo",
  "status": "done",
  "target": "https://example.com",
  "mode": "url",
  "ws_url": "/tools/seo/SEO_JOB_UID/ws",
  "report_url": "/tools/seo/SEO_JOB_UID/report",
  "score": 82,
  "grade": "B",
  "page_count": 1,
  "error": null,
  "created_at": "2026-06-14T10:00:00+00:00",
  "completed_at": "2026-06-14T10:00:18+00:00"
}
```

### `GET /tools/seo/{uid}/report` - SEO audit report

Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid of a finished audit. |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "status": "done",
  "target": "https://example.com",
  "score": 82,
  "grade": "B",
  "page_count": 1,
  "counts": {
    "pass": 40,
    "warn": 8,
    "fail": 3,
    "info": 5,
    "skip": 0
  },
  "categories": {
    "crawlability": {
      "score": 90,
      "pass": 9,
      "warn": 1,
      "fail": 0
    }
  },
  "pages": [
    {
      "url": "https://example.com",
      "status": 200,
      "score": 82,
      "grade": "B"
    }
  ],
  "checks": [
    {
      "id": "meta.title_present",
      "category": "meta",
      "title": "Title tag",
      "status": "pass",
      "severity": "high",
      "value": "Example Domain",
      "recommendation": "",
      "url": "https://example.com"
    }
  ],
  "site": {
    "robots": {
      "status": 200
    },
    "sitemap": {
      "status": 200,
      "url_count": 12
    }
  },
  "generated_at": "2026-06-14T10:00:18+00:00"
}
```

### `GET /tools/seo/{uid}/screenshot/{index}` - SEO audit page screenshot

Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid of a finished audit. |
| `index` | path | integer | yes | Zero-based index of the audited page. |

### `GET /tools/seo-meta/{target_type}/{target_uid}` - Generated SEO metadata for a content item

Read the clean, AI-generated SEO title, description and keywords for a published post, project, gist, news article or issue. Returns a plain-content default with status 'pending' until the AI value is ready.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Content type. Allowed: post, project, gist, news, issue. |
| `target_uid` | path | string | yes | The content uid (or issue number). |

**Sample response**

```json
{
  "uid": "SEO_META_UID",
  "target_type": "post",
  "target_uid": "CONTENT_UID",
  "seo_title": "Building a fast SQLite social network",
  "seo_description": "How DevPlace keeps SQLite synchronous and still serves a developer social network fast, with WAL, mmap and batch query helpers.",
  "seo_keywords": "sqlite, fastapi, social network, performance, wal",
  "status": "ready",
  "source": "ai",
  "generated_at": "2026-06-14T10:00:18+00:00"
}
```

### `POST /tools/deepsearch/run` - Queue a DeepSearch research job

Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs. Connect ws_url for live progress frames.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `query` | form | string | yes | The research question to investigate. |
| `depth` | form | integer | no | Research depth (1-4). |
| `max_pages` | form | integer | no | Maximum sources to crawl (1-30). |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
  "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
  "progress_frames_note": "The ws_url stream emits newline-delimited JSON frames; the set is append-only and the first frame carries version:1. Each frame has a type: phase (phase, index, total, label), stage, substep (planning angles), queries, candidates, rsearch, progress (done, total, url), page_loaded (source, render, elapsed_ms, done, total), page_cached, page_skipped (reason), page_duplicate, embed_batch (batch, total_batches, backend, done, total), embed_done (backend, chunk_count), agent (agent, status start|done, elapsed_ms, tokens_in, tokens_out), report_ready, done (session_url), failed (message)."
}
```

### `GET /tools/deepsearch/{uid}` - DeepSearch status

Poll a DeepSearch job. Once done, score, confidence and session_url are populated.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | DeepSearch job uid returned when the run was queued. |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "kind": "deepsearch",
  "status": "done",
  "query": "history of the transistor",
  "depth": 2,
  "max_pages": 12,
  "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
  "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
  "session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
  "score": 78,
  "confidence": 0.72,
  "source_diversity": 0.64,
  "page_count": 11,
  "chunk_count": 240,
  "error": null,
  "created_at": "2026-06-14T10:00:00+00:00",
  "completed_at": "2026-06-14T10:01:40+00:00"
}
```

### `GET /tools/deepsearch/{uid}/session` - DeepSearch report

Full cited research report: summary, findings, sources and metrics. Negotiates HTML or JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | DeepSearch job uid of a finished run. |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "status": "done",
  "query": "history of the transistor",
  "score": 78,
  "confidence": 0.72,
  "source_diversity": 0.64,
  "page_count": 11,
  "chunk_count": 240,
  "summary": "The transistor was invented at Bell Labs in 1947...",
  "findings": [
    {
      "title": "Invention",
      "detail": "...",
      "confidence": 0.8,
      "citations": [
        1
      ]
    }
  ],
  "sources": [
    {
      "url": "https://example.com",
      "title": "Example",
      "source": "httpx"
    }
  ],
  "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
  "export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
  "export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
  "export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf"
}
```

### `POST /tools/isslop/run` - Queue a AI usage analysis

Start a background authenticity analysis of a git repository or website. Returns the job uid plus status, events and report URLs.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `url` | form | string | yes | Repository (http/git/ssh) or website URL to classify. |

**Sample response**

```json
{
  "uid": "ISSLOP_UID",
  "status_url": "/tools/isslop/ISSLOP_UID",
  "events_url": "/tools/isslop/ISSLOP_UID/events",
  "report_url": "/tools/isslop/ISSLOP_UID/report",
  "topic": "public.isslop.ISSLOP_UID"
}
```

### `GET /tools/isslop/list` - My AI usage analyses

List the caller's analyses, newest first. Member history is account-bound; guest history is session-bound and claimed by the account on first signed-in call.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `limit` | query | integer | no | Maximum analyses to return (1-200). |

**Sample response**

```json
{
  "analyses": [
    {
      "uid": "ISSLOP_UID",
      "status": "completed",
      "source_url": "https://github.com/owner/repository",
      "source_kind": "git",
      "grade": "B",
      "human_percent": 71.4,
      "ai_percent": 28.6,
      "category": "human-clean",
      "report_url": "/tools/isslop/ISSLOP_UID/report",
      "badge_url": "/tools/isslop/ISSLOP_UID/badge.svg"
    }
  ]
}
```

### `GET /tools/isslop/{uid}` - AI usage analysis status

Poll an analysis. Once completed, grade, category and the human/AI split are populated.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid returned when the run was queued. |

**Sample response**

```json
{
  "uid": "ISSLOP_UID",
  "status": "completed",
  "source_url": "https://github.com/owner/repository",
  "source_kind": "git",
  "grade": "B",
  "slop_score": 31.2,
  "origin_score": 28.0,
  "quality_deficit_score": 22.5,
  "human_percent": 71.4,
  "ai_percent": 28.6,
  "category": "human-clean",
  "confidence": "medium",
  "files_total": 120,
  "files_analyzed": 96,
  "report_url": "/tools/isslop/ISSLOP_UID/report",
  "badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
  "events_url": "/tools/isslop/ISSLOP_UID/events",
  "topic": "public.isslop.ISSLOP_UID"
}
```

### `GET /tools/isslop/{uid}/events` - AI usage analysis event trail

The persisted, ordered event trail of an analysis. Use ?after=SEQ to poll incrementally; live frames also stream on the pub/sub topic.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid. |
| `after` | query | integer | no | Return only events with a sequence number greater than this. |
| `limit` | query | integer | no | Maximum events to return (1-5000). |

**Sample response**

```json
{
  "uid": "ISSLOP_UID",
  "status": "running",
  "events": [
    {
      "seq": 1,
      "kind": "stage",
      "message": "Resolving source type",
      "data": {
        "stage": "resolve"
      },
      "created_at": "2026-06-14T10:00:00+00:00"
    }
  ]
}
```

### `GET /tools/isslop/{uid}/report` - AI usage analysis report

Full report: verdict, markdown body, per-file results, image review and badge embeds. Negotiates HTML or JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid of a finished run. |

**Sample response**

```json
{
  "uid": "ISSLOP_UID",
  "status": "completed",
  "source_url": "https://github.com/owner/repository",
  "grade": "B",
  "human_percent": 71.4,
  "ai_percent": 28.6,
  "category": "human-clean",
  "markdown": "# Verdict...",
  "generator_model": "molodetz",
  "badge": {
    "badge_url": "https://devplace.example/tools/isslop/ISSLOP_UID/badge.svg",
    "report_url": "https://devplace.example/tools/isslop/ISSLOP_UID/report",
    "markdown": "[![authenticity human score](...)](...)",
    "html": "<a href=...><img src=.../></a>"
  },
  "files": [
    {
      "path": "src/main.py",
      "language": "python",
      "lines": 120,
      "origin_score": 35.0,
      "quality_deficit_score": 18.0,
      "category": "human-clean",
      "signals": []
    }
  ],
  "images": [
    {
      "path": "assets/hero.png",
      "ai_probability": 84.0,
      "grade": "F",
      "verdict": "ai-generated",
      "image_kind": "illustration",
      "tells": [
        "waxy skin"
      ],
      "description": "...",
      "thumb_url": "/tools/isslop/ISSLOP_UID/media/0f3a9c2d1b4e5a67.webp"
    }
  ]
}
```

### `GET /tools/isslop/{uid}/report.md` - Download report markdown

Download the full report as a markdown file.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid of a finished run. |

### `GET /tools/isslop/{uid}/source` - Annotated source of a flagged file

The persisted source of a signal-bearing file with its signals, rendered with line numbers and highlighted findings (HTML) or as JSON. Linked from the report's file table, signal chips and prose.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid. |
| `path` | query | string | yes | Workspace-relative file path from the report. |
| `line` | query | integer | no | Line to focus and highlight. |

**Sample response**

```json
{
  "uid": "ISSLOP_UID",
  "path": "src/libs/Env.ts",
  "language": "typescript",
  "category": "human-clean",
  "origin_score": 24.0,
  "quality_deficit_score": 34.9,
  "source": "import { createEnv } from '@t3-oss/env-nextjs';...",
  "truncated": false,
  "signals": [
    {
      "code": "PUBLIC_ENV_SECRET",
      "title": "Secret exposed via public env variable",
      "severity": "strong",
      "line": 12
    }
  ]
}
```

### `GET /tools/isslop/{uid}/media/{name}` - Reviewed image thumbnail

Aspect-preserving WebP thumbnail of a reviewed image, persisted as evidence. The name comes from the report's images[].thumb_url.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid. |
| `name` | path | string | yes | Thumbnail file name from the report. |

### `GET /tools/isslop/{uid}/badge.svg` - Authenticity badge

Embeddable SVG badge showing the human score and authenticity grade, linking to the report.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Analysis uid. |

---

<a id="doc-push"></a>
# Web Push

Push notifications are delivered by one or more providers. `webpush` is the default and
implements the Web Push protocol: fetch the public VAPID key, then register a
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
Push Notification service device token and is only offered when an administrator has
configured it.

`GET /push.json` lists the providers that currently accept registrations. A registration
body without a `provider` field is a `webpush` registration, so existing clients need no
change.

There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](#doc-notifications) feed.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /push.json` - Get the public key

Return the VAPID public key and the providers that accept registrations.

*Minimal role:* Public

**Sample response**

```json
{
  "publicKey": "BASE64_VAPID_KEY",
  "providers": {
    "webpush": {
      "publicKey": "BASE64_VAPID_KEY"
    }
  }
}
```

### `POST /push.json` - Register a subscription

Register a push subscription. Sends a welcome notification.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `provider` | json | string | no | Provider to register with. Omit for webpush. |
| `endpoint` | json | string | no | Subscription endpoint URL. Required for webpush. |
| `keys` | json | string | no | Subscription keys object. Required for webpush. |
| `token` | json | string | no | Hexadecimal device token. Required for apns. |

> A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.

> An APNs body is JSON: `{"provider": "apns", "token": "..."}`.

> A provider that is unknown, disabled or unconfigured returns 400.

**Sample response**

```json
{
  "registered": true
}
```

---

<a id="doc-issues"></a>
# Issue Reports

The issue tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /issues` - List issue tickets

Render the issue board from Gitea, paginated and filterable by state.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `state` | query | string | no | Filter: open (default), closed, or all. |
| `page` | query | integer | no | 1-based page. |

**Sample response**

```json
{
  "issues": [
    {
      "number": 0,
      "title": "Title",
      "state": "string",
      "html_url": "/path",
      "comments_count": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00",
      "author_username": "username",
      "author_uid": "UID",
      "author_avatar_seed": "string",
      "is_local_author": false
    }
  ],
  "pagination": null,
  "state": "string",
  "configured": false,
  "error_message": "string"
}
```

### `POST /issues/create` - Report an issue

Enqueue an issue report. It is enhanced by AI and filed on the tracker.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |

> Returns a job uid and status_url. Poll the status_url until status is done to get the issue number.

**Sample response**

```json
{
  "uid": "JOB_UID",
  "status_url": "/issues/jobs/JOB_UID"
}
```

### `GET /issues/jobs/{uid}` - Issue filing job status

Poll the filing job; the result carries the new issue number and url.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Job uid. |

**Sample response**

```json
{
  "uid": "JOB_UID",
  "kind": "issue_create",
  "status": "done",
  "number": 42,
  "issue_url": "/issues/42",
  "enhanced": true,
  "error": null,
  "created_at": "2026-06-12T09:00:00+00:00",
  "completed_at": "2026-06-12T09:00:03+00:00"
}
```

### `POST /issues/planning` - Generate a tickets planning report

Enqueue a phased markdown implementation document for open tickets, with each ticket's full description reproduced verbatim (inline plus a Source Tickets appendix) alongside implementation steps and acceptance criteria. Admin only.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `numbers` | form | string | no | Comma-separated issue numbers to include. Omit to plan every open ticket. |

> Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it.

> Provide 'numbers' to plan only a chosen subset of open tickets; omitting it plans all open tickets.

**Sample response**

```json
{
  "uid": "PLANNING_JOB_UID",
  "status_url": "/issues/planning/PLANNING_JOB_UID"
}
```

### `GET /issues/planning/{uid}` - Planning report job status

Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Planning job uid. |

**Sample response**

```json
{
  "uid": "PLANNING_JOB_UID",
  "kind": "planning",
  "status": "done",
  "download_url": "/issues/planning/PLANNING_JOB_UID/download",
  "markdown": "# Open Tickets Implementation Plan\n\n...",
  "ai_used": true,
  "issue_count": 12,
  "bytes_out": 4096,
  "error": null,
  "created_at": "2026-06-15T09:00:00+00:00",
  "completed_at": "2026-06-15T09:00:05+00:00"
}
```

### `GET /issues/planning/{uid}/download` - Download the planning report

Download the generated planning report as a markdown file. Admin only.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Planning job uid. |

### `GET /issues/{number}` - View an issue ticket

Render a Gitea issue and its comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |

**Sample response**

```json
{
  "issue": {
    "number": 0,
    "title": "Title",
    "state": "string",
    "html_url": "/path",
    "comments_count": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00",
    "author_username": "username",
    "author_uid": "UID",
    "author_avatar_seed": "string",
    "is_local_author": false
  },
  "body": "string",
  "comments": [
    {
      "id": 0,
      "body": "string",
      "html_url": "/path",
      "created_at": "2026-01-01T00:00:00+00:00",
      "author_username": "username",
      "author_uid": "UID",
      "author_avatar_seed": "string",
      "is_local_author": false,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00",
          "can_modify": false
        }
      ]
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "can_modify": false
    }
  ],
  "can_comment": false,
  "can_attach": false,
  "viewer_is_admin": false
}
```

### `POST /issues/{number}/comment` - Comment on an issue

Post a comment to the Gitea issue, attributed to the current user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `body` | form | textarea | yes | Comment, 1-5000 characters. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/issues/12",
  "data": {
    "comment_id": 1
  }
}
```

### `POST /issues/{number}/status` - Change an issue status

Open or close the Gitea issue. Admin only.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `status` | form | string | yes | New status: open or closed. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/issues/12",
  "data": {
    "state": "closed"
  }
}
```

### `GET /issues/{number}/attachments` - List issue attachments

Return the files attached to an issue ticket.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |

**Sample response**

```json
{
  "number": 0,
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "can_modify": false
    }
  ]
}
```

### `POST /issues/{number}/attachments` - Attach files to an issue

Link already-uploaded files (from /uploads/upload) to an open issue. The files are also mirrored to the Gitea tracker. Allowed only while the issue is open.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `attachment_uids` | form | string | yes | Comma separated attachment uids returned by /uploads/upload. |

> Only the open issue accepts changes; a closed issue returns 409.

> You can only link your own uploads unless you are an administrator.

### `DELETE /issues/{number}/attachments/{uid}` - Delete an issue attachment

Soft-delete a file from an open issue (owner or administrator) and remove it from the tracker.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `uid` | path | string | yes | Attachment uid. |

### `POST /issues/{number}/comments/{cid}/attachments` - Attach files to an issue comment

Link already-uploaded files to an issue comment (issue must be open). Mirrored to the Gitea comment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `cid` | path | integer | yes | Gitea comment id. |
| `attachment_uids` | form | string | yes | Comma separated attachment uids returned by /uploads/upload. |

### `DELETE /issues/{number}/comments/{cid}/attachments/{uid}` - Delete an issue comment attachment

Soft-delete a file from an issue comment (owner or administrator) and remove it from the tracker.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Issue number. |
| `cid` | path | integer | yes | Gitea comment id. |
| `uid` | path | string | yes | Attachment uid. |

---

<a id="doc-gateway"></a>
# OpenAI Gateway

An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
upstream using the gateway's own credentials, so no DevPlace key is required - but an
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
`https://devplace.net/openai/v1`.

This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
service (news, bots, Devii) calls it by default instead of an external provider, sends the
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
shown. Because `Force model` is on by default, the upstream always receives the configured
model regardless of what a client (or `molodetz`) requests.

The gateway also performs **vision** augmentation: when a request includes an image, the gateway
describes the image with a configured vision model and rewrites it to text, so a vision-less
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.

The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.

The gateway also serves **image generation** at `/openai/v1/images/generations`. Clients request the
generic model `molodetz-img-small`, which the gateway maps to the configured image model (OpenRouter's
Flux 1.1 Pro by default). Cost is tracked per call with a flat per-image price when the upstream
returns no native cost.

## Quick start

Copy the command below and paste it into a terminal. If you are signed in the `YOUR_API_KEY`
and `user-app-v-13.37.0` placeholders are already filled in with your own values; otherwise
replace them with the API key from your [profile](/profile) page and any application identifier.

```bash
curl -X POST "https://devplace.net/openai/v1/chat/completions"   -H "Authorization: Bearer YOUR_API_KEY"   -H "X-App-Reference: user-app-v-13.37.0"   -H "Content-Type: application/json"   -d '{
    "model": "molodetz",
    "messages": [{"role": "user", "content": "Hello, how are you?"}]
  }'
```

The response carries `X-Gateway-*` headers with token counts and dollar cost for the call.
For streaming, add `"stream": true` to the JSON body.

## Model routing and providers

On top of the single default upstream above, an administrator can register additional named
**providers** and map any number of requested **model names** onto them, so one gateway can front
many models across many backends. A model route binds a source model name (what a client sends) to a
target provider and upstream model, and carries:

- its **own pricing economy** (input, output, and cache-hit / cache-miss prices per million tokens),
  used to compute that call's cost when the upstream returns no native cost;
- an optional **vision model**, which turns on the image-to-text merge for that route (so a text-only
  model can answer about images);
- an optional **context window** used for the context-utilization header.

Resolution is transparent to clients: when the requested `model` matches an active route, the gateway
forwards to that route's provider and target model and meters the call against the route's economy.
When it matches no route, the request falls through to the default upstream unchanged (so `molodetz`,
`molodetz~embed`, and any existing client keep working exactly as before). Providers and routes are
managed by administrators on the **Gateway** page (`/admin/gateway`).

## Per-call cost and usage headers

Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
and dollar cost directly from the response with no extra request:

| Header | Meaning |
|--------|---------|
| `X-Gateway-Model` | Upstream model actually used for the call |
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, `image`, or passthrough |
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |

Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
model route, falling back to the prices configured on the `openai` service when no route matches. The
denied paths that make no upstream call (embeddings or image generation disabled) return no usage headers.

## Request header `X-App-Reference`

Clients **SHOULD** send an `X-App-Reference` header to identify themselves for cost attribution.
The value is a free-form slug (max 30 characters, letters, digits, `_`, `.`, `-`). When missing or
invalid, the gateway defaults to `default`. The value is recorded in every usage ledger row and can
be queried alongside owner-kind and owner-id to attribute spending per application.

```
X-App-Reference: devplace-devii-v-1-0-0
```

Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).

The gateway is exempt from rate limiting, but every other endpoint follows the shared
[Conventions & Errors](#doc-conventions); see [Authentication](#doc-authentication)
for signing DevPlace's own requests.

### `POST /openai/v1/chat/completions` - Chat completions

OpenAI-compatible chat completion. Supports streaming.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `model` | json | string | no | Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it falls back to the configured default upstream model. |
| `messages` | json | string | yes | Chat messages array. |
| `stream` | json | string | no | Set true for a streamed SSE response. |

> Returns `503` when the gateway service is not running.

> Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.

> If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).

### `POST /openai/v1/embeddings` - Embeddings

OpenAI-compatible text embeddings. Request model molodetz~embed.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `model` | json | string | no | Embedding model id; the gateway maps molodetz~embed to the configured model, or to a matching embed model route's provider and target model. |
| `input` | json | string | yes | String or array of strings to embed. |
| `dimensions` | json | string | no | Optional output vector size (Matryoshka, 32-4096). |

> Returns `503` when the gateway service is not running or embeddings are disabled.

> Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).

> If `model` matches a configured embed model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default embedding model.

### `POST /openai/v1/images/generations` - Image generation

OpenAI-compatible image generation. Request model molodetz-img-small.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `model` | json | string | no | Image model id; the gateway maps molodetz-img-small to the configured model, or to a matching image model route's provider and target model. |
| `prompt` | json | string | yes | Text prompt describing the image to generate. |
| `size` | json | string | no | Output dimensions (provider-dependent). |
| `response_format` | json | string | no | Return format: url or b64_json. |

> Returns `503` when the gateway service is not running or image generation is disabled.

> Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).

> If `model` matches a configured image model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default image model.

### `POST /openai/v1/{path}` - Passthrough

Any other /v1 path is forwarded to the upstream as-is.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `path` | path | string | yes | Upstream API path after /v1/. |

---

<a id="doc-game"></a>
# Code Farm

The Code Farm is a cooperative idle game. Each member owns a farm of plots, plants software
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.

Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.

All endpoints negotiate HTML or JSON. POST bodies are form encoded
(`application/x-www-form-urlencoded`). Every own-farm action returns `{"ok": true, "farm": {...}}`
- the full updated farm state - so a client can refresh without a second request; the two
neighbour actions (water, steal) return the neighbour's farm as `{"farm": {...}}`, and a
successful steal adds `stole_coins`. An invalid action (not enough coins, wrong plot state, a
protected harvest, an active cooldown) returns HTTP 400 as
`{"error": {"status": 400, "message": "..."}}`; an unknown farm username is 404. Reading your
own farm state also runs lazy owner effects: the CI Bot legacy upgrade auto-harvests ready
builds, and any due Defense upkeep is charged. The complete rules, formulas, and an automated
client are on the [Code Farm guide](#doc-code-farm).

### `GET /game` - Code Farm page

The player's own farm: HUD, plot grid, shop, and leaderboard.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 50,
    "level": 1,
    "plots": []
  }
}
```

### `GET /game/state` - Farm state

The signed-in player's full farm state as JSON. Reading it auto-collects ready builds (with the CI Bot legacy upgrade, reported as auto_harvested/auto_harvest_coins/auto_harvest_xp) and charges any due Defense upkeep.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 50,
    "level": 1,
    "ci_tier": 1,
    "plot_count": 4,
    "prestige": 0,
    "stars": 0,
    "refactor_cost": 20000,
    "plots": [
      {
        "slot": 0,
        "state": "empty",
        "raided_fraction": 0.0
      }
    ],
    "daily_streak_reset": false,
    "contract_boost_seconds_remaining": 0,
    "auto_harvested": 0,
    "steal_max_per_victim_per_day": 3,
    "defense_downgrade_available": false,
    "crops": [
      {
        "key": "python",
        "name": "Python Script",
        "cost": 15,
        "reward_coins": 36,
        "grow_seconds": 120,
        "locked": false,
        "market_state": "normal"
      }
    ]
  }
}
```

### `GET /game/leaderboard` - Farm leaderboard

Top 25 farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid over 30 days, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running). Cached about 15 seconds.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `board` | query | string | no | Leaderboard board key. |

**Sample response**

```json
{
  "entries": [
    {
      "rank": 1,
      "username": "alice",
      "level": 4,
      "xp": 600,
      "coins": 240,
      "total_harvests": 52,
      "prestige": 1,
      "score": 6120,
      "title": "The Architect"
    }
  ]
}
```

### `GET /game/farm/{username}` - View a farm

Another player's farm, with per-plot can_water/can_steal flags computed for the viewer.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Farm owner's username. |

**Sample response**

```json
{
  "farm": {
    "owner_username": "alice",
    "is_owner": false,
    "plots": []
  }
}
```

### `POST /game/plant` - Plant a crop

Plant a crop in an empty plot. Costs the crop's live coin price (the cost field in the farm state's crops list).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slot` | form | integer | yes | Plot slot index, 0-based. |
| `crop` | form | string | yes | Crop key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 35
  }
}
```

### `POST /game/harvest` - Harvest a build

Harvest a finished (state ready) build for coins and XP.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slot` | form | integer | yes | Plot slot index, 0-based. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 86
  }
}
```

### `POST /game/buy-plot` - Buy a plot

Unlock a new plot (up to 12). Cost starts at 100 coins and doubles per extra plot; the exact price is the farm state's next_plot_cost.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "plot_count": 5
  }
}
```

### `POST /game/upgrade` - Upgrade CI

Upgrade the farm CI tier for faster builds (up to tier 5); the exact price is the farm state's ci_next_cost.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "ci_tier": 2
  }
}
```

### `POST /game/farm/{username}/water` - Water a build

Water another player's growing build to cut 8% off its build time; pays the visitor 6 coins scaled by their own prestige and Tech Debt Payoff multiplier, plus 3 XP. Once per visitor per build, 3 waterings per build total.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Farm owner's username. |
| `slot` | form | integer | yes | Plot slot index, 0-based. |

**Sample response**

```json
{
  "farm": {
    "owner_username": "alice"
  }
}
```

### `POST /game/farm/{username}/steal` - Steal a build

Raid another player's ready build once its protection window has passed. You take a share of the build's realized coin value (half by default, less against defended owners - the plot's steal_coins field is the exact payout) and the owner keeps and can still harvest the remainder; the plot records the share taken as raided_fraction. Limited to once per hour per neighbour and 3 raids per victim per day; a fully stripped build reports steal_reason stripped, and Security Fortress builds are immune.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Farm owner's username. |
| `slot` | form | integer | yes | Plot slot index, 0-based. |

**Sample response**

```json
{
  "farm": {
    "owner_username": "alice"
  },
  "stole_coins": 18
}
```

### `POST /game/fertilize` - Fertilize a build

Spend coins to halve a growing build's remaining time (the plot's fertilize_cost field is the exact price). The price is computed from the exact value this build will pay out - including the golden multiplier and any active contract, Underdog, or Canary upside - so fertilizing is a pure time-skip and never a profit, on any build, at any prestige, with any combination of boosts.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slot` | form | integer | yes | Plot slot index, 0-based. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 12
  }
}
```

### `POST /game/daily` - Claim daily bonus

Claim the once-per-UTC-day coin bonus; consecutive days grow a streak (20 coins on day one up to 92 from day seven on), scaled by your own prestige and Tech Debt Payoff multiplier. A lapsed streak resets to day one - the farm state's daily_streak_reset flag and daily_reward already reflect that.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "streak": 3,
    "coins": 94
  }
}
```

### `POST /game/perk` - Upgrade a perk

Upgrade a permanent perk with coins: yield (+5% harvest coins), growth (+4% build speed), discount (-3% planting cost), or xp (+5% harvest XP) per level. Perks reset on refactor.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `perk` | form | string | yes | Perk key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 0
  }
}
```

### `POST /game/quests/claim` - Claim a quest

Claim a completed daily quest by its kind, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a 48-hour +20% coin boost instead of coins.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `quest` | form | string | yes | Quest kind. |
| `scope` | form | string | no | daily (default) or weekly. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 130
  }
}
```

### `POST /game/prestige` - Refactor (prestige)

Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, up to 35% with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "prestige": 1,
    "coins": 6550
  }
}
```

### `POST /game/grant` - Claim the community grant

Claim the weekly community grant, paid from the treasury filled by refactor fees and divided between everyone currently eligible (capped at 2500 coins, suppressed below 250). Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 2550
  }
}
```

### `POST /game/legacy` - Buy a Legacy upgrade

Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest (CI Bot), multiplier (+10% coins/level), speed (+5% build speed/level), plots (+1 starting plot/level), defense (+30s grace, -5% steal loss/level), or carryover (Golden Parachute, +5% refactor carry-over/level).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `key` | form | string | yes | Legacy upgrade key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "stars": 1
  }
}
```

### `POST /game/mastery` - Buy a Mastery upgrade

Spend Mastery points (earned at prestige 50 and every 5 prestige after) on a permanent Mastery upgrade: autoreplant (Continuous Delivery, 3 points), analytics (Farm Analytics, 2 points), or contracts (Legacy Contracts, 4 points).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `key` | form | string | yes | Mastery upgrade key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "mastery_points": 0
  }
}
```

### `POST /game/infrastructure/buy` - Buy Infrastructure

Buy a permanent, expensive, prestige-gated Infrastructure building: registry (Rust/Compiler/Kernel build 15% faster; 3M coins, prestige 3), canary (12% chance to double a harvest, 6% to only refund its planting cost; 6M, prestige 8), or observability (caps what any raider can take from you at 20% of a build's value; 15M, prestige 15).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `key` | form | string | yes | Infrastructure key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 0
  }
}
```

### `POST /game/defense/upgrade` - Upgrade Defense

Buy the next Defense tier (Firewall through Zero Trust Mesh; the farm state's defense_next_cost is the exact price). Each tier multiplicatively reduces what a raider takes from you and adds steal grace, but adds an ongoing daily coin upkeep of max(tier minimum, 0.2% of your balance). If you cannot pay, the tier decays by one level and only what you can afford is taken - your balance is never emptied.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "defense_level": 1
  }
}
```

### `POST /game/defense/downgrade` - Downgrade Defense

Drop your Defense down one tier to escape its daily upkeep. There is no refund. Available whenever defense_downgrade_available is true in the farm state.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "defense_level": 0
  }
}
```

### `POST /game/cosmetics/buy` - Buy a cosmetic

Buy a purely cosmetic title or plot skin with coins. No gameplay effect. The farm state's cosmetics list carries each key, cost, and an owned flag.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `key` | form | string | yes | Cosmetic key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "coins": 0
  }
}
```

### `POST /game/cosmetics/equip` - Equip a title

Equip an owned title cosmetic so its display name shows next to your name on the leaderboard.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `key` | form | string | yes | An owned title cosmetic key. |

**Sample response**

```json
{
  "ok": true,
  "farm": {
    "active_title": "title_architect"
  }
}
```

---

<a id="doc-quizzes"></a>
# Quizzes

A quiz is user-generated content like a gist or a project: it has an owner, a slug, comments,
votes, bookmarks and reactions. Any signed-in member authors quizzes, every member plays them,
and guests read published ones.

**Publishing is terminal.** A draft is fully editable; the moment its owner publishes it, the
quiz, its questions and its options are frozen forever. There is no unpublish and no
post-publish edit, which is what makes two members' scores on the same quiz comparable. Every
write endpoint on a published quiz returns `400`; only delete still works. Publish validates
the whole quiz first and refuses with the exact list of problems.

Playing a quiz creates an **attempt**. There is at most one in-progress attempt per member per
quiz - starting again returns the existing one. Each question can be answered exactly once; a
second submit returns `400` and credits nothing. A time limit is stored on the attempt and
evaluated lazily on read, so an expired attempt reads as `expired` with no background process
involved.

Seven question kinds are graded deterministically. The eighth, `free_text`, is graded by the
internal AI gateway against the author's criteria and billed to the answering member's own API
key. When the gateway is unavailable the answer is still graded, by a deterministic
token-overlap fallback, and the answer carries `graded_by: "fallback"` so the degradation is
visible rather than silent. `graded_by` is one of `auto`, `ai`, `fallback`.

**Correct answers are never served to a player mid-attempt.** `is_correct` on the options and
`correct_boolean` / `expected_answer` / `numeric_value` / `match_value` on the question are
omitted unless the viewer owns the quiz, or the question has already been answered in this
attempt and the quiz has `reveal_answers` on. A public export of a published quiz omits them
too; the owner's export includes them.

The **scoreboard** at `/quizzes/scoreboard` sums each member's **best** completed attempt per
quiz, never the sum of all attempts, so replaying a quiz can raise a member's contribution to
their personal best and never beyond it. Quizzes a member wrote themselves count like any
other.

All endpoints negotiate HTML or JSON. POST bodies are form encoded
(`application/x-www-form-urlencoded`). Action POSTs answer `{"ok": true, "redirect": "...",
"data": {...}}`; an invalid domain operation answers `400` as
`{"error": {"status": 400, "message": "..."}}`.

### `GET /quizzes` - Quiz hub

Published quizzes with the viewer's per-quiz state, the filter counts and the cross-quiz scoreboard.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `search` | query | string | no | Match the title, description or author username. |
| `filter` | query | enum | no | Which quizzes to list. Allowed: all, todo, done, mine, drafts. |
| `page` | query | integer | no | 1-based page number. |

**Sample response**

```json
{
  "quizzes": [
    {
      "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
      "slug": "8bbb000000000001-sqlite-fundamentals",
      "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
      "title": "SQLite fundamentals",
      "status": "published",
      "question_count": 10,
      "total_points": 14,
      "attempt_count": 23,
      "time_limit_seconds": 900,
      "pass_percent": 70,
      "viewer_owns": false,
      "viewer_can_edit": false,
      "viewer_can_play": true,
      "viewer_state": "todo",
      "validation_errors": [],
      "viewer_best_percent": 0.0,
      "comment_count": 3,
      "stars": 5
    }
  ],
  "filter": "all",
  "counts": {
    "all": 12,
    "todo": 9,
    "done": 3,
    "mine": 2,
    "drafts": 1
  },
  "pagination": {
    "page": 1,
    "total": 12,
    "total_pages": 1
  },
  "scoreboard": [
    {
      "rank": 1,
      "user": {
        "username": "alice"
      },
      "total_points": 84.0,
      "quizzes_completed": 7,
      "avg_percent": 88.4,
      "perfect_count": 2
    }
  ],
  "viewer_can_create": true
}
```

### `GET /quizzes/scoreboard` - Quiz scoreboard

Score per user across every published quiz, counting each member's best attempt per quiz. Cached about 15 seconds.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `limit` | query | integer | no | How many entries to return, up to 100. |

**Sample response**

```json
{
  "scoreboard": [
    {
      "rank": 1,
      "user": {
        "username": "alice"
      },
      "total_points": 84.0,
      "quizzes_completed": 7,
      "avg_percent": 88.4,
      "perfect_count": 2
    }
  ],
  "viewer_standing": null,
  "limit": 20
}
```

### `GET /quizzes/new` - New quiz form

The create form behind the New quiz button.

*Minimal role:* Member

**Sample response**

```json
{
  "viewer_can_create": true
}
```

### `POST /quizzes/create` - Create a quiz

Create a draft quiz. Add its questions afterwards, then publish it.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | 3 to 200 characters. |
| `description` | form | string | no | Markdown, up to 5000 characters. |
| `shuffle_questions` | form | boolean | no | Shuffle the question order per attempt. |
| `shuffle_options` | form | boolean | no | Shuffle the answer options. |
| `reveal_answers` | form | boolean | no | Reveal the correct answer after each question. |
| `allow_review` | form | boolean | no | Allow reviewing every answer on the results screen. |
| `time_limit_seconds` | form | integer | no | 0 for no limit, up to 86400. |
| `pass_percent` | form | integer | no | 0 to 100, 0 for no pass or fail verdict. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
  "data": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals"
  }
}
```

### `POST /quizzes/import` - Import a quiz document

Create a complete quiz - metadata, settings, every question and every option - from one JSON document. Capped at 100 questions and 12 options per question.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `document` | form | string | yes | The complete quiz as a JSON string. See the export endpoint for the exact shape. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
  "data": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "question_count": 10
  }
}
```

### `GET /quizzes/{slug}` - Quiz detail

One quiz with its stats, its leaderboard, its comments and the viewer's own state. A draft is visible only to its owner and to administrators.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "quiz": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
    "title": "SQLite fundamentals",
    "status": "published",
    "question_count": 10,
    "total_points": 14,
    "attempt_count": 23,
    "time_limit_seconds": 900,
    "pass_percent": 70,
    "viewer_owns": false,
    "viewer_can_edit": false,
    "viewer_can_play": true,
    "viewer_state": "todo",
    "validation_errors": []
  },
  "leaderboard": [],
  "comments": [],
  "viewer_state": "todo",
  "star_count": 5
}
```

### `GET /quizzes/{slug}/export` - Export a quiz

The full quiz document, the exact inverse of the import endpoint. The owner gets every correct answer; everyone else gets the questions without the key.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "title": "SQLite fundamentals",
  "description": "Ten questions on WAL, indexing and transactions.",
  "settings": {
    "shuffle_questions": true,
    "reveal_answers": true,
    "pass_percent": 70,
    "time_limit_seconds": 900
  },
  "questions": [
    {
      "kind": "single_choice",
      "prompt": "Which journal mode allows concurrent readers and one writer?",
      "points": 1,
      "options": [
        {
          "label": "DELETE"
        },
        {
          "label": "WAL",
          "is_correct": true
        }
      ]
    }
  ]
}
```

### `GET /quizzes/{slug}/leaderboard` - Quiz leaderboard

Top completed attempts on one quiz, best percentage first.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `limit` | query | integer | no | How many entries to return, up to 100. |

**Sample response**

```json
{
  "quiz_uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
  "entries": [
    {
      "rank": 1,
      "user": {
        "username": "bob"
      },
      "score_points": 13.0,
      "score_percent": 92.86,
      "passed": true,
      "completed_at": "2026-07-25T10:00:00+00:00"
    }
  ]
}
```

### `GET /quizzes/{slug}/edit` - Quiz builder

The owner's builder page: the quiz, every question with its answer key, the question-kind catalogue and the live pre-publish checklist.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "quiz": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
    "title": "SQLite fundamentals",
    "status": "published",
    "question_count": 10,
    "total_points": 14,
    "attempt_count": 23,
    "time_limit_seconds": 900,
    "pass_percent": 70,
    "viewer_owns": false,
    "viewer_can_edit": false,
    "viewer_can_play": true,
    "viewer_state": "todo",
    "validation_errors": []
  },
  "questions": [],
  "kinds": [
    {
      "key": "single_choice",
      "label": "Single choice",
      "has_options": true
    }
  ],
  "validation_errors": [
    "Add at least one question before publishing."
  ]
}
```

### `POST /quizzes/edit/{slug}` - Edit a quiz

Change the title, description and settings of a DRAFT quiz. 400 once published.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `title` | form | string | yes | 3 to 200 characters. |
| `description` | form | string | no | Markdown, up to 5000 characters. |
| `shuffle_questions` | form | boolean | no | Shuffle the question order per attempt. |
| `shuffle_options` | form | boolean | no | Shuffle the answer options. |
| `reveal_answers` | form | boolean | no | Reveal the correct answer after each question. |
| `allow_review` | form | boolean | no | Allow reviewing every answer on the results screen. |
| `time_limit_seconds` | form | integer | no | 0 for no limit, up to 86400. |
| `pass_percent` | form | integer | no | 0 to 100, 0 for no pass or fail verdict. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals"
}
```

### `POST /quizzes/{slug}/publish` - Publish a quiz

IRREVERSIBLE. Freezes the quiz, its questions and its options forever. Refuses with the validation problems when the quiz is incomplete.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals",
  "data": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "status": "published"
  }
}
```

### `POST /quizzes/delete/{slug}` - Delete a quiz

Owner or administrator. Removes the quiz with its questions, options, attempts and answers. The only operation left on a published quiz.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes"
}
```

### `POST /quizzes/{slug}/questions` - Add a question

Append one question with its options to a DRAFT quiz. 400 once published.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `kind` | form | enum | yes | The question kind. Allowed: single_choice, multiple_choice, true_false, free_text, fill_blank, numeric, ordering, matching. |
| `prompt` | form | string | yes | Markdown, up to 2000 characters. |
| `points` | form | integer | no | 1 to 100. |
| `explanation` | form | string | no | Shown after answering. |
| `options` | form | string | no | Option labels, one per line or comma separated. |
| `match_values` | form | string | no | Accepted answers aligned with the options, for fill_blank and matching. |
| `correct_indexes` | form | string | yes | 0-based indexes of the correct options, comma separated. Required for choice questions. |
| `correct_boolean` | form | boolean | no | true_false only: the statement is true. |
| `expected_answer` | form | string | no | free_text only: the reference answer. |
| `grading_criteria` | form | string | no | free_text only: criteria for the AI reviewer. |
| `numeric_value` | form | number | no | numeric only: the correct value. |
| `numeric_tolerance` | form | number | no | numeric only: accepted absolute tolerance. |
| `case_sensitive` | form | boolean | no | fill_blank only: compare case sensitively. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
  "data": {
    "uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
    "position": 0
  }
}
```

### `POST /quizzes/{slug}/questions/{question_uid}` - Edit a question

Replace one question and its options on a DRAFT quiz. 400 once published.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `question_uid` | path | string | yes | The question uid. |
| `kind` | form | enum | yes | The question kind. Allowed: single_choice, multiple_choice, true_false, free_text, fill_blank, numeric, ordering, matching. |
| `prompt` | form | string | yes | Markdown, up to 2000 characters. |
| `points` | form | integer | no | 1 to 100. |
| `explanation` | form | string | no | Shown after answering. |
| `options` | form | string | no | Option labels, one per line or comma separated. |
| `match_values` | form | string | no | Accepted answers aligned with the options. |
| `correct_indexes` | form | string | yes | 0-based indexes of the correct options. |
| `correct_boolean` | form | boolean | no | true_false only. |
| `expected_answer` | form | string | no | free_text only. |
| `grading_criteria` | form | string | no | free_text only. |
| `numeric_value` | form | number | no | numeric only. |
| `numeric_tolerance` | form | number | no | numeric only. |
| `case_sensitive` | form | boolean | no | fill_blank only. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
```

### `POST /quizzes/{slug}/questions/{question_uid}/delete` - Delete a question

Remove one question and its options from a DRAFT quiz, then renumber.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `question_uid` | path | string | yes | The question uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
```

### `POST /quizzes/{slug}/questions/reorder` - Reorder the questions

Set a new question order on a DRAFT quiz. Every uid must be listed exactly once.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `order` | form | string | yes | Every question uid in the wanted order, comma separated. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
```

### `POST /quizzes/{slug}/attempts` - Start or resume an attempt

Returns the member's single in-progress attempt, creating it when there is none. The question order and one blank answer row per question are materialized at start.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/attempts/0198f2c0-3333-7aaa-8bbb-000000000003",
  "data": {
    "uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
    "status": "in_progress"
  }
}
```

### `GET /quizzes/{slug}/attempts/{attempt_uid}` - Read an attempt

The attempt with its questions in play order. Correct answers are withheld until a question is answered and the quiz reveals answers.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `attempt_uid` | path | string | yes | The attempt uid. |

**Sample response**

```json
{
  "quiz": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
    "title": "SQLite fundamentals",
    "status": "published",
    "question_count": 10,
    "total_points": 14,
    "attempt_count": 23,
    "time_limit_seconds": 900,
    "pass_percent": 70,
    "viewer_owns": false,
    "viewer_can_edit": false,
    "viewer_can_play": true,
    "viewer_state": "todo",
    "validation_errors": []
  },
  "attempt": {
    "uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
    "status": "in_progress",
    "remaining_seconds": 812,
    "answered_count": 2,
    "question_count": 10,
    "score_points": 2.0,
    "max_points": 14,
    "score_percent": 14.29,
    "questions": [
      {
        "uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
        "kind": "single_choice",
        "prompt": "Which journal mode allows concurrent readers?",
        "points": 1,
        "options": [
          {
            "uid": "opt-a",
            "label": "DELETE"
          },
          {
            "uid": "opt-b",
            "label": "WAL"
          }
        ]
      }
    ]
  }
}
```

### `POST /quizzes/{slug}/attempts/{attempt_uid}/answer` - Answer a question

Grade and record one answer. Each question can be answered exactly once; a second submit answers 400 and credits nothing.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `attempt_uid` | path | string | yes | The attempt uid. |
| `question_uid` | form | string | yes | The question being answered. |
| `answer_text` | form | string | no | Free text, the numeric value, or true/false. |
| `option_uids` | form | string | no | Chosen option uids, comma separated and in order for ordering. |
| `blanks` | form | string | no | fill_blank only: one answer per blank, comma separated. |
| `matches` | form | string | no | matching only: the chosen right-hand value per option_uid, in order. |

**Sample response**

```json
{
  "ok": true,
  "answer": {
    "question_uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
    "answered": true,
    "is_correct": true,
    "awarded_points": 1.0,
    "feedback": "Correct.",
    "graded_by": "auto",
    "confidence": 1.0
  },
  "attempt": {
    "answered_count": 3,
    "score_points": 3.0,
    "max_points": 14
  }
}
```

### `POST /quizzes/{slug}/attempts/{attempt_uid}/finish` - Finish an attempt

Close the attempt and compute the final score from its answer rows. A second finish returns the same result and awards nothing again.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `attempt_uid` | path | string | yes | The attempt uid. |

**Sample response**

```json
{
  "quiz": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
    "title": "SQLite fundamentals",
    "status": "published",
    "question_count": 10,
    "total_points": 14,
    "attempt_count": 23,
    "time_limit_seconds": 900,
    "pass_percent": 70,
    "viewer_owns": false,
    "viewer_can_edit": false,
    "viewer_can_play": true,
    "viewer_state": "todo",
    "validation_errors": []
  },
  "attempt": {
    "status": "completed",
    "score_points": 13.0,
    "max_points": 14,
    "score_percent": 92.86,
    "passed": true
  },
  "review": [],
  "fallback_count": 0
}
```

### `GET /quizzes/{slug}/attempts/{attempt_uid}/results` - Attempt results

The result of one attempt: score, percentage, pass verdict, and the per-question review when the author allowed it. Attempt owner or admin.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `slug` | path | string | yes | Quiz slug or uid. |
| `attempt_uid` | path | string | yes | The attempt uid. |

**Sample response**

```json
{
  "quiz": {
    "uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
    "slug": "8bbb000000000001-sqlite-fundamentals",
    "url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
    "title": "SQLite fundamentals",
    "status": "published",
    "question_count": 10,
    "total_points": 14,
    "attempt_count": 23,
    "time_limit_seconds": 900,
    "pass_percent": 70,
    "viewer_owns": false,
    "viewer_can_edit": false,
    "viewer_can_play": true,
    "viewer_state": "todo",
    "validation_errors": []
  },
  "attempt": {
    "status": "completed",
    "score_percent": 92.86,
    "passed": true
  },
  "review": [],
  "fallback_count": 0
}
```

---
