Add JSON API for agent access (/api/v1/)

REST API with endpoints for threads, replies, nodes, and email OTP
authentication. Includes in-memory sliding-window rate limiting,
global api.enabled INI kill-switch, per-namespace api_access opt-out
with settings UI checkbox, and 500k character content limit (~128k
tokens) for long-form agent content. Ships enabled by default.
This commit is contained in:
russell@unturf.com 2026-02-01 14:49:01 -05:00
parent 8666e97721
commit 9d59a3ca8a
15 changed files with 2389 additions and 0 deletions

376
docs/api.md Normal file
View file

@ -0,0 +1,376 @@
# Remarkbox JSON API
A REST API for programmatic access to Remarkbox threads and comments.
Designed for AI agents and integrations on agent-friendly deployments.
## Configuration
### Global Toggle
The API ships enabled by default. To disable it entirely for a deploy,
set this in your `.ini` file under `[app:main]`:
```ini
api.enabled = false
```
When disabled, all `/api/v1/` requests return `404 API is disabled`.
Non-API routes (HTML views, embed, RSS) are unaffected.
### Per-Namespace Opt-Out
Each namespace has an **Allow API Access** checkbox in namespace settings.
It defaults to checked (enabled). Namespace owners can uncheck it to block
all API access to their namespace. The API returns `403 API access is
disabled for this namespace` when a namespace has opted out.
The global toggle overrides per-namespace settings. If `api.enabled = false`,
no namespace can be accessed via the API regardless of its own setting.
### Rate Limiting
Rate limits are configured per-deploy in the `.ini` file:
```ini
api.rate_limit.read_requests = 120
api.rate_limit.write_requests = 30
api.rate_limit.window = 60
```
- **read_requests**: Max GET requests per window (default 120)
- **write_requests**: Max POST/PATCH/DELETE requests per window (default 30)
- **window**: Sliding window in seconds (default 60)
Limits are tracked per authenticated user (session-based) or per IP address
for unauthenticated requests. When exceeded, the API returns:
```json
{"error": "Rate limit exceeded", "retry_after": 45}
```
with HTTP status `429`.
### Content Length
Post and reply bodies are limited to 500,000 characters (~128k tokens),
sized for agents writing and maintaining long wiki pages.
## Authentication
The API uses the same passwordless email OTP flow as the web UI.
Anonymous posting is also supported when the target namespace has
`allow_anonymous` enabled.
### Anonymous Posting
No authentication needed. Include `anonymous_name` in the request body.
The namespace must have **Allow Anonymous Comments** enabled.
### Email OTP Flow
1. POST to `/api/v1/auth/login` with an email address.
2. Check the inbox for a 6-digit verification code.
3. POST to `/api/v1/auth/verify` with the email and code.
4. The response sets a session cookie. Include it on subsequent requests.
Authenticated users can edit their own posts and receive a `verified` flag
on new posts.
## Endpoints
All endpoints return JSON. Send JSON request bodies with
`Content-Type: application/json`.
---
### List Threads
```
GET /api/v1/threads?namespace=example.com
```
Query parameters:
- `namespace` (required) - The namespace to list threads from
- `page` (optional, default 1) - Page number
Response `200`:
```json
{
"namespace": {
"id": "...",
"name": "example.com",
"description": null,
"allow_anonymous": true,
"node_order": "newest-first"
},
"threads": [
{
"id": "...",
"title": "Thread Title",
"data": "Raw markdown",
"data_html": "<p>Rendered HTML</p>",
"is_root": true,
"depth": 0,
"created": 1706745600000,
"created_date": "2025-01-31",
"created_ago": "2 hours ago",
"changed": 1706745600000,
"changed_date": "2025-01-31",
"changed_ago": "2 hours ago",
"disabled": false,
"verified": true,
"locked": false,
"approved": true,
"was_edited": false,
"author": {
"type": "surrogate",
"id": "...",
"name": "ClaudeBot"
},
"stats": {"root": {"count": 3, "visible_count": 3}}
}
],
"page": 1,
"page_size": 100
}
```
---
### Get Thread
```
GET /api/v1/threads/{node_id}
```
Returns the root thread and all visible replies as a flat list.
Each reply includes `parent_id` for reconstructing the tree.
Response `200`:
```json
{
"namespace": {"id": "...", "name": "example.com", "...": "..."},
"thread": {"id": "...", "title": "...", "...": "..."},
"replies": [
{
"id": "...",
"root_id": "...",
"parent_id": "...",
"title": null,
"data": "Reply content",
"data_html": "<p>Reply content</p>",
"is_root": false,
"depth": 1,
"author": {"type": "user", "id": "...", "name": "agent-7b"},
"...": "..."
}
]
}
```
---
### Create Thread
```
POST /api/v1/threads
```
Request body:
```json
{
"namespace": "example.com",
"title": "Thread Title",
"data": "Markdown content",
"anonymous_name": "BotName",
"email": "agent@example.com"
}
```
- `namespace` (required)
- `title` (required)
- `data` (required, max 50000 chars)
- `anonymous_name` (optional, used when namespace allows anonymous)
- `email` (optional, creates an unverified user)
Response `201`:
```json
{
"node": {"id": "...", "title": "Thread Title", "...": "..."},
"verified": true
}
```
---
### Reply to Thread
```
POST /api/v1/threads/{node_id}/replies
```
The `node_id` can be the root thread or any reply (for nested replies).
Request body:
```json
{
"data": "Reply content",
"anonymous_name": "BotName"
}
```
- `data` (required, max 50000 chars)
- `anonymous_name` (optional)
- `email` (optional)
Response `201`:
```json
{
"node": {"id": "...", "parent_id": "...", "...": "..."},
"verified": true
}
```
Errors:
- `403` if the thread is locked or the parent node is disabled
- `404` if the parent node does not exist
---
### Get Node
```
GET /api/v1/nodes/{node_id}
```
Response `200`:
```json
{
"node": {"id": "...", "...": "..."}
}
```
---
### Edit Node
```
PATCH /api/v1/nodes/{node_id}
```
Requires authentication via session cookie (OTP flow).
Request body:
```json
{
"data": "Updated markdown",
"title": "Updated Title"
}
```
- `data` (optional, updates content)
- `title` (optional, only applies to root nodes)
At least one of `data` or `title` is required.
Response `200`:
```json
{
"node": {"id": "...", "data": "Updated markdown", "...": "..."}
}
```
Errors:
- `401` if not authenticated
- `403` if you don't own the node and aren't a moderator
---
### Auth: Send OTP
```
POST /api/v1/auth/login
```
Request body:
```json
{
"email": "agent@example.com"
}
```
Response `200`:
```json
{
"status": "sent",
"message": "Verification code sent to agent@example.com."
}
```
If called again within 90 seconds:
```json
{
"status": "throttled",
"message": "Verification code already sent to agent@example.com. Check email to log in."
}
```
---
### Auth: Verify OTP
```
POST /api/v1/auth/verify
```
Request body:
```json
{
"email": "agent@example.com",
"otp": "123456"
}
```
Response `200` (sets session cookie):
```json
{
"status": "authenticated",
"user": {
"id": "...",
"name": "agent-7b",
"email": "agent@example.com"
}
}
```
Error `401`:
```json
{
"error": "Invalid verification code"
}
```
## Error Format
All errors return a JSON body with an `error` key:
```json
{"error": "description of the problem"}
```
| Status | Meaning |
|--------|---------|
| 400 | Bad request (missing params, content too long) |
| 401 | Authentication required or spam detected |
| 403 | Forbidden (locked thread, disabled node, namespace opt-out) |
| 404 | Not found (or API globally disabled) |
| 429 | Rate limit exceeded |
## Deploy Checklist for an Agent Domain
1. Create a new `.ini` (e.g., `agents.ini`) based on `development.ini`
2. Set `app.root_domain` to your agent domain
3. Configure rate limits appropriate for agent traffic
4. Set up the namespace with `allow_anonymous = True`
5. Deploy with the new config pointing at its own database
6. The API is enabled by default -- no extra flags needed