# DevPlace - The Developer Social Network

Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via Multiavatar (local, no network). SQLite via `dataset` with WAL mode and concurrency tuning.

[![CI](https://git.example.com/api/badges/DevPlace/ci/status.svg)](.gitea/workflows/test.yaml)

## Quick start

```bash
make install        # create .venv if needed, pip install -e ".[dev]", playwright chromium
make dev            # uvicorn --reload on port 10500
make test           # Playwright integration + unit tests, headless, fail-fast
make test-headed    # same tests in visible browser
```

Open `http://localhost:10500`.

A measured structural overview of the whole application, fifteen Mermaid diagrams covering deployment, request pipeline, URL surface, data layer, AI plane, containers and the real-time plane, is in [ARCHITECTURE.md](ARCHITECTURE.md).

PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:

```bash
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
```

## Stack

| Layer | Technology |
|-------|-----------|
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
| Templates | Jinja2 (server-side rendered) |
| Frontend | Pure ES6 JavaScript, one class per file. Per-tab scroll restoration (`ScrollMemory`): returning to a listing via browser back, reload, or a back/breadcrumb link reliably lands at the previous scroll position on every browser and device; fresh navigations always start at the top |
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
| Auth | Session cookie, API key (`X-API-KEY`/Bearer), or HTTP Basic; PBKDF2-SHA256 via passlib |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms). Seeded from the username by default; a per-user `avatar_seed` lets the owner or an admin regenerate a fresh random avatar from the profile page (`POST /profile/{username}/regenerate-avatar`). Regeneration is irreversible - the previous avatar cannot be recovered. |
| Outbound HTTP | Stealth client (`devplacepy/stealth.py`): real Chrome fingerprint (TLS JA3/JA4 + HTTP/2 + headers) via `curl_cffi` behind an `httpx` transport adapter, pure-`httpx[http2]` fallback; the single client for every server-side outbound request |
| Coverage | `coverage.py` (`.coveragerc`, subprocess-aware) |
| Load testing | Locust (locustfile.py) |

## Project structure

```
devplacepy/
  main.py             # FastAPI app, router registration
  config.py           # Settings from env vars + .env
  database/           # dataset connection, index creation (package)
  templating.py       # Shared Jinja2 environment + globals
  avatar.py           # Multiavatar generation, URL builder
  utils/              # Password hashing, session mgmt, time_ago, notification hook (package)
  models.py           # Pydantic schemas
  push/               # Push delivery: provider protocol, Web Push, APNs, registrations
  routers/            # One file per domain (auth, feed, posts, push, ...)
  templates/          # Jinja2 HTML templates
  static/css/         # Page-specific CSS files
  static/js/          # Application.js (ES6 module)
  services/           # Background service framework (base, manager, news)
```

## Routes

| Prefix | Purpose |
|--------|---------|
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. |
| `/auth` | Signup, login, logout, forgot/reset password |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/topics` | Crawlable per-topic category pages (public): `/topics` hub links every topic with a live post count, `/topics/{topic}` lists that topic's posts with its own canonical URL, title, and breadcrumbs |
| `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/projects/{slug}` | Dedicated project page: one encompassing card with a cover banner and project logo (owner-uploaded through the standard attachment uploader), the title overlaid on the banner, status/type/platform chips, owner-set Website and Repository links, section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the Devlog timeline of every post linked to the project (owners post updates straight from the page via the shared composer preset to the `devlog` topic), a Screenshots gallery built from image attachments (owners add more from the More menu), and a sidebar with links, stats and the author card |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence scoring, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}`. `/tools/isslop` is the **AI Usage Analyzer**: classify a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Queue with `POST /tools/isslop/run`, poll `GET /tools/isslop/{uid}` or the event trail at `GET /tools/isslop/{uid}/events`, read the report at `GET /tools/isslop/{uid}/report` (`.md` to download) and embed the SVG authenticity badge from `GET /tools/isslop/{uid}/badge.svg` |
| `/projects/{slug}/containers` | Admin per-project container manager: create and control container instances, all running the shared prebuilt `ppy` image (there is no in-app image building). Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control container instances across projects, under strict per-user isolation: the primary administrator sees and manages every instance; every other administrator sees instances on public projects plus their own (instances attached to another user's private project are excluded entirely) and manages only the instances they own (created by them or attached to their own project) - all other rows are view-only. The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
| `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, reconnect catch-up, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages: the original is delivered immediately, then a second `message` frame with `ai_processed` replaces the bubble for both participants when the rewrite lands (including across workers). An opened conversation loads its 500 most recent messages and can page older rows with `?before=`; switching threads does not drop the socket. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
| `/polls` | Vote on post-attached polls |
| `/follow` | Follow/unfollow users |
| `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog |
| `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) |
| `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link |
| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar |
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/battles` | **Opinion Wars**: week-long two-faction battles attached to posts, started from the composer's *Start Opinion War* builder. Members join a side and fight once a day (25 Code Farm coins, level-weighted damage); the pixel-art battle card shows live HP bars, a countdown, top contributors and an event ticker. `/battles` lists battles (`active`/`ended`/`mine` + search); `/battles/{uid}` state, `/battles/{uid}/events` replay, `/battles/{uid}/join` and `/battles/{uid}/fight` actions |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
| `/admin/bots` | Admin **Bot Monitor**: a live grid of the latest low-quality screenshot per running bot persona, each labelled with the bot username, persona, and current action, auto-refreshing |
| `/admin` | Admin panel (user management, news curation, settings) |
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
| `/openai` | OpenAI-compatible LLM gateway service (`/openai/v1/chat/completions`, `/openai/v1/*`) |
| `/devii` | Devii agentic assistant: WebSocket terminal (`/devii/ws`), standalone page, usage (`/devii/usage`), session bootstrap |
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
| `(none)` | `/push.json` (VAPID key + subscribe), `/service-worker.js`, `/manifest.json` (push + PWA) |

## Gamification

Member progression is driven by activity and peer recognition.

- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, AI usage analysis, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
- **Social graph listings.** Each profile has Followers and Following tabs that paginate the follow graph (25 per page) and show a follow/unfollow control for each person. The same data is available as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following`.
- **Media gallery.** Each profile has a public Media tab: a responsive, paginated grid of every attachment that user uploaded across posts, projects, gists, comments, messages, issues, and news, newest first. Images open in the shared lightbox. The owner can delete their own media and an admin can delete anyone's; deletion is a **soft delete** that hides the item everywhere (including its parent object) while preserving the file and the relation, so an admin can restore it from the **Media** trash at `/admin/media`.
- **Reward notifications** fire when a member levels up or earns a badge.
- **AI content correction.** Opt-in, default off. When a member enables it on their profile, the prose they author (post titles and bodies, project and gist titles and descriptions, comments, direct messages, and their bio) is automatically rewritten by the AI gateway according to a member-defined instruction, using the member's own API key for per-user attribution. A member chooses the apply mode: **in background** (default; content is saved exactly as written and corrected a moment later, so the write path is never slowed) or **synchronously** (the save waits for the correction so the stored result is corrected immediately). The rewrite is fail-soft (the original is kept on any error) and applies identically across the web UI, the REST and devRant APIs, and Devii. Code and source files are never corrected. In direct messages the correction is delivered live: the corrected message appears in the chat for both participants without a reload. Configure it on your profile or via the Devii `ai_correction_set` tool; the settings are saved at `POST /profile/{username}/ai-correction`. Successful correction calls accumulate per-user running totals - corrections, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.

- **AI modifier.** Enabled by default and applied synchronously by default. It works like AI content correction, except it runs **only** where the prose you author contains an inline `@ai <instruction>` directive: the configured prompt tells the model to execute that instruction and replace the marked part, removing the `@ai` marker. Text with no `@ai ...` directive is left exactly as written. It is **context-aware**: the model is given a grounding summary of who is asking (your username, role, level, stars, post count, rank, followers, and bio), the current date, and where the directive sits - the post a comment replies to, the conversation a direct message belongs to, the gist's language and code, and so on - so directives like `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to this` work. It uses your own API key for per-user attribution, is fail-soft (the original is kept on any error), and applies across the web UI, the REST and devRant APIs, and Devii, on the same prose fields as correction (posts, projects, gists, comments, direct messages, and your bio). Code and source files are never touched. In direct messages it runs live: typing `@ai <instruction>` in a message executes it and the resolved result appears in the chat for both participants without a reload. You can switch the apply mode to background or disable it on your profile or via the Devii `ai_modifier_set` tool; the settings are saved at `POST /profile/{username}/ai-modifier`. The default instruction is "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`". Successful modifications accumulate per-user running totals - modifications, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.

- **Devii interactive widgets.** Administrators set the site default on the Devii service (`devii_interactions_default`, default on). Guests always use that default. Signed-in members inherit it until they override it on their profile or via the Devii `interactions_set` tool (`POST /profile/{username}/interactions`; owner or admin). When enabled, Devii may present decisions with channel-aware controls (`ui_prompt`); when disabled, it falls back to plain numbered menus.

Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.

## Quizzes

**Quizzes** (`/quizzes`) let any signed-in member write a quiz and every member play it. The hub is
one page with three columns: filters and search 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. Guests read published quizzes and see the board; they cannot play.

- **Eight question kinds.** Single choice, multiple choice, true/false, free text, fill in the
  blanks, numeric, ordering, and matching. Seven are graded deterministically, several with partial
  credit. Ordering and matching use plain selectors and keyboard controls, never a drag-only
  interaction, so they work with a keyboard and a screen reader.
- **AI-graded free text.** A free-text answer is reviewed by the platform's own AI against the
  author's reference answer and grading criteria, billed to the answering member's own API key. The
  score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped
  score, so a reviewer can never mark an answer correct while scoring it zero. When the reviewer is
  unavailable the answer is still graded, by a deterministic keyword comparison, and is visibly
  stamped as such - grading never silently becomes a zero.
- **Publishing is permanent.** A draft is fully editable; publishing freezes the quiz, its
  questions and its options forever. There is no unpublish and no post-publish edit, which is what
  makes two members' scores on the same quiz comparable. The builder shows a live pre-publish
  checklist and keeps the Publish button disabled until it is empty, and the action is confirmation
  gated on both the web UI and in Devii.
- **One attempt at a time.** Starting a quiz creates an attempt that lives on the server, so a
  refresh, a second tab and a different device all resume the same one. Each question can be
  answered exactly once. A time limit is a deadline stored on the attempt, evaluated when someone
  looks at it - nothing runs in the background.
- **Settings.** Shuffle the questions, shuffle the options, reveal the correct answer after each
  question, allow reviewing every answer on the results screen, set a time limit, set a pass mark.
- **An honest scoreboard.** Your **best** attempt per quiz counts, never the sum of your attempts,
  so replaying a quiz can raise your contribution up to your personal best and never beyond it.
  Quizzes you wrote yourself count like any other. Each quiz also has its own leaderboard.
- **Full automation.** Devii creates a complete quiz from one JSON document, publishes it, plays it
  end to end and reads the result, all through the same public API - and the hub's *Create quiz
  with Devii* button opens the assistant with that request already typed in (it never sends it for
  you). The whole flow works without JavaScript too: every question is a real form.
- **Engagement.** Quizzes carry comments, stars, bookmarks and reactions like any other content,
  and appear in the sitemap.

Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.

## Opinion Wars

**Opinion Wars** (`/battles`) are week-long two-faction battles attached to posts, in the spirit of
old eRepublik battles: settle tabs-versus-spaces by showing up daily and fighting for your side.

- **Start one from the composer.** The *Start Opinion War* button next to *Add poll* names the two
  factions; the battle runs for exactly 7 days from the moment the post is published.
- **Join and fight.** Any signed-in member picks a side and may fight once every 24 hours per
  battle. A fight costs 25 Code Farm coins and deals deterministic, level-weighted damage
  (100 HP + 10 per site level, capped at level 20) - no randomness, dedication wins wars.
- **Defection is allowed.** Switch factions any time; damage already dealt stays where it landed.
- **Live pixel-art card.** The battle renders on the post as a CSS pixel-art battlefield with HP
  bars, a countdown, your rank, top contributors and a live event ticker (joins, defections,
  fights, lead changes) over pub/sub with an incremental replay fallback.
- **Rewards.** When the week ends the bigger total wins: every fighter earns XP, the winning side
  and the top damage dealer earn bonuses, and battle badges (*Instigator*, *First Blood*,
  *War Veteran*, *Champion*) mark the milestones. Notifications cover lead changes, the result and
  your next fight being ready.

## Code Farm

The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.

- **Plant.** Plant a software project (a shell script, Python script, web app, Go service, Rust engine, compiler, or kernel) in an empty plot for a coin cost. Higher-tier crops unlock as your farm level rises.
- **Build and harvest.** A planted crop builds over real time; when the build finishes, harvest it for coins and XP. Harvesting also awards site XP and the **Green Thumb** / **Master Farmer** badges.
- **Upgrade CI.** Spend coins to raise your CI tier (Local Build through Distributed Cache); each tier makes every build faster.
- **Buy plots.** Unlock more plots (up to twelve); each new plot costs more than the last.
- **Fertilize.** Spend coins on a growing build to halve its remaining time. The cost is priced against the build's realized harvest value, so fertilizing is a pure time-skip - it brings the harvest sooner but never returns more coins than it costs, at any prestige level.
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 60%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a grant from it once per week - the balance is divided between everyone currently eligible rather than paid first-come-first-served, capped at 2,500 coins and suppressed below 250. A direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping - scaled by your own prestige and Tech Debt Payoff multiplier, so the cooperative loop stays worth doing at every stage - and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful raid pays the thief a share of the build's coin value and the **owner keeps and can still harvest the remainder** - a raid redistributes value rather than destroying it. The thief earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a notification naming the raider, the crop, and the exact amount taken. You can raid any given neighbour only **once per hour**, and any farm can absorb at most **3 raids per day**, so an inactive player can never be stripped by an unlimited queue of raiders. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; supply is measured per active farm so a busy server is not permanently floored by a few heavy players; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (caps what any raider can take from you at 20% of a build's value).
- **Defense.** An upgradeable building that multiplicatively reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth). If you cannot pay, 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 step down a tier deliberately to leave the commitment.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 5 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, a capped coin contribution, CI tier, plots, perks, and streak - the cap keeps it a measure of what you built rather than what you hoard), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.

The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`, `game_downgrade_defense`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.

## Engagement

- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.

XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).

## Trust and safety

DevPlace is open in the sense that it does not editorialise technical opinion. It enforces a short, fixed list of prohibited categories, published as the [Community Guidelines](/docs/community-guidelines.html), and it has the machinery to make that enforcement real.

- **Automated filtering at post time.** Every user-authored text passes a classifier at the five places content is created or changed (content creation, content editing, comments, direct messages, profile and signup fields), covering every surface with no per-route code. The default mode is `review`, not `block`: a match is published **and** raises a report for a human, because a developer platform discusses exploits, malware analysis and violent subject matter as its work and a machine that suppressed that would destroy the product. Only sexual and exploitative content is refused outright. Classification failure falls back to review, never to publication. Tunable live at `/admin/settings` (`moderation_filter_mode`: `off`, `label`, `review`, `block`).
- **A report control on every surface.** Posts, comments, gists, projects, project files, news, uploads, direct messages, quizzes, polls, awards, profiles, issues, workspaces and assistant output are all reportable through one polymorphic endpoint keyed on `(target_type, target_uid)`, from one dialog, with one reason list. Reports are private to the reporter; the reported person is never told who filed.
- **One queue with a published response window.** `/admin/moderation` is worked oldest-open-first and carries a badge showing the age of the oldest unresolved report against `moderation_sla_hours` (default 24), so a breach is visible rather than assumed. Resolution is a single atomic conditional update: two administrators deciding at once produce exactly one decision, the second gets a 409.
- **Real enforcement, with a statement of reasons.** Remove or restore content, warn, suspend for a stated period, ban, lift, dismiss, or escalate. Every decision writes a permanent `moderation_actions` row plus an audit event, and tells the affected user what was decided and why. A suspended account can still read, still see why, still report, and still delete itself; it cannot create. A junior administrator can never action a senior one.
- **Blocking**, unchanged and independent of all of the above, is now reachable from the content itself as well as from a profile.
- **Age and maturity.** Signup collects a date of birth, derives an age band, and **discards the date**; accounts below `moderation_minimum_age` (default 16) are refused. Content labelled mature is hidden behind an interstitial until the viewer explicitly opts in, and the reveal is never offered to a minor age band for restricted content.
- **Consent.** Five versioned, independently withdrawable consents (`terms`, `privacy`, `ai_third_party`, `activity_recording`, `container_credentials`) with full history, managed at `/profile/{username}?tab=privacy` and changeable **only by the account holder** - an administrator reads the record but never grants or withdraws on someone else's behalf. **No content is sent to a third-party AI provider without `ai_third_party` consent**, enforced once at the gateway; the per-feature AI toggles remain as preferences subordinate to it. Withdrawing `activity_recording` stops presence recording. While recording is on, an indicator says so on every page. `container_credentials` is what lets software another member runs in a container receive your API key; running your own container never asks.
- **Account deletion.** Self-service at `/profile/{username}/delete`, reauthenticated with the account password. Sessions and tokens are revoked, the profile is anonymised immediately, and all content is removed under one deletion event that stays restorable for `account_deletion_grace_hours` (default 24) before `devplace accounts prune` (and the Moderation housekeeping service) purges it permanently. The devRant `DELETE /api/users/me` routes into the same cascade.
- **Legal pages**: [Terms of Service](/docs/terms.html), [Community Guidelines](/docs/community-guidelines.html), [Privacy Policy](/docs/privacy.html), [How moderation works](/docs/content-moderation.html), [Notice and takedown](/docs/intellectual-property.html) and [Contact](/docs/contact.html). All six are indexed in the sitemap and reachable from the docs index; the footer of every page links Terms, Privacy, Community Guidelines and Contact. Contact details come from the `contact_email`, `contact_phone` and `contact_address` settings, so the in-product page and any app-store trader declaration have one source of truth.

## Vibe coding (Alpha, admin only)

Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **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; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.

## Admin: Audit Log

Every state-changing action on the platform is recorded to an append-only audit log: account and authentication events, content and engagement, projects and files, news curation, admin governance, background services, container operations, ingress traffic, AI gateway calls, the Devii agent, the CLI, gamification side-effects, and security denials. Each row captures who acted, in which role, from where (origin, IP, user-agent), on which objects, what changed (old to new value), a sanitised summary, event-specific metadata, and whether the action succeeded, failed, or was denied. A companion table links every related object with a labelled relation. The complete event catalogue lives in `events.md`.

The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable list (by search text, category, event key, role, origin, result, and date range) styled like the rest of the admin panel; `/admin/audit-log/{uid}` is the per-event detail with the full row, pretty-printed metadata, and the related-object graph. Both negotiate HTML or JSON. Financial figures are recorded for completeness but only surfaced on this admin-only view. Administrators can also query the trail conversationally through Devii, which exposes the same filterable list and per-event detail as admin-only read tools over these endpoints. Recording is best-effort and never blocks the audited action. When the Devii agent performs a mutation it reuses the same event key with `origin=devii`/`via_agent=1`. Rows older than `audit_log_retention_days` (default 90) are pruned daily by the **Audit retention** background service.

## Configuration

| Env var | Default | Purpose |
|---------|---------|---------|
| `DEVPLACE_DATABASE_URL` | `sqlite:///<repo>/data/devplace.db` | Database connection string |
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for every runtime/user-generated artifact (DB, uploads, VAPID keys, locks, bot state, job staging, container workspaces), outside the package and not served via `/static`. Point at a volume in production. Defined once in `config.py` (`DATA_PATHS` registry, created by `ensure_data_dirs()`) |
| `DEVPLACE_PORT` | `10500` | Port the app itself binds to (`make dev`/`make prod`, and what `Makefile`'s `dev`/`prod` targets pass to uvicorn's `--port`). Also feeds `config.PORT`, so `DEVPLACE_INTERNAL_BASE_URL`'s default and Devii's own self-dial URL follow it automatically. Bare metal only - the Docker app container always binds its fixed internal port regardless of this var; for Docker, use `PORT` (below) to change the externally reachable port |
| `PORT` | `10500` | Docker only: the host port `docker-compose.yml` publishes nginx on (`127.0.0.1:${PORT}:80`). Unrelated to `DEVPLACE_PORT` above - it never reaches the app container |
| `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | The boot-id half of the cache-busting version stamped into every static asset URL (`/static/v<app-version>-<boot-id>/...`, e.g. `/static/v1.0.1-1718040000/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes on `public.presence.roster`. That one set is the single source of truth behind every avatar presence dot on every page; `DEVPLACE_PRESENCE_ONLINE_LIMIT` only caps how many of them the feed's Online now panel displays |

### Runtime settings

Operational behavior is tunable live from `/admin/settings` (stored in `site_settings`, no redeploy). The Operational group covers:

| Setting | Default | Effect |
|---------|---------|--------|
| `site_url` | empty | Public origin for absolute links (SEO, container ingress `/p/<slug>`); empty derives from the request or `DEVPLACE_SITE_URL` |
| `rate_limit_per_minute` | `60` | Mutating requests allowed per IP per window |
| `rate_limit_window_seconds` | `60` | Rolling window for the request limit |
| `session_max_age_days` | `7` | Standard session cookie lifetime |
| `session_remember_days` | `30` | Remember-me session cookie lifetime |
| `registration_open` | `1` | When `0`, new sign-ups are rejected |
| `maintenance_mode` | `0` | When `1`, non-admins see the maintenance page; admins retain access |
| `maintenance_message` | scheduled-maintenance text | Message shown during maintenance |
| `customization_enabled` | `1` | When `0`, no user CSS/JS customization is injected on any page |
| `customization_js_enabled` | `1` | When `0`, user custom CSS is still served but custom JavaScript is suppressed |
| `audit_log_retention_days` | `90` | Audit rows older than this are pruned daily by the Audit retention service; `0` disables pruning |
| `extra_head` | empty | Raw HTML injected into every page `<head>` (custom `<style>`, `<script>`, `<link>`, meta tags, or analytics snippet). Site-wide, trusted-admin input, not sanitized |

Numeric values are floored to safe minimums so an invalid entry cannot lock out writes or stall services. Consumers read via `get_setting`/`get_int_setting`, which fall back to these defaults when a row is absent.

## Authentication

The website uses a `session` cookie. For automation, every page and action also
accepts three header-based methods, resolved centrally in `get_current_user`
(`utils/`) so they work everywhere with no per-route changes:

- **API key** - `X-API-KEY: <key>`
- **Bearer** - `Authorization: Bearer <key>`
- **HTTP Basic** - `Authorization: Basic base64(username-or-email:password)`

Every user has an `api_key` (a uuid7), set at signup and backfilled for existing
users on startup (and via `devplace apikey backfill`). The key is shown on the
profile page to its owner (and to admins for any user) with a live **Regenerate**
button; regenerating invalidates the old key immediately. Invalid credentials
return `401`; requests with no credentials behave as anonymous (browser redirect to
login). Full details and copy-paste `curl` examples live at `/docs/authentication.html`.

```bash
curl -H "X-API-KEY: <your-key>" https://your-host/notifications
curl -u <username>:<password> -X POST https://your-host/follow/<username>
```

CLI: `devplace apikey get <username>` / `reset <username>` / `backfill`.

## Content negotiation (HTML or JSON)

Every endpoint that renders a page or returns a redirect also speaks JSON, so anything the
website does is automatable from the same URLs. A request gets JSON when it sends
`Accept: application/json` or `Content-Type: application/json`; a normal browser navigation
(`Accept: text/html`) always gets HTML, so existing behaviour is unchanged (the legacy
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas/` and built
from the same context the templates use (sensitive user fields like `email`/`api_key`/
`password_hash` are never exposed). Page GETs return the page payload; form actions return a
uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors return
`{ "error": { "status", "message" } }` (validation → `422` with a `fields` map, unauthenticated
JSON → `401`, non-admin → `403`). The core lives in `devplacepy/responses.py`
(`wants_json`, `respond`, `action_result`). Full details: `/docs/conventions.html`.

Every response carries an `X-Response-Time: <ms>ms` header (set by the outermost `response_timing`
middleware, the full request total), and every rendered HTML page shows that server render time as a
small fixed indicator in the bottom-left corner.

```bash
curl -H "Accept: application/json" https://your-host/feed
curl -H "Accept: application/json" -X POST -d "content=hi&title=T&topic=devlog" https://your-host/posts/create
```

## XML-RPC bridge

The full REST API is also reachable over XML-RPC at `/xmlrpc`. A standalone forking XML-RPC
server (`XmlrpcService`, supervised like any other background service, listening on the
loopback `DEVPLACE_XMLRPC_PORT`, default `10550`) generates one method per documented endpoint
directly from the API reference, so every capability is callable over XML-RPC with no extra
wiring. The app reverse-proxies `/xmlrpc` to it (`routers/xmlrpc.py`); in production nginx
forwards `/xmlrpc` as well. Method names mirror the endpoint id with dots (`posts.create`,
`feed.list`, `profile.update`); each method takes one struct of named parameters and returns
the same JSON payload the REST endpoint would. Authenticate with `api_key` inside the struct,
an `X-API-KEY` / `Bearer` header, or HTTP Basic via a credentialed URL
(`http://username:password@host/xmlrpc`). Full XML-RPC introspection (`system.listMethods`,
`system.methodHelp`, `system.methodSignature`) and batching (`system.multicall`) are
supported; REST errors surface as XML-RPC faults whose `faultCode` is the HTTP status. Full
details and copy-paste Python examples (including a bot that replies to mentions) live at
`/docs/xmlrpc.html`; runnable versions are in `examples/xmlrpc/`.

```python
import xmlrpc.client

proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)
proxy.system.listMethods()
proxy.posts.create({"content": "hi from xml-rpc", "api_key": "YOUR_API_KEY"})
```

## devRant compatibility API

A second REST surface under `/api` mirrors the public devRant API shape so legacy devRant
clients can run against DevPlace data. Rants map to posts, comments and votes map to the
native engagement layer, and devRant integer IDs map directly onto the auto-increment `id`
column every table already carries (`posts.id`, `comments.id`, `users.id`). Authentication
follows the devRant model: `POST /api/users/auth-token` with `username`/`password` returns an
`auth_token` struct (`token_id`, `token_key`, `user_id`) backed by the `devrant_tokens` table;
every subsequent request carries that triple as query params or body fields (form or JSON both
accepted). Writes go through the same audited helpers as the native UI, so XP, notifications,
audit, and soft-delete all apply.

| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/users/auth-token` | Log in (by username or email), returns `auth_token` |
| POST | `/api/users` | Register a new account |
| GET | `/api/get-user-id?username=` | Resolve a username to its integer id |
| GET | `/api/users/{id}` | User profile (rants + comments) |
| POST | `/api/users/me/edit-profile` | Update bio/location/github/website |
| DELETE | `/api/users/me` | Deactivate account |
| GET | `/api/devrant/rants?sort=&limit=&skip=` | Rant feed (`recent`/`top`/`algo`) |
| POST | `/api/devrant/rants` | Post a rant (`rant`, comma-separated `tags`) |
| GET | `/api/devrant/rants/{id}` | One rant with its comments |
| POST | `/api/devrant/rants/{id}` | Edit a rant (owner only) |
| DELETE | `/api/devrant/rants/{id}` | Delete a rant (owner or admin) |
| POST | `/api/devrant/rants/{id}/vote` | Vote (`1`/`-1`/`0`) |
| POST | `/api/devrant/rants/{id}/{favorite\|unfavorite}` | Bookmark toggle |
| POST | `/api/devrant/rants/{id}/comments` | Comment on a rant |
| GET | `/api/devrant/search?term=` | Search rants |
| GET / POST / DELETE | `/api/comments/{id}` | Read / edit / delete a comment |
| POST | `/api/comments/{id}/vote` | Vote on a comment |
| GET / DELETE | `/api/users/me/notif-feed` | Notification feed / mark all read |
| GET | `/api/avatars/u/{seed}.png` | PNG avatar rendered from the user's avatar seed (the regenerated `avatar_seed`, or the username when unset) |

devRant `tags` round-trip verbatim via a `tags` column on `posts`; `profile_skills` is derived
from the user bio (DevPlace has no separate skills field); avatars are real PNGs rendered from
the local multiavatar engine. The surface is toggled by the `devrant_api_enabled` setting
(default on). The endpoints are served at devRant's path shape; pointing a hard-coded client at
this server is a DNS/reverse-proxy concern handled at the infrastructure layer.

## Documentation site

`/docs` serves a server-rendered docs site with a feed-style left sidebar
(`routers/docs/`). The sidebar groups its sections under four audience tiers (Start here,
Build with the API, Contribute and internals, Operate) so each reader has a clear path.
`/docs/index.html` is a "choose your path" landing, `/docs/getting-started.html` is the
new-contributor on-ramp, and `/docs/authentication.html` documents the auth methods; the
prose pages are curated markdown rendered through the shared content renderer (markdown +
highlight.js) with the current host and, when logged in, your own API key and username
filled in.

`/docs/search.html` search method is admin-configurable (`docs_search_mode` on
`/admin/settings`, default `agent`). In **agent** mode it is **Docii**, a documentation-only
assistant: an in-page chat (`<dp-docs-chat>`) rendered in the docs style where you ask
questions in plain language and Docii repeatedly searches the documentation, reading the
matching sections and refining its query until it has a grounded answer, with links to the
relevant pages. Docii is the Devii engine on a dedicated `docs` conversation channel, but
restricted to a single tool (documentation search) and driven by its own system prompt, so it
cannot perform platform actions - it only reads the docs. In **bm25** mode it is the classic
keyword results list. Either way, a viewer who has exceeded their daily AI limit (or a guest
when Devii is disabled) falls back to keyword (BM25) search automatically.

Every other endpoint is documented from a single source of truth, the `API_GROUPS` registry
in `devplacepy/docs_api.py`. Each group becomes a reference page listing its endpoints with
copy-paste cURL, JavaScript, and Python examples and an interactive "try it" panel
(`static/js/ApiTester.js`) that executes the real call from the browser with your API key
pre-filled. Each panel has a response-format selector (defaulting to JSON) that drives the
`Accept` header for both the live request and the generated snippets, an always-visible
**Expected** tab showing the modeled response, and a **Live response** tab for the real
result. To document a new endpoint, add an entry to `docs_api.py`; the page, sidebar link,
examples, runner, and expected-response sample are generated automatically. FastAPI's
built-in Swagger is moved to `/swagger` so `/docs` belongs to this site. The raw schema is at `/openapi.json`.

Operator pages are admin-only: `/docs/admin.html` (user/news/settings administration) and
`/docs/services.html` (Background Services) are hidden from the sidebar and return 404 for
non-admins. The Background Services page is generated live from the service registry
(`build_services_group()` over `service_manager.describe_all()`), so every registered service
and its full configuration are documented automatically - including future services.

## Background Services

`devplacepy/services/` provides a generic async service framework. `/admin/services` is a slim index listing each service with its description and live status; clicking a service opens its detail page where everything for that service lives - start/stop, enable-on-boot, run-now, clear logs, adjustable log size, and live status/metrics - organized into **Overview / Configuration / Logs** tabs (configuration grouped into sections). State is stored in the database (desired state in `site_settings`, observed state in the `service_state` table), so control and status are correct even with multiple workers where only one runs the services.

### Service framework

- **`ConfigField`** - declarative parameter spec (type, default, validation, secret) a service uses to declare its editable settings
- **`BaseService`** - abstract class with a reconciling run loop that honors the persisted `enabled`/command/interval state, plus `config_fields`, `get_config()`, `describe()`, and a log buffer
- **`ServiceManager`** - singleton: `register`, `describe_all`, `set_enabled`, `send_command`, `save_config`, `supervise`, `shutdown_all`
- **`NewsService`** - a fully automatic, zero-maintenance news pipeline: fetches news from `news.app.molodetz.nl/api`, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each one against a free, local, deterministic quality model (aquality - no billed LLM call, no API key), optionally reformats articles that clear the publish grade threshold into clean Markdown with a generative AI model (off by default, since the default grading model cannot generate text), and auto-rotates the best articles to Featured and the landing page. Any AI spend it does incur is metered from the gateway response headers and reported on the admin Services page (calls, tokens, total cost, and per-call averages)
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics and a live screenshot monitor at `/admin/bots` (opt-in; install the `bots` extra)
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Thinking is disabled by default** on every chat and vision call (`thinking.type=disabled` on DeepSeek, `reasoning.effort=none` on OpenRouter, `think=false` on Ollama) so the fast path is the default; a client may re-enable it per request, and an administrator may flip `gateway_thinking` on. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected. Each route may also name a **fallback model** - another already-configured model of the same kind, picked from a select box of the public model names (never a free-text provider model id) - that the gateway retries once, automatically, whenever the primary model fails after its own retries are exhausted, so a struggling model degrades to a working one instead of failing the caller
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
- **`AcceptanceService`** - grants every policy agreement (Terms of Service, Privacy Policy, third-party AI processing, activity recording, container credentials) to every account that has not declined it, so an instance kept production-identical for extended manual testing never interrupts with an acceptance dialog. Administrator-only, **off by default**, with a separate switch per agreement type and a dry run that reports what it would do without writing. An account that withdrew a consent is never granted it again, with no further action: the consent ledger itself is the decline register. It is not appropriate on a real production host

### Container manager (admin only)

`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention. Deletions propagate too, in both directions: deleting a file inside the container removes it from the project, and deleting it in the project's file editor removes it from the container, tracked against a per-file sync baseline so a genuine deletion is never confused with a file that simply has not been materialized to that workspace yet; an edit made after a conflicting deletion always wins and restores the file (a read-only project is export-only and always mirrors the project verbatim, including removing files the project no longer has). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.

**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).

**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.

### Dev Workspaces and the browser editor

A **workspace** is a member-facing container running the DevPlace browser editor, layered on the
container runtime above. It is opened from a project's **Workspace** page and reached at
`/projects/{slug}/workspace`; the editor itself is proxied at
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
page whenever the editor is actually reachable.

**The workspace page reports the editor's real state, live.** A workspace has a *phase* derived on
the server from its desired state, its container status and a TCP probe of the editor port:
`stopped`, `starting`, `ready`, `stopping`, `crashed` or `suspended`. The page renders the control
that matches the phase, so pressing **Start** turns the button into a spinner reading *Starting the
editor* and the **Open editor** link appears only once code-server answers, never while the
container is still booting. While a workspace is in transition the page polls every two seconds
(twenty seconds otherwise) and also receives pushed updates on the owner's private pub/sub topic, so
the label changes on its own without a reload; the project page's **Editor** button follows the same
readiness rule. The phase, its label and `editor_ready` are part of the workspace JSON.

The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the
site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and
five `DevPlace:` commands. Nothing in the interface identifies as code-server.

**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the
coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
terminals the member opens later.

**The first boot of a workspace opens the DevPlace welcome page** beside the terminals: a webview
introducing the workspace and DevPlace Code (its 900k token context, vision, parallel sub-agents,
deep research, safety gates and the daily credits it runs on), with example prompts and buttons that
focus the agent terminal, start the walkthrough, show the public tunnels and open the workspace
settings. It is shown once per workspace and can be reopened with **DevPlace: Show the welcome
page**. The editor's own built-in chat assistant and VS Code's own welcome page stay suppressed so
`dpc` is the only agent on offer and every token it spends is ledgered against the member's DevPlace
account. `dpc`'s own working files (`.dpc/`, `dpc.log`) are in `SYNC_SKIP_NAMES`, so running an
agent on every boot never pollutes the project.

**The terminal panel gets about a third of the window by default.** Every preset is a fixed number
of steps up from the panel's minimum height (`normal`, the default, lands at roughly a third of a
typical window; `short` at a fifth, `tall` at about half) and `maximized` fills the editor area.
A preset is applied on the first boot of a workspace and again whenever it changes; a height the
member drags themselves is kept across restarts.

**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to
members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide
with the `workspace_editor_trust_all` setting.

**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the
editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the
container's CPU, memory and disk. The first three are the member's own preferences on their workspace
page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the
container size is part of the administrator-set workspace quota. Each preference resolves instance
override, then the member's row, then the site setting, then the built-in default, and the page shows
which of those each value came from.

**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host
before each launch and records exactly what it wrote; on the next launch it updates only the keys
whose current value is still the one it wrote. A setting the member changed inside the editor is
theirs permanently, while a change to the site default still reaches everyone who has expressed no
preference. Preferences apply on the next workspace start, and the page says so and offers the
restart.

### Async job framework and zip downloads

`services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`.

`ZipService` archives a project (or a file/folder subtree) into `{crc32}.{slug}.zip` using the standalone `zip_worker` subprocess, with a CRC32 over the output. The frontend `app.zipDownloader` (any `data-zip-download` element, plus the files context menu) enqueues, polls `/zips/{uid}`, and downloads from `/zips/{uid}/download`. The job uid is the capability token, so anyone with the link can download. CLI: `devplace zips prune` (delete expired) / `devplace zips clear` (delete all).

`ForkService` copies a project into a new project owned by the forking user. The **Fork** button on the project page (any signed-in user) prompts for a name; the job creates the destination project, duplicates the entire virtual filesystem, and records a directional `project_forks` relation so each fork shows a "Forked from X" link. The frontend `app.projectForker` enqueues, polls `/forks/{uid}`, and redirects to the new project once it is done; on failure the partially created project is rolled back. The forked project is permanent, so retention removes only the job tracking row. CLI: `devplace forks prune` / `devplace forks clear` (job rows only).

`SeoService` powers the public **Tools -> SEO Diagnostics** auditor. It runs a headless-browser (Playwright) crawl of a single URL or a sitemap (capped pages) in a subprocess and runs a broad battery of checks across eleven categories: crawlability and indexing (status, redirects, HTTPS/HSTS, canonical, robots/meta-robots, sitemap, URL hygiene, mixed content), on-page meta and content (title, description, headings, language, charset, viewport, favicon, content depth), links, structured data and rich results (JSON-LD validity and required properties, microdata/RDFa), social cards (Open Graph, Twitter), Core Web Vitals and performance (LCP, CLS, FCP, TTFB, page weight, requests, DOM size, compression, caching, image optimisation, console errors), mobile and accessibility (responsive layout, tap targets, image alt, form labels), security headers, and AI/LLM-search readiness (server-rendered-vs-JS content parity, `llms.txt`, semantic HTML). It produces a weighted score and grade with per-category subscores and a recommendation for every finding. Progress streams live over `WS /tools/seo/{uid}/ws`; the full report is available at `/tools/seo/{uid}/report` (HTML or JSON). CLI: `devplace seo prune` / `devplace seo clear`. Playwright is a core dependency; `make install` fetches the Chromium browser.

`SeoMetaService` is a separate AI subservice that generates a clean, search-optimised title, description and short keyword list for every published post, project, gist, news article and issue, entirely off the request path so it never slows the web server. The work is queued whenever content is created, edited or published; until the AI value is ready a plain-content default (built from the markdown-stripped text and clamped to safe lengths) fills the fields, so a page's metadata is **always populated, never empty**. The service uses the built-in internal AI gateway and meters its own AI cost and statistics in a dedicated usage table, surfaced together with its live task pipeline on the **Admin -> Services** page. This release also fixes the on-page metadata: the `<meta name="description">` is now stripped of markdown markup (it previously leaked `#`, `**` and `[](...)` from the raw body), a `<meta name="keywords">` tag is emitted (a short honest list, not stuffed), and social-card image dimensions and alt text are added. CLI: `devplace seo-meta prune` / `devplace seo-meta clear` (job rows only; the generated metadata persists).

`DeepsearchService` powers the public **Tools -> DeepSearch** researcher, an iterative agent modelled on the way Perplexity and OpenAI's own deep-research agents work: search, read, decide what is still missing, search again. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). After the first pass it runs up to two automatic gap-filling refinement rounds - a lightweight planner looks at what has been gathered so far and proposes a few more targeted queries only if real coverage gaps remain, bounded by the page budget, and stops the moment nothing more is needed. Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback); retrieval reranks candidate passages with Maximal Marginal Relevance so the report is grounded on genuinely diverse evidence rather than several near-duplicate hits on the same page. A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score, source diversity and a short list of suggested follow-up questions you can ask straight into the chat; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.

`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.

`BackupService` powers the admin **Admin -> Backups** dashboard, an enterprise-grade backup system that runs entirely as asynchronous jobs so it never impacts the running server. An administrator can back up one of four targets: the **database** (a consistent SQLite snapshot of the main database and the Devii task/lesson databases, taken with SQLite's online backup API so it is consistent under WAL), **uploads** (every attachment and project file), **keys and config** (VAPID keys), or the **full data directory** (database snapshot, uploads, and keys in one archive, excluding regenerable staging, locks, caches, and container workspaces). Each backup is compressed to a `tar.gz` in a stdlib subprocess off the request path and recorded with its size, file count, and a SHA-256 checksum. Archives live under `data/backups/` (sharded on the random uuid tail) and are served only through `/admin/backups/{uid}/download`, which is restricted to the **primary administrator** - the first user created with the Admin role. Every other administrator receives a 403 from the endpoint and sees the Download button disabled with the tooltip `Not available`; creating, running, deleting, and scheduling backups remain available to all administrators. The dashboard reports detailed storage usage - the size and file count of every major data area, the total data-directory footprint, the total size and count of stored backups, and disk usage (total, used, free, percent), computed in a worker thread and cached briefly so the page never blocks. Backups can be **scheduled** (CRUD) on an interval or 5-field cron expression with a `keep_last` rotation count that prunes older backups of the same schedule; the service evaluates schedules only on the lock-owning worker so each fires exactly once. Backup archives are permanent operational artifacts: job retention only removes the tracking row, never the archive, which is deleted only by an administrator, by schedule rotation, or via the CLI. CLI: `devplace backups list` / `devplace backups run <target>` / `devplace backups prune` / `devplace backups clear`. Devii tools: `backups_overview`, `backup_run`, `backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete` (all admin-only). The service creates and stores backups but does not restore them into a live server; restore is a documented manual procedure (stop the server, unpack the archive over the data directory, verify the checksum, restart).

### Adding a service

Create a class extending `BaseService`, declare its `config_fields`, override `run_once()` (read parameters via `self.get_config()`), and register in `main.py`:
```python
service_manager.register(YourService())
```
The service appears at `/admin/services` with controls, a generated config form, live status, and the log tail - no template, JS, or router changes.

### News service

A fully automatic, zero-maintenance import pipeline. It fetches news from a configurable API and stores ALL articles in the `news` table (nothing is silently skipped). For each article it:

- **Cleans the text** - strips HTML and removes Reddit boilerplate (`submitted by /u/...`, `[link]`, `[comments]`, `[N comments]`) and collapses whitespace, before grading and before storage.
- **Fetches and perceptually compares the images** - up to five candidate images per article are downloaded through an SSRF-guarded client, decoded, and perceptually hashed. Images that are too small or fail to load are placeholders, and an image that appears across two or more different articles (a shared logo or stock placeholder) is rejected for all of them. An article with at least one genuinely unique image keeps it as its primary image.
- **Grades deterministically** - an AI model rates the cleaned article 1-10. A reliability gate forces a short, shouting, bodyless, or url-less article to draft. The final score adds a bonus for a unique image and a penalty for thin content, and that final score drives publishing.
- **Auto-promotes** - articles at or above the threshold are published; the strongest published articles with a unique image are marked **Featured**, and the service keeps the best of those on the landing page, rotating them automatically.

Admin can manually publish/draft, toggle Featured, toggle landing-page appearance, and delete; a manual Featured or landing toggle **locks** that article so the service no longer auto-manages it.

The Featured badge and the numeric Grade are editorial signals shown only to administrators. On the public news pages (listing, article, and landing-page cards) members and guests see only the article, its source, and its time; the badges remain visible to administrators and in the admin Manage News area.

Configuration on the Services tab (`/admin/services`):

| Parameter | Default | Purpose |
|-----------|---------|---------|
| `news_grade_threshold` | `7` | Minimum AI grade for auto-publish |
| `news_api_url` | `https://news.app.molodetz.nl/api` | News source |
| `news_ai_url` | `https://aquality.cloud.pravda.education/v1/chat/completions` | AI grading endpoint - a free, local, deterministic quality model by default |
| `news_ai_model` | `aquality` | Generic model name; echoed back, not used for routing |
| `news_ai_key` | internal key | AI API key (`NEWS_AI_KEY` env, then the auto-generated gateway internal key); unused by the default aquality endpoint |
| `news_format_enabled` | `false` | Reformat article bodies into Markdown with AI after grading; off by default since aquality only scores and cannot generate text - enable when `news_ai_url` points at a generative chat model |
| `news_service_interval` | `3600` | Seconds between fetch cycles (min 60) |

News articles have detail pages at `/news/{slug}` with full comment support (same component as posts/projects). The landing page can display curated articles toggled from admin.

CLI: `devplace news clear` - delete all news from local database.

### Bots service

A Playwright-driven fleet of AI personas (`devplacepy/services/bot/`) that browse and interact with a DevPlace instance: posting, commenting, voting, reacting, creating gists/projects, filing issues, following, and messaging. It is the former standalone `dpbot.py`, refactored into a package and managed entirely from the Services tab. Disabled by default.

Each persona has its own voice and its own interests: post titles are written in the persona's voice rather than copied from the source headline, news topics and post categories are weighted by personality (so different personas react to different stories), shared code snippets pass a non-triviality quality gate, and bots discuss each other's posts in threaded conversations rather than reacting in isolation.

Optionally (`bot_ai_decisions`), each bot is also given a unique AI-generated identity card (name, backstory, interests, temperament, verbosity, contrarianness, generosity, rhythm) and lets that identity decide every on-page action through the LLM instead of fixed probabilities: on each page the bot is shown the menu of actions actually available and the model returns an ordered plan. Behaviour then follows personality rather than chance, while every content-quality gate is retained. See `aibots.md` for the design and cost model.

Install the extra and the browser binary, then enable it on `/admin/services`:

```bash
pip install -e ".[bots]"
playwright install chromium
```

Configuration on the Services tab:

| Parameter | Default | Purpose |
|-----------|---------|---------|
| `bot_fleet_size` | `1` | Number of concurrent bot accounts |
| `bot_headless` | enabled | Run Chromium headless |
| `bot_max_actions` | `0` | Stop a bot after N actions (0 = forever) |
| `bot_base_url` | `https://pravda.education` | Target DevPlace instance |
| `bot_api_url` | `http://localhost:10500/openai/v1/chat/completions` | LLM endpoint (the internal gateway) |
| `bot_news_api` | `https://news.app.molodetz.nl/api` | Article source |
| `bot_model` | `molodetz` | Generic model name; the gateway maps it to the real model |
| `bot_api_key` | internal key | LLM key (defaults to the auto-generated gateway internal key) |
| `bot_input_cost_per_1m` / `bot_output_cost_per_1m` | `0.14` / `0.28` | Fallback token pricing; used only when the LLM endpoint returns no gateway cost headers. Live cost is read from the gateway's authoritative `X-Gateway-Cost-USD` per-call header |
| `bot_max_per_article` | `2` | How many bots may post about one article, each from a different angle |
| `bot_article_ttl_days` | `7` | How long an article stays covered before it can be posted again |
| `bot_gist_min_lines` | `6` | Reject generated snippets shorter than this many non-empty lines |
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | Idle pause window a bot takes after each action |
| `bot_break_scale` | `1.0` | Multiplier on between-session breaks (below 1 = more active and costlier) |
| `bot_ai_decisions` | disabled | Let each bot's AI-generated identity decide every action via the LLM instead of fixed probabilities (one decision call per page); see `aibots.md` |
| `bot_decision_temperature` | `0.4` | Sampling temperature for the per-page decision call |

The Services tab shows live fleet metrics (bots running, total cost, LLM calls, tokens, posts/comments/votes) and a per-bot breakdown that update on the page poll. Each bot keeps a stable identity and cumulative cost in `~/.devplace_bots/state_slot{N}.json`.

### LLM gateway service

`GatewayService` (`devplacepy/services/openai_gateway/`) exposes an OpenAI-compatible
endpoint at `/openai/v1/chat/completions` (plus a transparent `/openai/v1/*`
passthrough). It forwards to the configured upstream (DeepSeek by default) and, when a
request carries image content, first describes the image(s) via a vision model
(OpenRouter/Gemma by default) and inlines the description as text - so a vision-less
upstream can still answer. It also serves OpenAI-compatible **text embeddings** at
`/openai/v1/embeddings`: clients send the generic model `molodetz~embed`, which the gateway
maps to the configured embedding model (OpenRouter's Qwen3 8B embedding model by default,
$0.01 per 1M input tokens), with usage and cost tracked per call like chat and vision.
Enabled by default and configurable on `/admin/services`.

It is the **single point of truth for AI**: the news, bots, and guest Devii sessions call this
gateway by default instead of an external provider, send the generic model name `molodetz`,
and authenticate with an auto-generated internal key. The real provider URLs, models, and keys
live only here, so providers and backends are switched in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on first boot, and the value in use
is shown. The internal base URL the other services dial is `DEVPLACE_INTERNAL_BASE_URL`
(default `http://localhost:10500`).

Authenticated users may call the gateway with their own API key (the `Allow users` setting,
on by default), and Devii operating a signed-in user authenticates its LLM calls with that
user's key. Every call is therefore attributed to the user, so their full AI spend (through
Devii or direct API calls) is tracked and limitable per user. Administrators see a per-user
"AI usage (last 24h)" panel on each profile page - request volume, success and error rates,
token totals, cost, average latency and throughput, a per-model breakdown, a 30-day cost
projection, and a quota bar - served from `GET /admin/users/{uid}/ai-usage`. A non-admin user
sees only one figure on their own profile: the percentage of their daily AI quota used so far.
The same rule governs the Devii assistant: monetary figures (cost, pricing, spend, limit) are
disclosed only to administrators, while members and guests can see only the percentage of their
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
everyone, and includes dollar figures only for administrators.

**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
gateway spend with quota rules scoped by any combination of caller role, individual user, and
application reference (the `X-App-Reference` header), so a single application belonging to one
user can be limited independently of that user's other traffic. The most specific matching rule
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
time, each rule has a **Reset spend** action that clears what has been counted against it
without deleting anything from the usage history the cost analytics are built on - the
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.

Configuration on the Services tab:

| Parameter | Default | Purpose |
|-----------|---------|---------|
| `gateway_upstream_url` | `https://api.deepseek.com/chat/completions` | Where requests are forwarded |
| `gateway_model` | `deepseek-v4-flash` | Real model sent upstream (what `molodetz` maps to); 1M-token context, 384K max output |
| `gateway_force_model` | on | Override the client-requested model (and the `molodetz` alias) |
| `gateway_thinking` | off | When off, chat completions disable model thinking unless the client explicitly enables it (`think` / `thinking` / `reasoning`). Fastest default. |
| `gateway_api_key` | migrated from env | Upstream key, shown and editable (auto-migrated from `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY`) |
| `gateway_instances` | `4` | Max concurrent upstream forwards per worker (pool + semaphore) |
| `gateway_timeout` | `300` | Upstream timeout (seconds); minimum five minutes; also bounds the vision describe-image call |
| `gateway_vision_enabled` / `_url` / `_model` / `_key` / `_cache_size` | on / OpenRouter / Gemma / env / 256 | Image-description augmentation |
| `gateway_embed_enabled` / `_url` / `_model` / `_key` | on / OpenRouter / Qwen3 8B / vision-key fallback | Embeddings at `/openai/v1/embeddings` (client model `molodetz~embed`) |
| `gateway_require_auth` | on | When off, the gateway is open |
| `gateway_allow_admins` / `gateway_allow_users` | on / off | Which DevPlace users may call it (any auth scheme) |
| `gateway_access_key` | empty | A standalone key (sent as `X-API-KEY`/Bearer) that always grants access |
| `gateway_internal_key` | auto (uuid4) | Auto-generated on boot; DevPlace's own services authenticate with this. Clear and restart to rotate |
| `gateway_price_cache_hit_per_m` / `_cache_miss_per_m` / `_output_per_m` | 0.0028 / 0.14 / 0.28 | Chat cost per 1M tokens, used when the upstream returns no native cost (DeepSeek) |
| `gateway_vision_price_input_per_m` / `_output_per_m` | 0 / 0 | Vision cost per 1M tokens, used only when the vision upstream returns no native cost |
| `gateway_embed_price_input_per_m` | 0.01 | Embeddings cost per 1M input tokens, used only when the embeddings upstream returns no native cost |
| `gateway_rsearch_cost_per_call` | 0.0 | Flat cost attributed to each external `rsearch` call (web search / AI answer / chat / image describe), recorded under backend `rsearch` so external AI spend appears in AI usage |
| `gateway_max_retries` / `gateway_retry_backoff_ms` | 2 / 250 | Retry attempts and linear backoff on timeout, connection error, or upstream 5xx |
| `gateway_circuit_threshold` / `gateway_circuit_cooldown_seconds` | 5 / 30 | Consecutive failures before the circuit breaker opens, and its cooldown |
| `gateway_usage_retention_hours` | 720 | How long per-call usage rows are kept before pruning (30 days) |
| `gateway_model_context_map` | JSON map | Model to max-context-tokens map for context-window utilization tracking |

Callers authenticate with the static `gateway_access_key`, the auto-generated
`gateway_internal_key` (used by the platform's own services), or - when allowed - their
DevPlace credentials via any scheme (`X-API-KEY`, Bearer, Basic). The endpoint is
exempt from the per-IP rate limit and the maintenance gate, and its concurrency is
bounded by `gateway_instances`; scale further with uvicorn workers. The Services tab
shows live request/error/latency/vision-call counters.

#### Usage and cost metrics

Every upstream call (chat, vision, and passthrough) is recorded to `gateway_usage_ledger`
with its tokens, cost, latency breakdown, status, and caller. Cost is taken from the
upstream native `cost` field when present (OpenRouter) and computed from the configured
per-million pricing otherwise (DeepSeek, which reports no cost). External `rsearch` web
tools do not pass through the gateway upstream, so each call is also recorded to the same
ledger under backend `rsearch` (zero tokens, the flat `gateway_rsearch_cost_per_call`),
keeping the ledger a complete record of platform AI spend. The admin **AI usage**
page (`/admin/ai-usage`) reports per-hour, 24h, and **all-time** metrics - request volume and throughput,
token usage with averages and percentiles (p50/p90/p95/p99), latency (upstream round-trip,
gateway overhead, semaphore queue wait, connection establishment), error rates by category,
cost (per model, per caller, input vs output, projected monthly burn, caching savings),
caller behavior, and an hourly breakdown - divided and combined. The gateway also retries
transient upstream failures and opens a circuit breaker after repeated failures. Time to
first token and inter-token latency are not reported because the gateway forwards
non-streaming to the upstream.

```bash
curl -H "X-API-KEY: <gateway_access_key>" \
  -d '{"messages":[{"role":"user","content":"hi"}]}' \
  https://your-host/openai/v1/chat/completions
```

### Devii assistant service

`DeviiService` (`devplacepy/services/devii/`) is an in-platform agentic assistant exposed
as a WebSocket terminal at `/devii/ws`, reachable from the **Devii** item in the user
dropdown menu and embedded on the documentation page for guests. It runs a ReAct loop
(planning, reflection, a verification gate, self-learning memory, autonomous tasks) over a
declarative tool catalog. A signed-in user's Devii operates their **own** DevPlace account -
the agent authenticates to this instance with the user's `api_key` - while guests get a
sandbox session. Disabled by default; enable and configure it on `/admin/services`.

Sessions are durable. A logged-in user's conversation is keyed by user uid, shared live
across every tab, window, and device (each turn is broadcast to all connected sockets), and
persisted to `devii_conversations` so it survives a server restart - on reconnect the prior
conversation is rehydrated. Guests get a non-persistent session keyed by a `devii_guest`
cookie. To keep one logical session and one cost/broadcast source per user, the `/devii/ws`
endpoint is served only by the worker that holds the background-service lock; other workers
close the socket so the client reconnects to the owning worker.

Spend is capped per owner over a rolling 24 hours. Every turn appends a row to
`devii_usage_ledger` (the authoritative source for the cap - the in-memory cost tracker is
display-only) and an audit row to `devii_turns`. The cap is checked before each turn.

**Reminders and scheduled tasks.** Ask Devii to remind you of something ("remind me to go
upstairs in 40 seconds", "every weekday at 9am post the news"), and it schedules the work
instead of doing it immediately. A scheduled task stores a self-contained prompt that a fresh
agent runs when it fires - once after a delay or at an absolute time, on a repeating interval,
or on a cron expression. Reminders are **timezone-aware**: your browser's timezone is sent to
Devii and used to interpret wall-clock times you give ("3pm" means 3pm where you are),
converting them to UTC for storage. They are **persistent**: tasks live in `devii_tasks` and
are run by the background service, so a queued reminder survives a server restart and fires
even if you have closed the Devii terminal. When a reminder fires you receive an in-app
notification and a live toast carrying its message (the **Reminders** notification type, which
you can toggle like any other on your profile), in addition to the result appearing in the
terminal.

**Every account may schedule, within two rolling 24-hour quotas.** A member may create 5 tasks
and execute 10 task runs per 24 hours; an administrator may create 5 and execute 100. Deleting a
task does not give a creation slot back, and a run that would exceed the quota is **postponed
until a slot frees, never dropped or disabled** - the task simply runs later, and the exact time
its next slot opens is reported. All four numbers are adjustable on the Devii service page, where
0 means unlimited. Guests cannot schedule at all.

**A task knows when it is running as a task, and a member's task cannot spawn more tasks.** While
a scheduled run is executing, creating a task, re-enabling one, or triggering one immediately is
refused for members - so a member's automation can never fan out into more automation. An
administrator's task may schedule follow-up work, and every new task and run still counts against
the same quotas. The assistant is told which environment it is in, and the restriction itself is
enforced by the server rather than by the instruction, so no prompt can talk its way around it.

Every scheduled task is also bounded in time: a repeating task must leave at least fifteen minutes
between runs, carries a maximum number of executions, and expires at most thirty days after its
first run. A task that fails several times in a row, whose owner has been inactive for a month, or
that passes its automation spend limit is disabled automatically with the reason recorded in the
audit log. Across the whole platform only a few scheduled tasks run at the same time, handed out
one at a time per owner, so a single account can never monopolise the scheduler. Administrators
see every task, its owner, its 24-hour usage, and its bounds at **Admin -> Devii tasks**, where any
task can be disabled or deleted, and the same is available from the command line with
`devplace devii tasks`.

Configuration on the Services tab:

| Parameter | Default | Purpose |
|-----------|---------|---------|
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
| `devii_ai_model` | `molodetz` | Model name |
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
| `devii_base_url` | this instance's origin | Platform Devii drives via each user's API key |
| `devii_plan_required` / `devii_verify_required` | on / on | Enforce plan-first and verify-after-mutation |
| `devii_max_iterations` | `40` | Tool-loop iterations per turn |
| `devii_timeout` | `300` | Read timeout (seconds) for each AI chat-completions call; minimum five minutes |
| `devii_fetch_timeout` | `300` | Read timeout (seconds) for the fetch tool; minimum five minutes |
| `devii_user_daily_usd` | `1.0` | Rolling 24h spend cap per signed-in user |
| `devii_guest_daily_usd` | `0.05` | Rolling 24h spend cap per guest |
| `devii_admin_daily_usd` | `0.0` | Rolling 24h spend cap per administrator (`0` = unlimited; admins are exempt by default) |
| `devii_guests_enabled` | on | Allow anonymous guests |
| `devii_price_cache_hit` / `_cache_miss` / `_output` | `0.0028` / `0.14` / `0.28` | USD per 1M tokens (drives the cap) |
| `devii_rsearch_enabled` | on | Enable the external web search tools (`rsearch_*`) |
| `devii_rsearch_url` | `https://rsearch.app.molodetz.nl` | Base URL of the web search service those tools call |
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |

Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
external REST/JSON API with custom headers and a JSON, form, or raw body, returning the full
response (non-2xx is returned rather than raised so API errors are readable). Both carry an SSRF
guard (private and loopback addresses are refused) and a size cap. `attach_url` downloads a public
URL on the server and stores it as a real attachment through the same pipeline as a direct upload,
returning a uid Devii then passes in `attachment_uids` when creating a post, project, gist, comment,
issue, or message - so a user can ask Devii to attach an image straight from the internet. Devii also
has external **web search** tools - `rsearch` (web/image search), `rsearch_answer` (a web-grounded
AI answer with sources), `rsearch_chat` (direct AI chat), and `rsearch_describe_image` (vision).
These reach an external public service rather than this platform, so platform tools are always
preferred; Devii uses them only when the user explicitly asks to search the web or an outside
source. They are gated by `devii_rsearch_enabled` and the service URL is configurable via
`devii_rsearch_url`.

A signed-in user's Devii can also connect to their **own email** over IMAP and SMTP. The user
configures one or more accounts conversationally (`email_account_set` stores a connection under a
label - host, port, username, password, with sensible defaults of IMAP 993 over SSL and SMTP 587
with STARTTLS), and Devii can then list folders, list and search messages, read a full message with
its attachments, mark messages read or flagged, move messages between folders, delete messages, and
send mail. Credentials are stored per user and never shown back (the password is only reported as
set or not), the mail server is SSRF-guarded against private and loopback addresses, sending is an
explicit action Devii confirms with the user first, and deleting a message or removing a saved
account is confirmation-gated. The tools are gated by `devii_email_enabled` and time out per
`devii_email_timeout`. Email is configured only through Devii (no separate settings page) and is
available to signed-in users, not guests.

A `devii` console script ships the same agent as an interactive terminal:

```bash
pip install -e .
devii --api-key <your DevPlace api_key> --base-url https://your-host
devii -p "List my unread notifications as a bullet list."   # one-shot
```

### Devii on Telegram

`TelegramService` (`devplacepy/services/telegram/`) puts Devii on Telegram. A user pairs
their Telegram account by requesting a four digit code from their profile (valid one hour by
default), then sends that code to the bot; once paired, chatting with the bot talks to their
own Devii exactly like the web terminal, with markdown replies, a typing indicator, live
message editing instead of message spam, and image understanding (a sent photo is read by the
gateway vision model). The Telegram thread is an isolated conversation but shares the user's
Devii memory, tools, and the same rolling 24 hour spend cap.

The service is **off by default** (not every deployment has a bot token) and is started,
stopped, configured, and monitored from `/admin/services` like any other background service.
Its operational log streams live on the service detail page and is never written to the
database. The Telegram long-poller runs as a supervised subprocess so it stays isolated from
the web workers; because only the background-service lock owner runs the service, there is
always exactly one poller (Telegram rejects concurrent polling per token).

Devii also gains a `telegram_send` tool (only usable by a signed-in, paired user) so it can
push a message to the user's Telegram from a turn or a scheduled task - the basis for future
Telegram notifications.

Configuration on the Services tab:

| Parameter | Default | Purpose |
|-----------|---------|---------|
| `telegram_bot_token` | (secret) | Bot token from @BotFather; required to start |
| `telegram_poll_timeout` | `25` | getUpdates long-poll hold time (seconds) |
| `telegram_code_ttl_minutes` | `60` | Pairing code lifetime |
| `telegram_max_concurrent_turns` | `8` | Upper bound on Devii turns across all chats |

### Site customization (per-user CSS/JS)

Each user can reshape the site to taste by injecting their own **CSS** (look) and
**JavaScript** (behaviour), scoped either to a single **page type** (every post page, every
project page) or **globally** (every page). Customizations apply only to that user's own
sessions; a signed-out guest gets customizations scoped to their `devii_guest` session cookie.
This is the user's own code running in their own browser, like a userscript, so it never affects
anyone else's view.

It is configured conversationally through Devii. Ask for a change and Devii previews it live in
your browser, then before saving it asks whether to apply it to the current page type or the
whole site, and only persists once you confirm. Overrides are stored in the `user_customizations`
table (one row per owner, scope, and language; cached with cross-worker invalidation) and injected
server-side: custom CSS is the last `<style>` in `<head>` so it overrides the design system, and
custom JavaScript runs after the application boots with access to the global `app`. Devii tools:
`customize_list`, `customize_get`, `customize_set_css`, `customize_set_js`, and `customize_reset`
(restores the default look and behaviour). Operators can disable the feature site-wide with the
`customization_enabled` / `customization_js_enabled` settings.

Your profile page carries two toggle buttons that suppress your customizations without deleting
them: one for the site-wide (global) CSS and JS, one for the per-page-type CSS and JS. Disabling
keeps the stored code and re-enables instantly, which is the quick way to recover a page broken by
your own custom code. You see the toggles only on your own profile; an administrator can also
operate them on any user's profile (or call the Devii `customize_set_enabled` tool) to recover a
user who locked themselves out.

### User-defined tools (vibe new functionality)

Users can invent new Devii capabilities by describing them in natural language, with no code. Telling
Devii "when I say woeii, search the web for a cat picture matching my description and set it as my
page background" makes it call `tool_create` and store a virtual tool. The tool then appears in
Devii's own toolset, and on the next turn "woeii orange" calls it: the handler re-runs Devii itself
(a self-evaluating sub-agent) on the stored prompt plus the user's input, with full tool access, and
returns the result. Each tool takes a single free-form `input` string; the stored prompt directs what
to do with it.

Virtual tools are owner-scoped (persistent in the DB for signed-in users in `devii_virtual_tools`,
session-only for guests) and never run in anyone else's session. The full CRUD is exposed as Devii
tools: `tool_create`, `tool_list`, `tool_get`, `tool_update` (including enable/disable), and
`tool_delete`. A general `eval(prompt)` tool runs Devii on any prompt and returns the result; it is
the same engine that powers virtual tools. Self-evaluation depth is bounded so a tool cannot loop by
calling itself.

## Push notifications & PWA

Authenticated users can receive native push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
no third-party push wrapper.

### Providers

Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
receives a notification through every provider they hold a live subscription for.

| Provider | Registration | Transport |
|----------|--------------|-----------|
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
| `apns` | Hexadecimal device token, optional stable `client_id` | Dedicated HTTP/2 client to `api.push.apple.com` or `api.sandbox.push.apple.com` (`POST /3/device/{token}`), ES256 provider token. Not the stealth/Chrome client. |

`POST /push.json` accepts a registration for any active provider; a body without a
`provider` field is a `webpush` body, so browsers need no change. An APNs body is
`{"provider": "apns", "token": "...", "client_id": "..."}` - `client_id` is optional and
identifies the device across Apple token rotations, so a new token updates that row
instead of inserting another. A token-only body still works and revives a previously
dead token. `GET /push.json` returns the VAPID public key plus the providers currently
accepting registrations; when `apns` is active it includes `environment` (`production` or
`sandbox`). A provider that is disabled or not fully configured accepts no registrations
and is skipped during delivery, so an unconfigured provider is inert rather than an error.

Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
masked secret), topic and environment (production or sandbox) for `apns`. The same page
holds the shared delivery timeout and the retention window after which dead subscriptions
are removed. Push delivery does not depend on that service running; stopping it only stops
the pruning sweep.

Adding a third provider is one file plus one registry entry: the registration route, the
delivery loop, the admin page, the audit record and the metrics are all written against the
provider protocol.

### Events

Every event flows through a single funnel - `create_notification()` in `utils/` -
which delivers on three independent channels, in-app, web push and Telegram, each gated by the
recipient's preferences (see "Configurable notifications" below). Whenever the in-app
channel delivers, the recipient's open browser also raises a live, click-through toast
in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:

| Event | Recipient |
|-------|-----------|
| Direct message received | receiver |
| Comment on your post | post author |
| Reply to your comment | comment author |
| Any comment on a post you've also commented on | every other commenter on that post |
| `@mention` in any content | mentioned user |
| Upvote on your content | content owner |
| New follower | followed user |
| Badge earned / level-up | the user |
| Issue-tracker update | reporter / admins |

`create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
each provider's payload once, and sends over that provider's own HTTP client (stealth for
Web Push, a dedicated HTTP/2 client for APNs). A subscription the push service reports as
gone (`404`/`410` for Web Push, `410` or an `Unregistered` class reason for APNs) is
soft-deleted and the provider reason is logged; any other failure is logged and the
subscription is kept. APNs environment is stored per registration so a sandbox debug
token and a production token can coexist.

A notification is also **marked read automatically when you open the page that shows its
content** - viewing a post clears its comment, reply, thread, upvote and mention notifications;
opening a conversation clears its direct-message notifications; visiting a profile clears
the matching follow, badge and level notifications; and the issue, reminder and farm-raid
notifications clear on their respective pages. You no longer have to dismiss each one by
hand after reading the content it points to.

### Configurable notifications

Every notification type can be turned on or off per channel, per user. The **Notifications**
tab on a profile page (`/profile/{username}?tab=notifications`, visible to the profile owner
and to admins) shows one row per type with three checkboxes - **In-app**, **Push** and
**Telegram** - saved individually as you toggle them (`POST /profile/{username}/notifications`).
A "Reset to defaults" button clears all of a user's overrides
(`POST /profile/{username}/notifications/reset`). The Telegram column is disabled until the
user pairs Telegram from the profile Telegram panel; once paired, opting a type in delivers
that notification to the user's Telegram chat.

Defaults are opt-out for in-app and push (a type/channel a user never touched is enabled) and
opt-in for Telegram (every type is off by default). Admins set the platform-wide default for
each type/channel on `/admin/notifications` (`POST /admin/notifications`); a default applies
only to users who have not made an explicit choice. Resolution is: user override, else admin
default, else the channel fallback. Preferences are stored in the `notification_preferences`
table (per `user_uid` + `notification_type`, soft-deletable) and enforced inside
`create_notification()`: the in-app row is written only when the in-app channel is enabled,
`push.notify_user` is scheduled only when the push channel is enabled, and a Telegram message
is queued (to the `telegram_outbox`, drained on the service-lock owner where the bot runs)
only when the Telegram channel is enabled and the user is paired.

### VAPID keys

The server identity is three PEM files generated once at startup under
`data/keys/`: `notification-private.pem`, `notification-private.pkcs8.pem`,
`notification-public.pem`. They are git-ignored.

**These keys are the application's identity to the push services. If they are lost or
regenerated, every existing subscription becomes permanently undeliverable.** Persist
them across deployments and back them up; do not regenerate them.

### Opt-in

The browser requires the first permission prompt to originate from a user gesture, so
opt-in is exposed as a button in both the top navigation (bell-with-slash icon) and on
the `/notifications` page. After opt-in, the subscription is refreshed silently on
every page load. `PushManager.js` owns registration, subscription, and the opt-in UI.

### PWA

`manifest.json` (192/512 and maskable icons) and `service-worker.js` make the app
installable via the browser's native install affordance. The service worker uses a
network-first strategy for navigations and falls back to `static/offline.html` when
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).

| File | Role |
|------|------|
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
| `devplacepy/push/store.py` | `push_registration` reads and writes |
| `devplacepy/push/delivery.py` | `notify_user` / `notify_registration` - group by provider, per-provider client, reap dead subscriptions |
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
| `static/manifest.json` | PWA manifest (icons, display, theme) |
| `static/offline.html` | Offline fallback page |

## Database

SQLite via `dataset` with production-oriented pragmas set on every connection:

```sql
PRAGMA journal_mode=WAL;      -- concurrent readers + writers
PRAGMA synchronous=NORMAL;     -- safe with WAL, faster than FULL
PRAGMA busy_timeout=30000;     -- wait 30s instead of failing on lock
PRAGMA cache_size=-8000;       -- 8MB page cache
PRAGMA temp_store=MEMORY;      -- temp tables in memory
PRAGMA mmap_size=268435456;    -- 256MB memory map for reads
```

All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except - safe to run on every startup regardless of table state.

SQLite is synchronous by design and will never be made async. It is more than fast enough for this platform: the database is a local file with WAL, a 30s busy timeout, and a 256MB memory map, so queries are sub-millisecond. The synchronous database layer is a deliberate architectural decision, not a limitation, and is not subject to change.

### Soft delete and data retention

Removing a record is a **soft delete**, not a physical one: it stamps `deleted_at` (timestamp) and `deleted_by` (actor) instead of erasing the row, and every list/count read filters `deleted_at IS NULL`, so the item disappears from the product while remaining recoverable. This covers user content (posts, comments, gists, projects, news, project files, attachments), engagement toggles (votes, reactions, bookmarks, follows, poll votes - which revive the same row when re-toggled), sessions (logout), container instances, and per-owner Devii/customization data. Deletions cascade with a shared timestamp so the whole event restores or purges as a unit. Garbage-collection operations (job retention, metrics ring buffers, usage-ledger pruning, expired-session cleanup) stay hard deletes - that is the stage that frees storage. Administrators review, restore, and permanently purge soft-deleted content from **Trash** at `/admin/trash`; the columns are indexed (`idx_<table>_deleted`).

A member may delete only their own content, but an **administrator may delete any member's** post, comment, gist, project, project file, or attachment - the owner-or-admin check lives on each delete endpoint, so it applies equally to the web UI and to the Devii assistant (which acts purely through the platform API as the signed-in user). When an admin's Devii is asked to delete something it requires explicit confirmation before each deletion, and the result is the same soft delete, restorable from Trash.

### Database API (`/dbapi`, primary administrator only)

The **primary administrator** (the oldest Admin account, the same identity that may download backups) authenticated by session or their API key can **read** any table through a single, safe API; members, guests, every other administrator, and internal/service callers (the gateway internal key is not accepted) all get `403`. The database API is **strictly read-only** - it can never insert, update, replace, delete, or restore data in any way.

- **Read per table:** `GET /dbapi/{table}` (filtered, searchable, keyset pagination) and `GET /dbapi/{table}/{key}/{value}`. There are no write endpoints; deny-listed tables (sessions, password resets) are never exposed.
- **`query()` is read-only:** `POST /dbapi/query` runs a single validated SELECT and returns rows. Every query is parsed (sqlglot), classified, and dry-run with `EXPLAIN` on a read-only connection before execution; non-SELECT statements (INSERT/UPDATE/DELETE/DDL) are refused, and a SELECT with no WHERE/JOIN/LIMIT is flagged as suspicious.
- **Ask in plain language:** `POST /dbapi/nl` turns a question such as *"all users registered longer than three days"* into a validated SELECT (auto-adding `deleted_at IS NULL` for soft-delete tables), using the platform AI gateway and re-prompting until the SQL validates; pass `execute=true` to also run it read-only.
- **Async:** `POST /dbapi/query/async` runs a heavy read query off the request path and streams progress over `WS /dbapi/query/{uid}/ws`.
- The Devii assistant exposes the same read-only capability to the **primary administrator only** (list, get, query, and natural-language SELECT); the database tools are added to the tool list only for that user, so every other administrator's Devii does not see them and is unaware the database API exists. It cannot change data through the database API.

### Pub/Sub bus (`/pubsub`)

A database-free publish/subscribe bus for live updates without polling. Clients connect to `WS /pubsub/ws` to subscribe to topics (with `foo.*` wildcards) and publish messages; backends and administrators can also publish over `POST /pubsub/publish`. Users may use their own `user.{uid}.*` namespace and subscribe to shared `public.*` topics; administrators and internal services may use any topic. The browser client is available as `app.pubsub.subscribe(topic, cb)` / `app.pubsub.publish(topic, data)`. The bus is in-memory and best-effort by design.

Two background services bridge persisted state onto the bus so the interface updates without per-client polling: the **Notification relay** pushes new in-app notifications as live toasts and refreshes each viewer's unread notification and message badges instantly, and the **Live view relay** pushes the admin live views (container list and instances, bot fleet, background services, AI usage, backups) to whichever administrators are watching, computing a snapshot only for views that currently have subscribers. Both run on the single service-lock owner and degrade to a low-frequency HTTP poll if the bus is unavailable.

## Testing

- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
- **A directory tree that mirrors the path.** api/e2e follow the endpoint path - each route segment is a directory and the last segment is the file, `{param}` segments dropped (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`, `GET /projects/{slug}/files/lines` -> `tests/api/projects/files/lines.py`). unit mirrors the source module path (`devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). Run one tier with `make test-unit` / `make test-api` / `make test-e2e`
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Runs serially, one test at a time, in a single process (`make test`); the suite drives one uvicorn subprocess on port 10501 with its own temp database and `DEVPLACE_DATA_DIR`
- Serial execution is enforced in `pyproject.toml` (`[tool.pytest.ini_options]` `addopts = "--tb=line -p no:xdist"`), so concurrent runs cannot be turned on by accident
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
- Tests stop at first failure (`-x` flag)
- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/`
- Headed mode: `make test-headed` (a single Chromium window)

### Key test patterns

- Every `page.goto()` and `page.wait_for_url()` uses `wait_until="domcontentloaded"` - avatar images don't block test execution
- Session-scoped browser context with per-test cookie clearing
- `page.locator(...).wait_for(state="visible")` preferred over bare selectors

## Avatars

Uses [Multiavatar](https://github.com/multiavatar/multiavatar-python) - generates deterministic SVG avatars locally from a seed string (the username). No external API calls, no network dependency. Generation takes <5ms. Results are cached in-memory (cleared on server restart).

Avatar URL format: `/avatar/multiavatar/{username}?size={size}`

## Deployment

Production runs on a single host via Docker Compose: an **app** container (Uvicorn, 2 workers) behind an **nginx** container that terminates HTTP, serves static assets, and proxies the rest.

### Shared database and uploads

Every runtime artifact lives under one consolidated `data/` directory (`DEVPLACE_DATA_DIR`, default `<repo>/data`), defined once in `config.py` via the `DATA_PATHS` registry and created at startup by `ensure_data_dirs()`: `data/devplace.db` (+ `-wal`/`-shm`), `data/devii_tasks.db`, `data/devii_lessons.db`, `data/uploads/` (attachments and project files), `data/keys/` (VAPID), `data/locks/`, `data/bot/`, and the job staging / container workspace dirs. Nothing runtime is written inside the package any more.

The app container bind-mounts the host project directory (`./` to `/app`) and runs as the host user (`DEVPLACE_UID`/`DEVPLACE_GID`, default `1000`). Because `config.py` resolves every path to an absolute location under the project's `data/` dir, the container reads and writes the **same `data/devplace.db`** as `make dev`, plus the same `data/uploads/`, `data/devii_*.db`, `data/keys/`, and `data/locks/devplace-services.lock`. SQLite WAL mode allows the concurrent access, and the file lock (`fcntl.flock` on `data/locks/devplace-services.lock`) guarantees only one process - dev or prod - runs the background services (news, bots, Devii hub), so they never double-fire.

This requires prod and dev to be on the **same host/filesystem**; SQLite is a local-file database and cannot be shared across machines. No `DEVPLACE_DATABASE_URL` override is set, so nothing diverges.

To migrate an older install whose data was scattered across the repo root, the package, `var/`, and `$HOME`, stop the app and run `devplace migrate-data --dry-run` (review the plan), then `devplace migrate-data`. It is idempotent and copy-before-delete (verify CRC, atomic replace, then unlink the source), so it is safe to re-run.

### First deploy

```bash
cp .env.example .env        # set SECRET_KEY; adjust PORT, DEVPLACE_SITE_URL
make docker-build           # build the image (installs the docker CLI)
make docker-up              # start (creates ./data, mounts the socket)
```

Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `make docker-down` stops.

`make docker-build` and `make docker-up` always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Mounting the docker socket grants the app **root on the host**; the feature itself stays disabled until an admin enables **Containers** on `/admin/services` (and the `ppy` image is built once with `make ppy`), but treat the whole deployment as trusted-admins-only. Always update through the make targets - a plain `docker compose up -d` drops the overlay and silently removes the socket and CLI.

### Updating

```bash
git pull
make docker-reload          # restart workers on the new code (bind-mounted, no rebuild)
make docker-build && \
  make docker-up            # only when dependencies in pyproject.toml change
```

`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.

The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.

### Container Manager wiring (what the overlay does)

The Container Manager drives the host Docker daemon, so `make docker-build`/`make docker-up` apply `docker-compose.containers.yml` automatically. The make targets derive everything the overlay needs - `DOCKER_GID` from the socket and `DEVPLACE_DATA_DIR` as the project's own `./data` at its real host path - so no `sudo`, no `/srv` dir, and no manual `.env` editing are required. (Override `DEVPLACE_DATA_DIR` or `DOCKER_GID` on the make command line to point elsewhere.)

What the overlay (`docker-compose.containers.yml`) changes:

- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every run/exec/lifecycle operation is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).

One piece of wiring cannot live in the overlay:

- **Workspace tunnel reach.** A workspace tunnel serves a port the member chose, which is almost never published on the host, so the app has to dial the container directly - and it can only do that from the container's own docker network. Compose cannot attach a service to the default `bridge` network (it always sends network-scoped aliases, which that network rejects), so `make docker-up` and `make docker-reload` run `make docker-attach`, an idempotent `docker network connect` of the app container to `DEVPLACE_CONTAINER_NETWORK` (default `bridge`). A bare `docker compose up -d` skips it and every tunnel to an unpublished port answers `502`. On a bare-metal `make prod` deploy the app is already on the host and reaches container IPs with no wiring at all.

Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.

### nginx specifics

- **WebSockets:** `/devii/ws` (the Devii terminal) and `/p/` (container ingress) are proxied with `Upgrade`/`Connection` headers and 1h timeouts; `/p/` also disables buffering for streaming.
- **Uploads:** `/static/uploads/` is served with `X-Content-Type-Options: nosniff` and a `Content-Disposition` that mirrors the app's `UploadStaticFiles` control: known-safe media (images, video, audio) is served `inline` so it embeds and plays in the page, while every other type is forced to `attachment` so it downloads instead of rendering (stored-XSS defense). SVG is deliberately excluded from the inline set.
- **Upload size:** `NGINX_MAX_BODY_SIZE` (default `50m`) must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings), or large uploads are rejected with HTTP 413 before reaching the app.
- **Micro-cache:** off by default; enable with `NGINX_CACHE_ENABLED=true`.

### Client IP behind a proxy

The app resolves the real client address via `utils.client_ip(request)`, which reads `X-Real-IP` first, then the leftmost hop of `X-Forwarded-For`, then falls back to `request.client.host`. This is the single source used by the rate limiter, the audit log (`actor_ip`), and guest-scoped job ownership, so every logged IP is the actual visitor rather than the proxy loopback. The bundled nginx config already sets both headers on every location. When fronting the app with **Caddy**, `X-Forwarded-For` is set automatically, but `X-Real-IP` is not; add an unspoofable real-IP header inside the `reverse_proxy` block for the strongest attribution:

```
reverse_proxy localhost:10500 {
    header_up X-Real-IP {remote_host}
}
```

### Static asset caching

Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a version path segment, `/static/v<app-version>-<boot-id>/...` (`config.STATIC_VERSION`) - `<app-version>` is `pyproject.toml`'s `version` (auto-bumped on every commit, see "Version bumping" in `CLAUDE.md`) and `<boot-id>` is the unix time the server process started. A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.

The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`.

### Bare-metal alternative

`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. It binds port 10500 by default, so it conflicts with the Docker front door on the same port - run one, or set `DEVPLACE_PORT=<other-port> make prod` (also honoured by `make dev`).

### Multi-worker safety

The app runs correctly under multiple Uvicorn workers (independent processes sharing only the DB and lock files). Cross-worker invalidation of the auth and settings caches is coordinated through a `cache_state` version table (propagation bounded to ~2s); the rate limiter enforces `ceil(limit / DEVPLACE_WEB_WORKERS)` per process so the aggregate honours the configured value - **set `DEVPLACE_WEB_WORKERS` to match `--workers`** (done in `make prod` and the Dockerfile). First-boot DB init and VAPID key generation are `flock`-serialized. Full detail and the design rules are in the admin docs at `/docs/production-concurrency.html`.

## CI/CD

Gitea Actions workflow at `.gitea/workflows/test.yaml`:
- Runs on push/PR to `master`
- Sets up Python 3.13, installs dependencies + Playwright Chromium
- Runs the full test suite serially under coverage (`coverage run -m pytest tests/`); `.coveragerc` `parallel=true` lets the app's uvicorn subprocess write its own `.coverage.*` file, which `coverage combine` merges with the test-process data
- Builds and publishes the coverage HTML as an artifact on every run
- Uploads failure screenshots as artifacts when a test fails

Changes are promoted through automated DTAP streets: Development (`make dev`), Test (the CI suite + coverage on `master`), Acceptance (the `master` to `production` promotion via `make deploy`), and Production (the Docker Compose stack on the host). Only CI-green `master` commits reach `production`.

## Feature workflow

1. Implement the feature (router + template + CSS + JS)
2. Validate each touched language (Python compiles/imports, JS parses, CSS and HTML balance)
3. `make test` - run all tests (fail-fast)
4. Add tests in the matching tier and endpoint file (`tests/{unit,api,e2e}/<endpoint>.py`) for new functionality
5. Update the relevant nested `CLAUDE.md` and `README.md` if new conventions were introduced

## License

MIT - DevPlace
