The AI integration runs an AI feature and returns content you can preview and then confirm, under /api/ai/*. Every feature follows the same two-step flow: POST /api/ai/suggest produces a validated preview artifact without writing anything, and POST /api/ai/generate persists a confirmed artifact and returns the created resource id(s). GET /api/ai/status reports whether the site has AI configured, whether you are a subscriber, and your remaining daily quota.
All AI endpoints accept either a session cookie or a Bearer sync token (Authorization: Bearer <token>), so the web app, native/mobile clients, and CLI tools all use the same routes. AI features are subscriber only: a free account receives 403 subscription_required from /suggest and /generate.
AI is app-wide and Anthropic-only. Every request runs on the site's own ANTHROPIC_API_KEY, held server-side and never returned by any endpoint. There are no per-user provider keys: access is gated on an active subscription alone. (Per-user keys were removed on 2026-09-05; clients that previously sent one should stop.)
The two-step design lets a client show a preview and get explicit user confirmation before anything is written:
POST /api/ai/suggest validates your input, resolves a provider/model, checks quota, calls the model, and validates the model's output into a typed artifact. It returns the artifact plus token usage and quota. Nothing is saved.POST /api/ai/generate. The server re-validates it (defense in depth) and persists it, returning the created resource id(s).Both steps count against your daily quota and are recorded in a per-user audit ledger that stores token counts and status only — never prompt or output text.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/ai/suggest | Session or Bearer | Run a feature and return a validated preview artifact. No write. Subscriber only. |
| POST | /api/ai/generate | Session or Bearer | Persist a confirmed artifact from /suggest. Subscriber only. |
| GET | /api/ai/status | Session or Bearer | Providers the site has configured, your subscriber flag, and remaining daily quota. Never returns keys. |
Auth required: yes (Session or Bearer) Subscriber only: yes Description: Run an AI feature and return a validated preview artifact. Writes nothing.
Request body
{
"feature": "powered_template | powered_document | message_series | article_series | writing_assist",
"input": "the user's free-text instruction or draft",
"context": { "…feature-specific fields…": "see Context below" },
"model": "model id (optional override)",
"maxOutputTokens": 2048
}
| Field | Type | Required | Description |
|---|---|---|---|
feature | string | yes | One of the five features. Any other value returns 422 invalid_input. |
input | string | yes | Free-text instruction (or draft, for writing_assist). Word-capped per feature; see Input and output limits. |
context | object | no | Feature-specific hints and source references. See Context object. |
model | string | no | Override the Anthropic model id. A provider field is accepted for backward compatibility but ignored: every request resolves to Anthropic. |
maxOutputTokens | number | no | Requested output-token budget. Clamped down to the feature's ceiling; a larger value is ignored, never honored. |
Response 200 OK
{
"ok": true,
"feature": "writing_assist",
"artifact": { "kind": "message", "content": "Rewritten draft…" },
"usage": { "inputTokens": 412, "outputTokens": 96, "model": "claude-sonnet-5" },
"quota": { "usedToday": 7, "dailyLimit": 50 }
}
The artifact shape depends on the feature; see Artifacts by feature.
Error responses
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | Not authenticated. |
| 403 | subscription_required | Authenticated but not a subscriber. |
| 409 | no_provider_configured | The server has no ANTHROPIC_API_KEY set. This is a site misconfiguration, not something you can fix from the client. |
| 422 | invalid_input | Missing/unknown feature, empty input, input over the word cap, or a series under the 10-word gate. |
| 422 | invalid_ai_output | The model returned output that failed validation (not valid JSON, missing required fields, or below the minimum item count). |
| 429 | quota_exceeded | The daily quota is reached. |
| 429 | rate_limited | Per-user short-window rate limit tripped (separate from the daily quota). Honor the Retry-After header. |
| 500 / 502 | provider_error | Upstream provider failure/timeout (502, 60s per-call), or an unexpected server error (500). Both carry code: "provider_error". |
Auth required: yes (Session or Bearer)
Subscriber only: yes
Description: Persist a confirmed artifact returned by /api/ai/suggest.
Request body
{
"feature": "powered_template | powered_document | message_series | article_series | writing_assist",
"artifact": { "kind": "…", "…": "the artifact from /suggest, edited if desired" },
"provider": "anthropic | openai | gemini (optional, recorded in the audit ledger)",
"model": "provider model id (optional, recorded in the audit ledger)",
"scheduleImmediately": false,
"crossPost": { "crossPostToBluesky": true, "crossPostToTwitter": true }
}
| Field | Type | Required | Description |
|---|---|---|---|
feature | string | yes | The feature the artifact was produced for. |
artifact | object | yes | A confirmed artifact envelope. Re-validated server-side before it is written. |
provider | string | no | Recorded in the audit ledger only. It does not select a provider and does not affect the write. |
model | string | no | Recorded for auditing only; does not affect the write. |
scheduleImmediately | boolean | no | message_series only. When true, the messages are created as individual scheduled posts instead of a list (see below). Defaults to false. |
crossPost | object | no | message_series only. The composer's cross-post selection (crossPostToBluesky, selectedMastodonIds, crossPostToLinkedIn, selectedLinkedInTargets, crossPostToTwitter, linkedInLinkAsFirstComment), applied to every scheduled message. Sanitized server-side against your own connected accounts; the tightest of the selected services' character limits also caps generation. |
Response 201 Created
{
"ok": true,
"feature": "powered_document",
"created": { "documentId": "clx…" },
"quota": { "usedToday": 8, "dailyLimit": 50 }
}
The created object is one of three shapes, depending on what the artifact persists:
created shape | Produced by |
|---|---|
{ "listId": "…" } | list and message_series artifacts (create a list). |
{ "scheduledMessageIds": ["…"], "firstScheduledAt": "…", "lastScheduledAt": "…" } | A message_series artifact sent with scheduleImmediately: true (creates scheduled posts instead of a list). |
{ "documentId": "…" } | A single document artifact. |
{ "folderId": "…", "documentIds": ["…", "…"] } | A doc_series artifact (creates a folder of documents). |
Writes are always scoped to the authenticated user; nothing in the artifact controls ownership. A tags, thread, or message artifact (from writing_assist) is not persistable and returns 422 invalid_input — those are meant to be inserted into the composer client-side, not written as standalone content.
Error responses
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | Not authenticated. |
| 403 | subscription_required | Authenticated but not a subscriber. |
| 409 | no_provider_configured | The server has no ANTHROPIC_API_KEY set. /generate checks this too, so a hand-edited artifact cannot be persisted while AI is unconfigured. |
| 422 | invalid_input | Missing feature/artifact, or an artifact type that cannot be persisted. |
| 422 | invalid_ai_output | The confirmed artifact fails re-validation. |
| 429 | quota_exceeded | The daily quota is reached. |
| 429 | rate_limited | Per-user short-window rate limit tripped. Honor the Retry-After header. |
| 500 / 502 | provider_error | An unexpected server error (500) or an upstream provider failure/timeout (502). Both carry code: "provider_error". |
Auth required: yes (Session or Bearer) Subscriber only: no (any authenticated user can read their status) Description: Report the providers the site has configured, the caller's subscriber flag, the per-provider fallback models, and remaining daily quota. Never returns keys.
Response 200 OK
{
"subscriber": true,
"providers": ["anthropic"],
"defaultModels": {
"anthropic": "claude-sonnet-5",
"openai": "gpt-4.1-mini",
"gemini": "gemini-2.0-flash"
},
"quota": { "usedToday": 8, "dailyLimit": 50, "remaining": 42 }
}
| Field | Type | Description |
|---|---|---|
subscriber | boolean | Whether the account may use /suggest and /generate. |
providers | string[] | Providers the site has configured. ["anthropic"] when the server key is set, [] when it is not. An empty array means /suggest and /generate will return 409 no_provider_configured. |
defaultModels | object | The fallback model per provider id. Only the anthropic entry is reachable. |
quota | object | usedToday, dailyLimit, and remaining for the rolling 24-hour window. |
Error responses
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | Not authenticated. |
feature selects which AI capability runs and, in turn, the artifact shape you get back:
| Feature | What it does | Suggest artifact kind | Generate creates |
|---|---|---|---|
powered_template | Generate a personalized list (schema + starter rows) from a description. | list | A list ({ listId }). |
powered_document | Draft a single markdown document (four modes; see below). | document | A document ({ documentId }). |
message_series | Plan a threaded series of short messages. | message_series | A list of scheduled rows ({ listId }), or — with scheduleImmediately: true — individual scheduled posts ({ scheduledMessageIds, … }). |
article_series | Plan and write a coherent series of documents. | doc_series | A folder of documents ({ folderId, documentIds }). |
writing_assist | Rewrite, tighten, expand, fix grammar, thread, or tag a composer draft. | message, thread, or tags | Not persistable (insert client-side). |
powered_document supports four modes, set in context.mode. Derived modes resolve their source server-side under the owning user (IDOR-guarded), and the Research URL fetch goes through the SSRF-guarded fetcher. You pass a reference, not the source text:
context.mode | Also requires | Source resolution |
|---|---|---|
article (default) | — | No source; drafts from your input topic alone. |
from_list | context.listId | Loads your list's schema and up to 50 rows, server-side. |
from_article | context.documentId | Loads your document's title and body, server-side. |
research_url | context.url | Fetches the page (http/https only, SSRF-guarded), extracts title/description and page text, and cites the URL. |
If the required reference is missing or not yours, the mode returns 422 invalid_input. Server-fetched context is truncated to a fixed size before it reaches the model, so very large lists or pages are summarized from a capped excerpt.
context is optional and id-based. Fields are feature-specific; unknown fields are ignored. Numeric hints are clamped server-side.
| Field | Type | Used by | Description |
|---|---|---|---|
templateKey | string | powered_template | Which base template to personalize. |
mode | string | powered_document | article, from_list, from_article, or research_url. |
listId | string | powered_document (from_list) | Source list; must be yours. |
documentId | string | powered_document (from_article) | Source document; must be yours. |
url | string | powered_document (research_url) | Source URL (http/https). |
action | string | writing_assist | rewrite (default), tighten, expand, grammar, thread, or tags. |
targetPlatform | string | writing_assist | Platform hint for threading/rewrites (for example a character-limit target). |
count | number | message_series, article_series | Requested item count; clamped to the series bounds below. |
channels | string[] | message_series | The cross-post channel labels currently selected in the composer (Bluesky, Mastodon, LinkedIn, X/Twitter). Used only to size the generated messages to the tightest of those services' character limits; with none selected, sizing falls back to your account's message length limit. |
spacingMinutes | number | message_series | Reserved / currently ignored. Accepted for forward compatibility but not applied — see the spacing rules below. |
An artifact is the single typed envelope for "content an AI produced". /suggest returns one; /generate accepts one back. Every artifact has a kind.
list (from powered_template):
{
"kind": "list",
"title": "Conference Talks",
"description": "Talks I want to submit this year.",
"dsl": { "name": "Conference Talks", "fields": [ { "…": "DSL schema object" } ] },
"rows": [ { "field_key": "value" } ]
}
The dsl is a full List Schema DSL object (see List Schema DSL), validated on both suggest and generate. The model is constrained to at most 20 fields, each with a key matching ^[a-z][a-z0-9_-]*$; schemas beyond that fail validation. Starter rows that fail schema validation are dropped rather than failing the whole artifact.
document (from powered_document):
{
"kind": "document",
"title": "Getting Started with Widgets",
"markdown": "# Getting Started…",
"outline": ["Overview", "Setup", "Next steps"],
"isPublic": false
}
message_series (from message_series):
{
"kind": "message_series",
"listTitle": "Launch thread",
"items": [
{ "order": 1, "content": "1/ Today we shipped…", "crossPostTargets": ["mastodon"] },
{ "order": 2, "content": "2/ Here's why it matters…" }
]
}
On generate, the default is a list whose rows are scheduled posts, spaced a fixed 4 minutes apart (context.spacingMinutes and any model-supplied timestamps are ignored). The row Channels come from the composer's cross-post selection (crossPost); the model's per-item crossPostTargets are used only for sizing hints.
When scheduleImmediately: true, the series skips the list and becomes individual scheduled posts instead — top-level messages the publish-scheduled-messages cron later posts (and cross-posts). The first fires ~30–45 minutes out and each subsequent one 5–10 minutes after the previous. Every post carries the same cross-post config, sanitized from crossPost against your own connected accounts. The response is { scheduledMessageIds, firstScheduledAt, lastScheduledAt }.
doc_series (from article_series):
{
"kind": "doc_series",
"folderTitle": "Kubernetes from Scratch",
"documents": [
{ "order": 1, "title": "Introduction", "outline": ["Why", "What"], "markdown": "# Introduction…" },
{ "order": 2, "title": "Core Concepts", "markdown": "# Core Concepts…" }
]
}
On generate, this creates a folder (name de-duplicated) and one document per entry, in order.
writing_assist artifacts — one of three, depending on context.action:
{ "kind": "message", "content": "Rewritten or tightened draft" }
{ "kind": "thread", "parts": ["1/ …", "2/ …"] }
{ "kind": "tags", "tags": ["kubernetes", "devops"] }
These are meant to be inserted into the composer by the client. They are not accepted by /generate.
Every call resolves to Anthropic, using the site's own ANTHROPIC_API_KEY. There is nothing per-user to resolve: a provider field in the request body is ignored.
The model is the request model if supplied, else the Anthropic fallback from defaultModels (see GET /api/ai/status). If the server has no key set, the call returns 409 no_provider_configured. The key is used only to call Anthropic and is never included in any response.
The OpenAI and Gemini adapters remain in the codebase but are dormant and unreachable.
Every ceiling below is enforced server-side. maxOutputTokens may request smaller than the feature's ceiling but is clamped down, never up.
| Feature | Max input (words) | Max output tokens |
|---|---|---|
writing_assist | 1500 | 1024 |
powered_template | 300 | 4096 |
message_series | 500 | 4096 |
powered_document | 500 | 8000 |
article_series | 500 | 8000 |
Other bounds:
message_series and article_series require at least 10 words of input; less returns 422 invalid_input. This matches the composer, where the series generator buttons stay inactive until the draft has enough content.message_series produces 3–12 items (default 5); article_series produces 2–6 documents (default 4). context.count is clamped into range.429 quota_exceeded. Both phases count: a /suggest and its follow-up /generate are two generations. Failed attempts also count — a call that ends in invalid_ai_output, refused, or provider_error still consumes one generation, so build in retry restraint./suggest and /generate are rate limited to 15 requests per 60 seconds per user; tripping it returns 429 rate_limited (distinct from quota_exceeded) with a Retry-After header giving the seconds to wait. The limiter is a best-effort, per-server-instance fixed window, so under horizontal scaling the effective ceiling can be somewhat higher — treat 15/60s as the guaranteed floor, not a hard cap.All AI errors share the shape { "error": "human message", "code": "machine_code" }, so clients can switch on code without parsing prose.
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Not authenticated (no valid session or Bearer token). |
| 403 | subscription_required | Authenticated but not a subscriber. |
| 409 | no_provider_configured | The server has no ANTHROPIC_API_KEY set (site misconfiguration). |
| 422 | invalid_input | Bad request: unknown/missing feature, empty or over-length input, series under the 10-word gate, a missing source reference for a derived Powered Document mode, or a non-persistable artifact on generate. |
| 422 | invalid_ai_output | The model's output failed validation (not JSON, missing fields, or below the minimum item count). |
| 429 | quota_exceeded | The 50-per-day quota is reached (failed attempts count too). |
| 429 | rate_limited | The 15-per-60s rate limit is tripped. Response includes a Retry-After header. |
| 502 | provider_error | The upstream provider errored or timed out (60-second per-call timeout). |
| 500 | provider_error | An unexpected server error. Same code as the 502 case, so switch on both status and code if you need to tell them apart. |
A 422 refused code also exists for the case where a provider declines to answer; treat it like invalid_ai_output (retry with different input). Note the two provider_error rows above share one code across HTTP 500 and 502.
Preview a rewrite of a composer draft:
POST /api/ai/suggest
Authorization: Bearer 3f1c9e...<64 hex chars>...a8
Content-Type: application/json
{
"feature": "writing_assist",
"input": "we shipped the new thing today its pretty cool and fast",
"context": { "action": "rewrite", "targetPlatform": "mastodon" }
}
Preview a document from one of your lists, then confirm it:
POST /api/ai/suggest
Authorization: Bearer <token>
Content-Type: application/json
{
"feature": "powered_document",
"input": "Summarize this reading list into a blog-style overview.",
"context": { "mode": "from_list", "listId": "clx123abc" }
}
POST /api/ai/generate
Authorization: Bearer <token>
Content-Type: application/json
{
"feature": "powered_document",
"artifact": { "kind": "document", "title": "My Reading Overview", "markdown": "# My Reading Overview…" }
}
Check what you can use before offering AI actions in a client:
GET /api/ai/status
Authorization: Bearer <token>
powered_template artifact produces.powered_document and article_series write.message_series list mirrors.