from devplacepy.constants import TOPICS, REACTION_EMOJI
VOTE_TARGETS = ["post", "comment", "gist", "project"]
REACTION_TARGETS = ["post", "comment", "gist", "project"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
COMMENT_TARGETS = ["post", "project", "news", "bug", "gist"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
"python", "javascript", "typescript", "html", "css", "c", "cpp", "java",
"go", "rust", "sql", "bash", "json", "markdown", "plaintext",
]
SERVICE_ACTIONS = [
("start", "Start the service"),
("stop", "Stop the service"),
("run", "Trigger a single run now"),
("clear-logs", "Clear the in-memory log buffer"),
]
def field(name, location, type="string", required=False, example="", description="", options=None):
spec = {
"name": name,
"location": location,
"type": type,
"required": required,
"example": example,
"description": description,
}
if options:
spec["options"] = list(options)
return spec
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
def endpoint(id, method, path, title, summary, auth="user", ajax=False,
encoding="none", interactive=True, destructive=False,
params=None, notes=None, sample_response=None, negotiation=None):
return {
"id": id,
"method": method,
"path": path,
"title": title,
"summary": summary,
"auth": auth,
"min_role": ROLE_LABELS.get(auth, auth.title()),
"ajax": ajax,
"encoding": encoding,
"interactive": interactive,
"destructive": destructive,
"params": params or [],
"notes": notes or [],
"sample_response": sample_response,
"negotiation": negotiation,
}
NON_BODY_ENDPOINTS = {"avatar", "gateway-passthrough", "notifications-open"}
def _classify(ep):
if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
return "negotiable"
if ep.get("ajax"):
return "ajax"
if ep["id"] in NON_BODY_ENDPOINTS:
return "none"
return "json"
def _service_control_endpoints():
endpoints = []
for action, summary in SERVICE_ACTIONS:
endpoints.append(endpoint(
id=f"services-{action.replace('-', '')}",
method="POST",
path=f"/admin/services/{{name}}/{action}",
title=f"Service: {action}",
summary=summary + ".",
auth="admin",
interactive=True,
destructive=True,
params=[field("name", "path", required=True, example="news", description="Registered service name.")],
sample_response={"ok": True},
))
return endpoints
API_GROUPS = [
{
"slug": "conventions",
"title": "Conventions & Errors",
"intro": """
# Conventions & Errors
Shared rules that apply to every endpoint in this reference.
## Base URL
Every example uses your current host:
```
{{ base }}
```
## Authentication
Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.
## Request bodies
POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.
## HTML or JSON (content negotiation)
**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:
- `Accept: application/json`
- `Content-Type: application/json`
A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.
**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).
**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:
```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```
`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.
**Errors** are JSON too when JSON is requested:
```json
{ "error": { "status": 404, "message": "Not found" } }
```
Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.
## Trying it here
Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.
## AJAX responses (legacy shape)
The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.
## Pagination
Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.
The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.
## Identifiers
Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.
## Dates
All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.
## Status codes
| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |
## Rate limiting
Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.
## Troubleshooting
**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).
**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.
**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).
**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.
""",
"endpoints": [],
},
{
"slug": "lookups",
"title": "Search & Lookups",
"intro": """
# Search & Lookups
Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="search-users",
method="GET",
path="/profile/search",
title="Search users",
summary="Find up to ten users whose username matches a query.",
auth="user",
params=[field("q", "query", required=True, example="al", description="Partial username to match.")],
sample_response={"results": [{"uid": "8f14e45f-...", "username": "alice_test"}]},
),
endpoint(
id="search-message-recipients",
method="GET",
path="/messages/search",
title="Search message recipients",
summary="Like user search, but excludes yourself; used by the message composer.",
auth="user",
params=[field("q", "query", required=True, example="bo", description="Partial username to match.")],
sample_response={"results": [{"uid": "0cc175b9-...", "username": "bob_test"}]},
),
],
},
{
"slug": "social-actions",
"title": "Votes, Reactions, Bookmarks & Polls",
"intro": """
# Votes, Reactions, Bookmarks & Polls
Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
explains that header rule and the response envelope.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="votes-cast",
method="POST",
path="/votes/{target_type}/{target_uid}",
title="Cast or toggle a vote",
summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
auth="user",
ajax=True,
encoding="form",
params=[
field("target_type", "path", "enum", True, "post", "Type of content being voted on.", VOTE_TARGETS),
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
field("value", "form", "enum", True, "1", "1 to upvote, -1 to downvote.", ["1", "-1"]),
],
sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
),
endpoint(
id="reactions-toggle",
method="POST",
path="/reactions/{target_type}/{target_uid}",
title="Toggle an emoji reaction",
summary="Add or remove an emoji reaction on a target.",
auth="user",
ajax=True,
encoding="form",
params=[
field("target_type", "path", "enum", True, "post", "Type of content being reacted to.", REACTION_TARGETS),
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
field("emoji", "form", "enum", True, REACTION_EMOJI[0], "One of the allowed reaction emoji.", REACTION_EMOJI),
],
sample_response={"counts": {REACTION_EMOJI[0]: 2}, "mine": [REACTION_EMOJI[0]]},
),
endpoint(
id="bookmarks-toggle",
method="POST",
path="/bookmarks/{target_type}/{target_uid}",
title="Toggle a bookmark",
summary="Save or unsave a target to your bookmarks.",
auth="user",
ajax=True,
encoding="none",
params=[
field("target_type", "path", "enum", True, "post", "Type of content to bookmark.", BOOKMARK_TARGETS),
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
],
sample_response={"saved": True},
),
endpoint(
id="bookmarks-saved",
method="GET",
path="/bookmarks/saved",
title="View saved bookmarks",
summary="Render your saved content. Returns an HTML page.",
auth="user",
interactive=True,
params=[field("before", "query", "string", False, "", "Pagination cursor (created_at of the last item).")],
notes=["Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."],
),
endpoint(
id="polls-vote",
method="POST",
path="/polls/{poll_uid}/vote",
title="Vote in a poll",
summary="Cast, change, or clear your vote on a poll option.",
auth="user",
ajax=True,
encoding="form",
params=[
field("poll_uid", "path", "string", True, "POLL_UID", "UID of the poll."),
field("option_uid", "form", "string", True, "OPTION_UID", "UID of the chosen option."),
],
notes=[
"You hold at most one vote per poll, and only your latest vote counts. "
"Voting a different option replaces your previous choice; voting your current "
"option again removes the vote.",
],
sample_response={"question": "Best editor?", "options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}], "total": 5, "voted": "OPTION_UID"},
),
],
},
{
"slug": "content",
"title": "Posts, Comments, Projects, Gists & News",
"intro": """
# Posts, Comments, Projects, Gists & News
The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="feed-list",
method="GET",
path="/feed",
title="Browse the feed",
summary="The main post feed. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("tab", "query", "enum", False, "all", "Feed selector.", ["all", "trending", "following"]),
field("topic", "query", "enum", False, "", "Filter by topic.", TOPICS),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="posts-create",
method="POST",
path="/posts/create",
title="Create a post",
summary="Publish a post, optionally with a poll. Redirects to the new post.",
auth="user",
encoding="form",
destructive=True,
params=[
field("content", "form", "textarea", True, "Posted from a script.", "Body, 10-2000 characters."),
field("title", "form", "string", False, "Hello", "Optional title, up to 500 characters."),
field("topic", "form", "enum", False, "random", "Post topic.", TOPICS),
field("project_uid", "form", "string", False, "", "Attach to a project."),
field("poll_question", "form", "string", False, "", "Optional poll question."),
field("poll_options", "form", "string", False, "", "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options)."),
],
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
),
endpoint(
id="posts-detail",
method="GET",
path="/posts/{post_slug}",
title="View a post",
summary="Render a post with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post.")],
),
endpoint(
id="posts-edit",
method="POST",
path="/posts/edit/{post_slug}",
title="Edit a post",
summary="Update a post you own, optionally adding a poll if it has none.",
auth="user",
encoding="form",
destructive=True,
params=[
field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post."),
field("content", "form", "textarea", True, "Updated body.", "Body, 10-2000 characters."),
field("title", "form", "string", False, "Updated title", "Optional title."),
field("topic", "form", "enum", False, "random", "Post topic.", TOPICS),
field("poll_question", "form", "string", False, "", "Optional poll question. Adds a poll only when the post has none."),
field("poll_options", "form", "string", False, "", "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options)."),
],
),
endpoint(
id="posts-delete",
method="POST",
path="/posts/delete/{post_slug}",
title="Delete a post",
summary="Delete a post you own, cascading its comments and votes.",
auth="user",
destructive=True,
params=[field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post.")],
),
endpoint(
id="comments-create",
method="POST",
path="/comments/create",
title="Create a comment",
summary="Comment on any commentable target. Supports nested replies.",
auth="user",
encoding="form",
destructive=True,
params=[
field("content", "form", "textarea", True, "Nice work.", "Body, 3-1000 characters."),
field("target_uid", "form", "string", False, "POST_UID", "UID of the target (or use post_uid)."),
field("post_uid", "form", "string", False, "", "Convenience alias for a post target."),
field("target_type", "form", "enum", False, "post", "Type of the target.", COMMENT_TARGETS),
field("parent_uid", "form", "string", False, "", "Parent comment UID for a reply."),
],
notes=["Either `target_uid` or `post_uid` is required."],
),
endpoint(
id="comments-delete",
method="POST",
path="/comments/delete/{comment_uid}",
title="Delete a comment",
summary="Delete a comment you own.",
auth="user",
destructive=True,
params=[field("comment_uid", "path", "string", True, "COMMENT_UID", "UID of the comment.")],
),
endpoint(
id="projects-list",
method="GET",
path="/projects",
title="Browse projects",
summary="List projects. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("tab", "query", "enum", False, "recent", "Sort selector.", ["recent", "popular", "released"]),
field("search", "query", "string", False, "", "Search title and description."),
field("project_type", "query", "enum", False, "", "Filter by type.", PROJECT_TYPES),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="projects-detail",
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render a project with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
),
endpoint(
id="projects-create",
method="POST",
path="/projects/create",
title="Create a project",
summary="Publish a project. Redirects to the new project.",
auth="user",
encoding="form",
destructive=True,
params=[
field("title", "form", "string", True, "My Project", "Title, 1-200 characters."),
field("description", "form", "textarea", True, "What it does.", "Description, 1-5000 characters."),
field("project_type", "form", "enum", False, "software", "Project type.", PROJECT_TYPES),
field("status", "form", "string", False, "In Development", "Free-form status label."),
field("platforms", "form", "string", False, "Linux, Web", "Comma-separated platforms."),
field("release_date", "form", "string", False, "31/12/2026", "Optional release date in DD/MM/YYYY format."),
field("demo_date", "form", "string", False, "31/12/2026", "Optional demo date in DD/MM/YYYY format."),
],
),
endpoint(
id="projects-delete",
method="POST",
path="/projects/delete/{project_slug}",
title="Delete a project",
summary="Delete a project you own.",
auth="user",
destructive=True,
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
),
endpoint(
id="projects-private",
method="POST",
path="/projects/{project_slug}/private",
title="Set project visibility",
summary="Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.",
auth="user",
encoding="form",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
field("value", "form", "boolean", True, "1", "1 to make the project private, 0 to make it public."),
],
),
endpoint(
id="projects-readonly",
method="POST",
path="/projects/{project_slug}/readonly",
title="Set project read-only",
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
field("value", "form", "boolean", True, "1", "1 to make the project read-only, 0 to make it writable."),
],
),
endpoint(
id="projects-zip",
method="POST",
path="/projects/{project_slug}/zip",
title="Queue a project zip",
summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
auth="public",
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
sample_response={"uid": "ZIP_JOB_UID", "status_url": "/zips/ZIP_JOB_UID"},
),
endpoint(
id="zips-status",
method="GET",
path="/zips/{uid}",
title="Zip job status",
summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
auth="public",
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid returned when the job was queued.")],
sample_response={
"uid": "ZIP_JOB_UID",
"kind": "zip",
"status": "done",
"preferred_name": "my-project",
"download_url": "/zips/ZIP_JOB_UID/download",
"error": None,
"bytes_in": 20480,
"bytes_out": 8192,
"item_count": 12,
"file_count": 10,
"dir_count": 2,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:03+00:00",
},
),
endpoint(
id="zips-download",
method="GET",
path="/zips/{uid}/download",
title="Download a zip archive",
summary="Stream the finished archive as application/zip. Each access extends the retention window.",
auth="public",
interactive=True,
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid of a finished job.")],
),
endpoint(
id="projects-fork",
method="POST",
path="/projects/{project_slug}/fork",
title="Queue a project fork",
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
auth="user",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project to fork."),
field("title", "form", "string", True, "My Fork", "Title for the new forked project."),
],
sample_response={"uid": "FORK_JOB_UID", "status_url": "/forks/FORK_JOB_UID"},
),
endpoint(
id="forks-status",
method="GET",
path="/forks/{uid}",
title="Fork job status",
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
auth="public",
params=[field("uid", "path", "string", True, "FORK_JOB_UID", "Fork job uid returned when the job was queued.")],
sample_response={
"uid": "FORK_JOB_UID",
"kind": "fork",
"status": "done",
"preferred_name": "My Fork",
"project_uid": "NEW_PROJECT_UID",
"project_url": "/projects/new-project-slug",
"source_project_uid": "SOURCE_PROJECT_UID",
"error": None,
"item_count": 12,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:05+00:00",
},
),
endpoint(
id="gists-list",
method="GET",
path="/gists",
title="Browse gists",
summary="List code gists. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("language", "query", "enum", False, "", "Filter by language.", GIST_LANGUAGES),
field("user_uid", "query", "string", False, "", "Filter by author UID."),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="gists-detail",
method="GET",
path="/gists/{gist_slug}",
title="View a gist",
summary="Render a gist with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist.")],
),
endpoint(
id="gists-create",
method="POST",
path="/gists/create",
title="Create a gist",
summary="Publish a code snippet. Redirects to the new gist.",
auth="user",
encoding="form",
destructive=True,
params=[
field("title", "form", "string", True, "Quick sort", "Title, 1-200 characters."),
field("source_code", "form", "textarea", True, "print('hello')", "Source, 1-400000 characters."),
field("language", "form", "enum", False, "python", "Syntax language.", GIST_LANGUAGES),
field("description", "form", "string", False, "", "Optional description."),
],
),
endpoint(
id="gists-edit",
method="POST",
path="/gists/edit/{gist_slug}",
title="Edit a gist",
summary="Update a gist you own.",
auth="user",
encoding="form",
destructive=True,
params=[
field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist."),
field("title", "form", "string", True, "Quick sort", "Title, 1-200 characters."),
field("source_code", "form", "textarea", True, "print('hi')", "Source, 1-400000 characters."),
field("language", "form", "enum", False, "python", "Syntax language.", GIST_LANGUAGES),
field("description", "form", "string", False, "", "Optional description."),
],
),
endpoint(
id="gists-delete",
method="POST",
path="/gists/delete/{gist_slug}",
title="Delete a gist",
summary="Delete a gist you own.",
auth="user",
destructive=True,
params=[field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist.")],
),
endpoint(
id="news-list",
method="GET",
path="/news",
title="Browse news",
summary="Curated developer news. Returns an HTML page.",
auth="public",
interactive=True,
params=[field("before", "query", "string", False, "", "Pagination cursor (synced_at).")],
),
endpoint(
id="news-detail",
method="GET",
path="/news/{news_slug}",
title="View a news article",
summary="Render a news article with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[field("news_slug", "path", "string", True, "NEWS_SLUG", "Slug or UID of the article.")],
),
],
},
{
"slug": "profiles",
"title": "Profiles & Social Graph",
"intro": """
# Profiles & Social Graph
Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="profile-detail",
method="GET",
path="/profile/{username}",
title="View a profile",
summary="Render a user profile. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("username", "path", "string", True, "{{ username }}", "Target username."),
field("tab", "query", "enum", False, "posts", "Profile tab.", ["posts", "activity", "followers", "following"]),
],
),
endpoint(
id="profile-update",
method="POST",
path="/profile/update",
title="Update your profile",
summary="Update your own bio and links.",
auth="user",
encoding="form",
destructive=True,
params=[
field("bio", "form", "textarea", False, "Building things.", "Bio, up to 500 characters."),
field("location", "form", "string", False, "Earth", "Location, up to 200 characters."),
field("git_link", "form", "string", False, "", "Git profile URL."),
field("website", "form", "string", False, "", "Personal website URL."),
],
),
endpoint(
id="profile-regenerate-key",
method="POST",
path="/profile/regenerate-api-key",
title="Regenerate your API key",
summary="Issue a new API key and invalidate the current one.",
auth="user",
interactive=False,
destructive=True,
notes=[
"> Running this invalidates the key these documentation panels use. Do it from your "
"[profile page](/profile/{{ username }}) instead, then reload these docs.",
],
sample_response={"api_key": "NEW_UUID"},
),
endpoint(
id="follow-user",
method="POST",
path="/follow/{username}",
title="Follow a user",
summary="Follow another user. Idempotent.",
auth="user",
destructive=True,
params=[field("username", "path", "string", True, "bob_test", "Username to follow.")],
),
endpoint(
id="unfollow-user",
method="POST",
path="/follow/unfollow/{username}",
title="Unfollow a user",
summary="Stop following a user.",
auth="user",
destructive=True,
params=[field("username", "path", "string", True, "bob_test", "Username to unfollow.")],
),
endpoint(
id="list-followers",
method="GET",
path="/profile/{username}/followers",
title="List followers",
summary="List the users who follow a profile, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field("username", "path", "string", True, "{{ username }}", "Target username."),
field("page", "query", "integer", False, "1", "Page number, 25 per page."),
],
sample_response={
"username": "{{ username }}",
"mode": "followers",
"count": 2,
"page": 1,
"total_pages": 1,
"followers": [
{"uid": "UUID", "username": "bob_test", "bio": "Building things.", "is_following": False},
],
},
),
endpoint(
id="list-following",
method="GET",
path="/profile/{username}/following",
title="List following",
summary="List the users a profile follows, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field("username", "path", "string", True, "{{ username }}", "Target username."),
field("page", "query", "integer", False, "1", "Page number, 25 per page."),
],
sample_response={
"username": "{{ username }}",
"mode": "following",
"count": 1,
"page": 1,
"total_pages": 1,
"following": [
{"uid": "UUID", "username": "alice_test", "bio": "", "is_following": True},
],
},
),
endpoint(
id="leaderboard",
method="GET",
path="/leaderboard",
title="View the leaderboard",
summary="Top contributors by stars. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="avatar",
method="GET",
path="/avatar/{style}/{seed}",
title="Generate an avatar",
summary="Deterministic SVG avatar for a seed. Returns an image.",
auth="public",
interactive=False,
params=[
field("style", "path", "enum", True, "multiavatar", "Avatar style.", ["multiavatar"]),
field("seed", "path", "string", True, "{{ username }}", "Seed string, usually a username."),
field("size", "query", "int", False, "128", "Pixel size."),
],
),
],
},
{
"slug": "messaging",
"title": "Messaging",
"intro": """
# Messaging
Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="messages-inbox",
method="GET",
path="/messages",
title="Open the inbox",
summary="Render conversations. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field("with_uid", "query", "string", False, "", "Open a specific conversation by user UID."),
field("search", "query", "string", False, "", "Jump to a conversation by username."),
],
),
endpoint(
id="messages-send",
method="POST",
path="/messages/send",
title="Send a message",
summary="Send a direct message to a user.",
auth="user",
encoding="form",
destructive=True,
params=[
field("content", "form", "textarea", True, "Hello there.", "Body, 1-2000 characters."),
field("receiver_uid", "form", "string", True, "RECEIVER_UID", "Recipient user UID."),
],
),
],
},
{
"slug": "notifications",
"title": "Notifications",
"intro": """
# Notifications
Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="notifications-list",
method="GET",
path="/notifications",
title="View notifications",
summary="Render your notifications. Returns an HTML page.",
auth="user",
interactive=True,
params=[field("before", "query", "string", False, "", "Pagination cursor.")],
),
endpoint(
id="notifications-counts",
method="GET",
path="/notifications/counts",
title="Unread counts",
summary="Unread notification and message counts.",
auth="public",
notes=["Guests receive `{ \"notifications\": 0, \"messages\": 0 }` instead of an error, so the navigation badge works before login."],
sample_response={"notifications": 2, "messages": 1},
),
endpoint(
id="notifications-open",
method="GET",
path="/notifications/open/{notification_uid}",
title="Open a notification",
summary="Mark a notification read and redirect to its target.",
auth="user",
interactive=False,
params=[field("notification_uid", "path", "string", True, "NOTIFICATION_UID", "UID of the notification.")],
),
endpoint(
id="notifications-mark-read",
method="POST",
path="/notifications/mark-read/{notification_uid}",
title="Mark one read",
summary="Mark a single notification as read.",
auth="user",
params=[field("notification_uid", "path", "string", True, "NOTIFICATION_UID", "UID of the notification.")],
),
endpoint(
id="notifications-mark-all-read",
method="POST",
path="/notifications/mark-all-read",
title="Mark all read",
summary="Mark every notification as read.",
auth="user",
),
],
},
{
"slug": "uploads",
"title": "Uploads",
"intro": """
# Uploads
Attachment storage. Upload a file to receive an attachment record, then reference its `uid`
in an `attachment_uids` field when creating a post, comment, project, gist, message, or bug -
see [Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="uploads-upload",
method="POST",
path="/uploads/upload",
title="Upload a file",
summary="Store a file and return its attachment record.",
auth="user",
encoding="multipart",
params=[field("file", "form", "file", True, "", "The file to upload.")],
notes=[
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
],
sample_response={"uid": "ATTACHMENT_UID", "filename": "clip.mp4", "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4", "size": 20480, "is_image": False, "is_video": True, "mime_type": "video/mp4"},
),
endpoint(
id="uploads-delete",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
title="Delete an attachment",
summary="Delete an attachment you own.",
auth="user",
destructive=True,
params=[field("attachment_uid", "path", "string", True, "ATTACHMENT_UID", "UID of the attachment.")],
sample_response={"status": "deleted"},
),
],
},
{
"slug": "project-files",
"title": "Project Filesystem",
"intro": """
# Project Filesystem
Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
sign requests.
""",
"endpoints": [
endpoint(
id="project-files-list",
method="GET",
path="/projects/{project_slug}/files",
title="List a project's files",
summary="Return the flat list of files and directories in a project.",
auth="public",
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
sample_response={"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"}, "files": [{"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "size": 42}], "is_owner": False},
),
endpoint(
id="project-files-raw",
method="GET",
path="/projects/{project_slug}/files/raw",
title="Read a project file",
summary="Return one file's metadata and (for text files) its content.",
auth="public",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "query", "string", True, "src/main.py", "Relative file path inside the project."),
],
sample_response={"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "mime_type": "text/plain", "size": 42, "url": None, "content": "print('hello')\n"},
),
endpoint(
id="project-files-write",
method="POST",
path="/projects/{project_slug}/files/write",
title="Write a text file",
summary="Create or overwrite a text file; parent directories are created automatically.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("content", "form", "textarea", True, "print('hello')", "Full file content (max 400000 chars)."),
],
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-lines",
method="GET",
path="/projects/{project_slug}/files/lines",
title="Read a line range",
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
auth="public",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "query", "string", True, "src/main.py", "Relative file path."),
field("start", "query", "integer", False, "1", "First line, 1-indexed (default 1)."),
field("end", "query", "integer", False, "50", "Last line inclusive; omit or -1 for end of file."),
],
notes=["Text files only; binary, directory, or missing paths return `404`."],
sample_response={"path": "src/main.py", "start": 1, "end": 2, "total_lines": 2, "lines": ["import os", "print(os.getcwd())"], "content": "import os\nprint(os.getcwd())"},
),
endpoint(
id="project-files-replace-lines",
method="POST",
path="/projects/{project_slug}/files/replace-lines",
title="Replace a line range",
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("start", "form", "integer", True, "10", "First line to replace (1-indexed)."),
field("end", "form", "integer", True, "12", "Last line to replace (inclusive)."),
field("content", "form", "textarea", False, "new code", "Replacement text (empty deletes the range)."),
],
notes=["Owner only. The preferred way to edit a large file; avoids rewriting the whole file."],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-insert-lines",
method="POST",
path="/projects/{project_slug}/files/insert-lines",
title="Insert lines",
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("at", "form", "integer", True, "1", "Insert before this 1-indexed line."),
field("content", "form", "textarea", True, "# header", "Text to insert."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-delete-lines",
method="POST",
path="/projects/{project_slug}/files/delete-lines",
title="Delete a line range",
summary="Delete lines start..end (inclusive) from a text file.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/main.py", "Relative file path."),
field("start", "form", "integer", True, "5", "First line to delete (1-indexed)."),
field("end", "form", "integer", True, "7", "Last line to delete (inclusive)."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
),
endpoint(
id="project-files-append",
method="POST",
path="/projects/{project_slug}/files/append",
title="Append to a file",
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "log.txt", "Relative file path."),
field("content", "form", "textarea", True, "next chunk", "Text to append."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "log.txt", "type": "file"}},
),
endpoint(
id="project-files-upload",
method="POST",
path="/projects/{project_slug}/files/upload",
title="Upload a file into a project",
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
auth="user",
encoding="multipart",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("file", "form", "file", True, "", "The file to upload."),
field("path", "form", "string", False, "assets", "Target directory, empty for the root."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "assets/logo.png", "type": "file", "is_binary": True}},
),
endpoint(
id="project-files-mkdir",
method="POST",
path="/projects/{project_slug}/files/mkdir",
title="Create a directory",
summary="Create a directory and any missing parents.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/components", "Relative directory path."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/components", "type": "dir"}},
),
endpoint(
id="project-files-move",
method="POST",
path="/projects/{project_slug}/files/move",
title="Move or rename",
summary="Move or rename a file or directory (and its descendants).",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("from_path", "form", "string", True, "src/old.py", "Existing path."),
field("to_path", "form", "string", True, "src/new.py", "New path."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/new.py"}},
),
endpoint(
id="project-files-delete",
method="POST",
path="/projects/{project_slug}/files/delete",
title="Delete a file or directory",
summary="Delete a file, or a directory and everything under it.",
auth="user",
encoding="form",
destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "form", "string", True, "src/old.py", "Relative path to delete."),
],
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/old.py"}},
),
endpoint(
id="project-files-zip",
method="POST",
path="/projects/{project_slug}/files/zip",
title="Queue a zip of files",
summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
auth="public",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("path", "query", "string", False, "src", "Relative file or directory to archive; empty for the whole project."),
],
sample_response={"uid": "ZIP_JOB_UID", "status_url": "/zips/ZIP_JOB_UID"},
),
],
},
{
"slug": "containers",
"title": "Container Manager",
"intro": """
# Container Manager
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
Mutations flip desired state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page", method="GET", path="/projects/{project_slug}/containers",
title="Container manager page", summary="The admin per-project container manager UI (Dockerfiles, builds, instance creation).",
auth="admin", interactive=True,
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
),
endpoint(
id="containers-admin-index", method="GET", path="/admin/containers",
title="Admin containers list", summary="The admin Containers section: every instance across all projects, each linking to its detail page.",
auth="admin", interactive=True,
),
endpoint(
id="containers-admin-data", method="GET", path="/admin/containers/data",
title="Admin containers list data", summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={"instances": [{"uid": "INSTANCE_UID", "name": "staging", "status": "running",
"project_slug": "PROJECT_SLUG", "project_title": "My Project",
"ingress_slug": "my-service", "restart_policy": "always"}]},
),
endpoint(
id="containers-admin-instance", method="GET", path="/admin/containers/{uid}",
title="Instance detail page", summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin", interactive=True,
params=[field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid.")],
),
endpoint(
id="containers-create-instance", method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance", summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin", encoding="form", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("name", "form", "string", True, "staging", "Instance name."),
field("boot_command", "form", "string", False, "python app.py", "Optional boot command."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.",
["never", "always", "on-failure", "unless-stopped"]),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish (must be a mapped port)."),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[field("slug", "path", "string", True, "my-service", "The instance's ingress_slug.")],
),
endpoint(
id="containers-instance-action", method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle", summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.",
["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-instance-logs", method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs", summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync", method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace", summary="Import the container /app workspace back into the project files.",
auth="admin", destructive=True,
params=[
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
],
},
{
"slug": "push",
"title": "Web Push",
"intro": """
# Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](/docs/notifications.html) feed.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="push-key",
method="GET",
path="/push.json",
title="Get the public key",
summary="Return the VAPID public key for subscribing.",
auth="public",
sample_response={"publicKey": "BASE64_VAPID_KEY"},
),
endpoint(
id="push-register",
method="POST",
path="/push.json",
title="Register a subscription",
summary="Register a browser push subscription. Sends a welcome notification.",
auth="user",
encoding="json",
interactive=False,
params=[
field("endpoint", "json", "string", True, "https://fcm.googleapis.com/...", "Subscription endpoint URL."),
field("keys", "json", "string", True, '{"p256dh":"...","auth":"..."}', "Subscription keys object."),
],
notes=["The body must be JSON: `{\"endpoint\": \"...\", \"keys\": {\"p256dh\": \"...\", \"auth\": \"...\"}}`."],
sample_response={"registered": True},
),
],
},
{
"slug": "bugs",
"title": "Bug Reports",
"intro": """
# Bug Reports
A public board for reporting issues. The list renders HTML; reporting uses form fields, and a
report can carry files uploaded via [Uploads](/docs/uploads.html).
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="bugs-list",
method="GET",
path="/bugs",
title="View bug reports",
summary="Render the bug board. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="bugs-create",
method="POST",
path="/bugs/create",
title="Report a bug",
summary="File a bug report.",
auth="user",
encoding="form",
destructive=True,
params=[
field("title", "form", "string", True, "Login button misaligned", "Title, 1-200 characters."),
field("description", "form", "textarea", True, "Steps to reproduce...", "Description, 1-5000 characters."),
],
),
],
},
{
"slug": "gateway",
"title": "OpenAI Gateway",
"admin": True,
"intro": """
# OpenAI Gateway
An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
upstream using the gateway's own credentials, so no DevPlace key is required - but an
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
`{{ base }}/openai/v1`.
This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
service (news, bots, Devii) calls it by default instead of an external provider, sends the
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
shown. Because `Force model` is on by default, the upstream always receives the configured
model regardless of what a client (or `molodetz`) requests.
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
describes the image with a configured vision model and rewrites it to text, so a vision-less
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
The gateway is exempt from rate limiting, but every other endpoint follows the shared
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
for signing DevPlace's own requests.
""",
"endpoints": [
endpoint(
id="gateway-chat",
method="POST",
path="/openai/v1/chat/completions",
title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.",
auth="user",
encoding="json",
params=[
field("model", "json", "string", False, "gpt-4o-mini", "Model id; the gateway may override it."),
field("messages", "json", "string", True, '[{"role":"user","content":"Hello"}]', "Chat messages array."),
field("stream", "json", "string", False, "false", "Set true for a streamed SSE response."),
],
notes=["Returns `503` when the gateway service is not running."],
),
endpoint(
id="gateway-passthrough",
method="POST",
path="/openai/v1/{path}",
title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="user",
interactive=False,
params=[field("path", "path", "string", True, "models", "Upstream API path after /v1/.")],
),
],
},
{
"slug": "services",
"title": "Background Services",
"admin": True,
"dynamic": True,
"intro": "",
"endpoints": [],
},
{
"slug": "admin",
"title": "Admin API",
"admin": True,
"intro": """
# Admin API
Site administration endpoints for users, news curation, and settings. Every call requires an
**admin** account. Background service management lives on the
[Background Services](/docs/services.html) page.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="admin-users",
method="GET",
path="/admin/users",
title="List users",
summary="Paginated user management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-analytics",
method="GET",
path="/admin/analytics",
title="Site analytics",
summary=(
"One-call aggregate analytics, returned as JSON: total members, active users "
"in the last 24h/7d/30d, users signed in now, new signups (24h/7d/30d), content "
"totals (posts, comments, gists, projects, news), and top authors. Use this "
"instead of paging the user list to count or measure activity."
),
auth="admin",
interactive=True,
params=[field("top_n", "query", "int", False, "10", "How many top authors to include (1-50).")],
notes=["This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html)."],
),
endpoint(
id="admin-ai-usage",
method="GET",
path="/admin/ai-usage/data",
title="AI gateway usage analytics",
summary=(
"One-call AI gateway metrics, returned as JSON for a bounded time window: request "
"volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
"latency (upstream, gateway overhead, queue wait, connection establishment), error "
"rates by category, cost in USD (per model, per caller, input vs output, projected "
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
"taken from the upstream native cost when present (OpenRouter) and computed from the "
"configured per-million pricing otherwise (DeepSeek)."
),
auth="admin",
interactive=True,
params=[
field("hours", "query", "int", False, "48", "Lookback window in hours (1-168)."),
field("top_n", "query", "int", False, "10", "How many rows in each top-N breakdown."),
],
notes=["TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."],
),
endpoint(
id="admin-user-ai-usage",
method="GET",
path="/admin/users/{uid}/ai-usage",
title="Per-user AI usage",
summary=(
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
"volume, success and error rates, token totals, cost (window, per hour, per request, "
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
"user's account with that user's own API key, this is the user's complete gateway "
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
"user's profile page."
),
auth="admin",
interactive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
field("hours", "query", "int", False, "24", "Lookback window in hours (1-168)."),
],
sample_response={
"owner_id": "USER_UID", "window_hours": 24, "requests": 42, "success": 41,
"failed": 1, "success_pct": 97.6, "error_pct": 2.4,
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
"cost": {"window_usd": 0.214, "per_hour_usd": 0.0089, "per_request_usd": 0.0051, "projected_30d_usd": 6.42},
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
"by_model": [{"key": "molodetz", "requests": 42, "total_tokens": 158000, "cost_usd": 0.214}],
"hourly": [{"hour": "2026-06-08T16", "requests": 6, "cost_usd": 0.031, "total_tokens": 22000}],
},
),
endpoint(
id="admin-user-role",
method="POST",
path="/admin/users/{uid}/role",
title="Set a user role",
summary="Promote or demote a user. You cannot change your own role.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
field("role", "form", "enum", True, "member", "New role.", ["member", "admin"]),
],
),
endpoint(
id="admin-user-password",
method="POST",
path="/admin/users/{uid}/password",
title="Reset a user password",
summary="Set a new password for a user.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
field("password", "form", "string", True, "newpassword", "New password, 6+ characters."),
],
),
endpoint(
id="admin-user-toggle",
method="POST",
path="/admin/users/{uid}/toggle",
title="Enable or disable a user",
summary="Toggle a user's active state. You cannot disable yourself.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "USER_UID", "Target user UID.")],
),
endpoint(
id="admin-news-list",
method="GET",
path="/admin/news",
title="List news articles",
summary="Paginated news management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-news-toggle",
method="POST",
path="/admin/news/{uid}/toggle",
title="Toggle featured",
summary="Toggle an article's featured flag.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
),
endpoint(
id="admin-news-publish",
method="POST",
path="/admin/news/{uid}/publish",
title="Toggle published",
summary="Switch an article between draft and published.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
),
endpoint(
id="admin-news-landing",
method="POST",
path="/admin/news/{uid}/landing",
title="Toggle landing",
summary="Toggle whether an article shows on the landing page.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
),
endpoint(
id="admin-news-delete",
method="POST",
path="/admin/news/{uid}/delete",
title="Delete a news article",
summary="Delete an article and its images.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
),
endpoint(
id="admin-settings-get",
method="GET",
path="/admin/settings",
title="Read site settings",
summary="Return the current site and operational settings. Negotiates HTML or JSON.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-settings",
method="POST",
path="/admin/settings",
title="Save site settings",
summary="Update operational and site settings. Empty fields are skipped.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("site_name", "form", "string", False, "DevPlace", "Site name."),
field("rate_limit_per_minute", "form", "string", False, "60", "Requests per window."),
field("registration_open", "form", "enum", False, "1", "Allow signups.", ["1", "0"]),
field("maintenance_mode", "form", "enum", False, "0", "Maintenance gate.", ["1", "0"]),
],
notes=["Accepts every field on the admin settings form; only non-empty values are written."],
),
],
},
]
from devplacepy import schemas
from devplacepy.docs_examples import schema_example, action_example
_PAGE_RESPONSES = {
"feed-list": schemas.FeedOut,
"posts-detail": schemas.PostDetailOut,
"projects-list": schemas.ProjectsOut,
"projects-detail": schemas.ProjectDetailOut,
"gists-list": schemas.GistsOut,
"gists-detail": schemas.GistDetailOut,
"news-list": schemas.NewsListOut,
"news-detail": schemas.NewsDetailOut,
"profile-detail": schemas.ProfileOut,
"leaderboard": schemas.LeaderboardOut,
"messages-inbox": schemas.MessagesOut,
"notifications-list": schemas.NotificationsOut,
"bugs-list": schemas.BugsOut,
"bookmarks-saved": schemas.SavedOut,
"admin-users": schemas.AdminUsersOut,
"admin-news-list": schemas.AdminNewsOut,
"admin-settings-get": schemas.AdminSettingsOut,
}
_ACTION_RESPONSES = {
"posts-create": ("/posts/POST_SLUG", {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"}),
"posts-edit": ("/posts/POST_SLUG", {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"}),
"posts-delete": ("/feed", None),
"comments-create": ("/posts/POST_SLUG#comment-COMMENT_UID", {"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"}),
"comments-delete": ("/posts/POST_SLUG", None),
"projects-create": ("/projects/PROJECT_SLUG", {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"}),
"projects-delete": ("/projects", None),
"gists-create": ("/gists/GIST_SLUG", {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"}),
"gists-edit": ("/gists/GIST_SLUG", {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"}),
"gists-delete": ("/gists", None),
"profile-update": ("/profile/YOUR_USERNAME", None),
"follow-user": ("/profile/bob_test", None),
"unfollow-user": ("/profile/bob_test", None),
"messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
"notifications-mark-read": ("/notifications", None),
"notifications-mark-all-read": ("/notifications", None),
"bugs-create": ("/bugs", {"uid": "BUG_UID"}),
"admin-user-role": ("/admin/users", None),
"admin-user-password": ("/admin/users", None),
"admin-user-toggle": ("/admin/users", None),
"admin-news-toggle": ("/admin/news", None),
"admin-news-publish": ("/admin/news", None),
"admin-news-landing": ("/admin/news", None),
"admin-news-delete": ("/admin/news", None),
"admin-settings": ("/admin/settings", None),
}
def _apply_negotiated_responses():
for group in API_GROUPS:
for ep in group.get("endpoints", []) or []:
if ep.get("sample_response") is None:
if ep["id"] in _PAGE_RESPONSES:
ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
elif ep["id"] in _ACTION_RESPONSES:
redirect, data = _ACTION_RESPONSES[ep["id"]]
ep["sample_response"] = action_example(redirect, data)
ep["negotiation"] = _classify(ep)
_apply_negotiated_responses()
_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}
def get_group(slug):
return _GROUPS_BY_SLUG.get(slug)
def api_doc_pages():
return [
{
"slug": group["slug"],
"title": group["title"],
"kind": "api",
"admin": group.get("admin", False),
"dynamic": group.get("dynamic", False),
}
for group in API_GROUPS
]
def _field_to_param(spec):
description = spec["label"]
if spec.get("help"):
description = f"{spec['label']} - {spec['help']}"
value = "" if spec.get("secret") else str(spec.get("value", ""))
if spec.get("secret"):
description += " Leave blank to keep the current value."
if spec["type"] == "bool":
return field(spec["key"], "form", "enum", False, value or "0", description, ["1", "0"])
if spec["type"] == "select":
options = [option["value"] for option in spec.get("options") or []]
return field(spec["key"], "form", "enum", False, value or (options[0] if options else ""), description, options)
if spec["type"] in ("int", "float"):
return field(spec["key"], "form", "int", False, value, description)
return field(spec["key"], "form", "string", False, value, description)
def _service_section(service):
enabled = "yes" if service.get("enabled") else "no"
groups = ", ".join(group["name"] for group in service.get("field_groups", []))
lines = [
f"## {service.get('title') or service['name']}",
"",
service.get("description", ""),
"",
f"- Service name: `{service['name']}`",
f"- Enabled: {enabled}",
f"- Run interval: {service.get('interval_seconds', 0)}s",
]
if groups:
lines.append(f"- Configuration groups: {groups}")
return "\n".join(lines)
def build_services_group(services, base):
intro_parts = [
"# Background Services",
"",
"DevPlace runs background services supervised by a single manager. Each service has its "
"own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
"them from the admin panel at `/admin/services` or with the endpoints below.",
"",
"Control a service by its `name` (shown per service): start or stop it, trigger a single "
"run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
"live status, metrics, and log tail for every service.",
"",
"See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
"is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
]
config_endpoints = []
for service in services:
intro_parts.append("")
intro_parts.append(_service_section(service))
params = [
_field_to_param(spec)
for spec in service.get("fields", [])
if not spec["key"].endswith("_enabled")
]
config_endpoints.append(endpoint(
id=f"services-config-{service['name']}",
method="POST",
path=f"/admin/services/{service['name']}/config",
title=f"Configure {service.get('title') or service['name']}",
summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
auth="admin",
encoding="form",
destructive=True,
params=params,
notes=["Use start/stop to enable or disable the service; this saves the remaining settings."],
))
control_endpoints = [
endpoint(
id="services-data",
method="GET",
path="/admin/services/data",
title="Service status",
summary="Live status, metrics, and log tail for every background service.",
auth="admin",
sample_response={"services": [{"name": "news", "status": "running", "enabled": True}]},
),
endpoint(
id="services-detail-data",
method="GET",
path="/admin/services/{name}/data",
title="One service status",
summary="Live status, metrics, and log tail for a single background service.",
auth="admin",
params=[field("name", "path", "string", True, "news", "Service name.")],
sample_response={"service": {"name": "news", "status": "running", "enabled": True}},
),
*_service_control_endpoints(),
]
endpoints = control_endpoints + config_endpoints
for ep in endpoints:
ep["negotiation"] = _classify(ep)
return {
"slug": "services",
"title": "Background Services",
"admin": True,
"intro": "\n".join(intro_parts),
"endpoints": endpoints,
}
def _substitute(value, replacements):
if isinstance(value, str):
for token, replacement in replacements.items():
value = value.replace(token, replacement)
return value
if isinstance(value, list):
return [_substitute(item, replacements) for item in value]
if isinstance(value, dict):
return {key: _substitute(item, replacements) for key, item in value.items()}
return value
def render_group(slug, base, username, api_key):
group = get_group(slug)
if not group:
return None
replacements = {
"{{ base }}": base,
"{{ username }}": username or "YOUR_USERNAME",
"{{ api_key }}": api_key or "YOUR_API_KEY",
}
return _substitute(group, replacements)