AI Integration

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 which providers you have configured 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.

You bring your own provider key. InterlinedList calls Anthropic, OpenAI, or Google Gemini with the key you store on the Integrations page (see Settings (Help Center)). Stored keys are decrypted server-side to call the provider and are never returned by any endpoint.

The suggest → generate flow

The two-step design lets a client show a preview and get explicit user confirmation before anything is written:

  1. Suggest. 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.
  2. Confirm and generate. The client shows the artifact to the user. On confirmation, send that same artifact back to 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.

Endpoint table

MethodPathAuthDescription
POST/api/ai/suggestSession or BearerRun a feature and return a validated preview artifact. No write. Subscriber only.
POST/api/ai/generateSession or BearerPersist a confirmed artifact from /suggest. Subscriber only.
GET/api/ai/statusSession or BearerConfigured providers, your default provider/model, and remaining daily quota. Never returns keys.

Endpoint reference

POST /api/ai/suggest

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" },
  "provider": "anthropic | openai | gemini  (optional override)",
  "model": "provider model id  (optional override)",
  "maxOutputTokens": 2048
}
FieldTypeRequiredDescription
featurestringyesOne of the five features. Any other value returns 422 invalid_input.
inputstringyesFree-text instruction (or draft, for writing_assist). Word-capped per feature; see Input and output limits.
contextobjectnoFeature-specific hints and source references. See Context object.
providerstringnoForce a provider. Must be one you have a stored key for, else 409 no_provider_configured.
modelstringnoOverride the model id for the resolved provider.
maxOutputTokensnumbernoRequested 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

StatusCodeCondition
401unauthorizedNot authenticated.
403subscription_requiredAuthenticated but not a subscriber.
409no_provider_configuredNo stored provider key, or the requested provider has no key.
422invalid_inputMissing/unknown feature, empty input, input over the word cap, or a series under the 10-word gate.
422invalid_ai_outputThe model returned output that failed validation (not valid JSON, missing required fields, or below the minimum item count).
429quota_exceededThe daily quota is reached.
429rate_limitedPer-user short-window rate limit tripped (separate from the daily quota). Honor the Retry-After header.
500 / 502provider_errorUpstream provider failure/timeout (502, 60s per-call), or an unexpected server error (500). Both carry code: "provider_error".

POST /api/ai/generate

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)"
}
FieldTypeRequiredDescription
featurestringyesThe feature the artifact was produced for.
artifactobjectyesA confirmed artifact envelope. Re-validated server-side before it is written.
providerstringnoRecorded for auditing only; does not affect the write.
modelstringnoRecorded for auditing only; does not affect the write.

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 shapeProduced by
{ "listId": "…" }list and message_series artifacts (both create 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

StatusCodeCondition
401unauthorizedNot authenticated.
403subscription_requiredAuthenticated but not a subscriber.
409no_provider_configuredYou have no stored provider key. /generate checks this too, so a keyless subscriber posting a hand-edited artifact gets a 409.
422invalid_inputMissing feature/artifact, or an artifact type that cannot be persisted.
422invalid_ai_outputThe confirmed artifact fails re-validation.
429quota_exceededThe daily quota is reached.
429rate_limitedPer-user short-window rate limit tripped. Honor the Retry-After header.
500 / 502provider_errorAn unexpected server error (500) or an upstream provider failure/timeout (502). Both carry code: "provider_error".

GET /api/ai/status

Auth required: yes (Session or Bearer) Subscriber only: no (any authenticated user can read their status) Description: Report configured providers, the user's default provider/model, the per-provider fallback models, and remaining daily quota. Never returns keys.

Response 200 OK

{
  "subscriber": true,
  "providers": ["anthropic", "openai"],
  "defaultProvider": "anthropic",
  "defaultModel": null,
  "defaultModels": {
    "anthropic": "claude-sonnet-5",
    "openai": "gpt-4.1-mini",
    "gemini": "gemini-2.0-flash"
  },
  "quota": { "usedToday": 8, "dailyLimit": 50, "remaining": 42 }
}
FieldTypeDescription
subscriberbooleanWhether the account may use /suggest and /generate.
providersstring[]Providers you have a stored key for, in fixed priority order (anthropic, openai, gemini). Empty means AI features will return 409 no_provider_configured.
defaultProviderstring | nullYour chosen default provider, or null if unset.
defaultModelstring | nullYour chosen default model, or null to use the provider's fallback.
defaultModelsobjectThe fallback model per provider when you have not set defaultModel.
quotaobjectusedToday, dailyLimit, and remaining for the rolling 24-hour window.

Error responses

StatusCodeCondition
401unauthorizedNot authenticated.

Features

feature selects which AI capability runs and, in turn, the artifact shape you get back:

FeatureWhat it doesSuggest artifact kindGenerate creates
powered_templateGenerate a personalized list (schema + starter rows) from a description.listA list ({ listId }).
powered_documentDraft a single markdown document (four modes; see below).documentA document ({ documentId }).
message_seriesPlan a threaded series of short messages.message_seriesA list of scheduled rows ({ listId }).
article_seriesPlan and write a coherent series of documents.doc_seriesA folder of documents ({ folderId, documentIds }).
writing_assistRewrite, tighten, expand, fix grammar, thread, or tag a composer draft.message, thread, or tagsNot persistable (insert client-side).

Powered Document modes

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.modeAlso requiresSource resolution
article (default)No source; drafts from your input topic alone.
from_listcontext.listIdLoads your list's schema and up to 50 rows, server-side.
from_articlecontext.documentIdLoads your document's title and body, server-side.
research_urlcontext.urlFetches 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 object

context is optional and id-based. Fields are feature-specific; unknown fields are ignored. Numeric hints are clamped server-side.

FieldTypeUsed byDescription
templateKeystringpowered_templateWhich base template to personalize.
modestringpowered_documentarticle, from_list, from_article, or research_url.
listIdstringpowered_document (from_list)Source list; must be yours.
documentIdstringpowered_document (from_article)Source document; must be yours.
urlstringpowered_document (research_url)Source URL (http/https).
actionstringwriting_assistrewrite (default), tighten, expand, grammar, thread, or tags.
targetPlatformstringwriting_assistPlatform hint for threading/rewrites (for example a character-limit target).
countnumbermessage_series, article_seriesRequested item count; clamped to the series bounds below.
spacingMinutesnumbermessage_seriesReserved / currently ignored. Accepted for forward compatibility but not applied — generated series are spaced a fixed 4 minutes apart (see below).

Artifacts by feature

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, this becomes a list whose rows are scheduled posts. The server assigns future timestamps spaced a fixed 4 minutes apart (context.spacingMinutes and any model-supplied timestamps are ignored) and keeps only cross-post targets it recognizes.

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.

Provider resolution

For each /suggest call the provider and model are resolved in this precedence:

  1. Per-request override — the provider in the request body (must have a stored key).
  2. Your defaultdefaultProvider set on the Integrations page.
  3. First configured — in fixed priority order: anthropic, then openai, then gemini.

The model is resolved as: the request model, else your defaultModel (only when the resolved provider is your default provider), else the provider's fallback model from defaultModels (see GET /api/ai/status). If the resolved provider has no stored key, the call returns 409 no_provider_configured. Keys are decrypted only to call the provider and are never included in any response.

Input and output limits

Every ceiling below is enforced server-side. maxOutputTokens may request smaller than the feature's ceiling but is clamped down, never up.

FeatureMax input (words)Max output tokens
writing_assist15001024
powered_template3004096
message_series5004096
powered_document5008000
article_series5008000

Other bounds:

  • Composer 10-word gate. 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.
  • Series counts. message_series produces 3–12 items (default 5); article_series produces 2–6 documents (default 4). context.count is clamped into range.
  • Content caps. A generated document is capped at 40,000 characters; a single message at 3,000 characters. Server-fetched context (a list dump or a page's text) is truncated to 12,000 characters.
  • Daily quota. 50 AI generations per rolling 24 hours per user, enforced even with your own provider keys. At the cap, calls return 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.
  • Rate limit. In addition to the daily quota, /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.

Error responses

All AI errors share the shape { "error": "human message", "code": "machine_code" }, so clients can switch on code without parsing prose.

HTTPcodeMeaning
401unauthorizedNot authenticated (no valid session or Bearer token).
403subscription_requiredAuthenticated but not a subscriber.
409no_provider_configuredNo stored provider key, or the requested provider has no key.
422invalid_inputBad 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.
422invalid_ai_outputThe model's output failed validation (not JSON, missing fields, or below the minimum item count).
429quota_exceededThe 50-per-day quota is reached (failed attempts count too).
429rate_limitedThe 15-per-60s rate limit is tripped. Response includes a Retry-After header.
502provider_errorThe upstream provider errored or timed out (60-second per-call timeout).
500provider_errorAn 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.

Examples

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>