March 29, 2026 06:43 PM
Article platform with AI out of the box: editor, media, analytics, and Markdown for LLM agents
Overview: server-side model calls without leaking keys, structured suggestions for SEO and body text, image and voice generation, model usage and view tracking, and the same public article served as HTML for humans and as Markdown—with metadata and token estimates—for tools and AI agents.
Why this matters out of the box
The shipped stack covers a typical editorial and site workflow:
The editor gets model suggestions in the context of the already saved article and revision—without a separate “chat in the browser with a key.”
SEO and preview can be generated in a structured way and applied to forms in one action; article body is a separate Markdown stream with round-trip into TipTap.
Images and voice go through the same media pipeline (CDN URLs) as author uploads—no manual handoff between services.
Administrators see aggregated token spend by source and user, plus article and revision views separately.
External systems and LLM agents get the same canonical URL
/article/{slug}as readers, but with theAccept: text/markdownheader—the response is a document with YAML front matter, aContent-Signalheader, and a token count inx-markdown-tokens, without parsing HTML or a separate “API for bots.”
Below: principles, UI surfaces, API routes, and placeholders for illustrations and sample responses.
How it works — principles
Principle | Meaning |
|---|---|
Keys only on the server |
|
Explicit AI enablement |
|
Immutable usage log | Every significant model call is recorded in |
Media in one pipeline | Generated images and TTS mp3 upload through the existing media flow; the UI sees ordinary URLs. |
Public article — two representations | HTML vs Markdown is negotiated via |
Environment variables for LLM and chat limits are described in config/env.ts (LLM_CONFIG) and .env.example: LLM_API_KEY, NEXT_PUBLIC_LLM_ENABLED, LLM_CHAT_MODELS, LLM_IMAGE_MODELS, LLM_CHAT_RATE_LIMIT_POINTS, LLM_CHAT_RATE_DURATION_SEC, and APP_INTERNAL_ORIGIN for correct server-side calls to your own APIs (e.g. behind a reverse proxy or in Docker) when setting Content-Signal on the article HTML page.
One URL — HTML for humans, Markdown for agents
For a published public article, the canonical URL stays **/article/{slug}**. The browser requests HTML as usual. A client that sends **Accept: text/markdown** (with higher priority than text/html) receives not HTML but Markdown text with YAML front matter at the top: title, description, slug, canonical, language, training flag (allow_ai_training), publication and update dates—then the article body in Markdown, aligned with the same content rendering used for HTML.
Practical benefits for LLMs and integrations:
No need to fetch HTML, strip tags, and lose structure;
One request returns token-efficient text plus machine-readable metadata;
The
**x-markdown-tokens**header estimates document size in tokens (viagpt-tokenizer,o200k_baseprofile)—handy for budgeting calls to your own or an external model;The
**Content-Signal**header conveys policy in the spirit of Content Signals: e.g. whether the material may be used for training (ai-train=yes|no) while keepingsearch=yes,ai-input=yesfor public delivery.
Technically the request is handled in **src/proxy.ts**: when Accept matches, a rewrite runs to the internal route GET /api/v1/public/article/markdown (slug via query and a helper header). For HTML responses, Content-Signal is merged from a separate request to GET /api/v1/public/article/content-signal?slug=... with base APP_INTERNAL_ORIGIN when the public request origin is unreliable.
Example calls (curl)
Request Markdown at the public article URL (as an agent or script would):
curl -sS -D - -o /tmp/article.md \
-H "Accept: text/markdown;q=1.0, text/html;q=0.5" \
"https://example.com/article/my-article-slug"The response should include among other things:
Content-Type: text/markdown; charset=utf-8Vary: AcceptContent-Signal: ai-train=yes, search=yes, ai-input=yes(values depend on article settings)x-markdown-tokens:
Direct call to the internal API (handy for docs and debugging; production base URL and rate limits may differ):
curl -sS -D - -o /tmp/article.md \
"https://example.com/api/v1/public/article/markdown?slug=my-article-slug"Sample JSON from content-signal (for documentation)
The GET /api/v1/public/article/content-signal?slug=... endpoint returns JSON with a ready-made string for the Content-Signal header:
{
"contentSignal": "ai-train=yes, search=yes, ai-input=yes"
}If the article has allowAiTraining: false, the response will include ai-train=no.
Sample Markdown body fragment (YAML + start of text)
Below is an illustrative format fragment (front matter fields match buildPublicArticleMarkdownDocument):
---
title: "Article title"
description: "Short description for preview and agents"
slug: "my-article-slug"
canonical: "https://example.com/article/my-article-slug"
language: "en"
allow_ai_training: yes
published_at: "2026-03-01T12:00:00.000Z"
updated_at: "2026-03-15T09:30:00.000Z"Comparison: HTML vs Markdown for one slug
Criterion | HTML (browser default | Markdown ( |
|---|---|---|
Content type |
|
|
Ease of parsing for agents | low (markup noise) | high (structure, fewer tokens per unit of meaning) |
Metadata | mostly in | YAML at the top of the file |
Volume estimate for LLMs | non-standardized |
|
Editor: streaming chat, audit, structured suggestions
Chat
POST /api/v1/llm/chat/stream — SSE stream; context is built from the article title, SEO fields, and plain text from TipTap (extractPlainTextFromRevisionContent). Only ADMIN / EDITOR roles; per-user rate limit applies. Usage events use source: chat_stream. History is loaded via GET /api/v1/llm/chat/history (LlmChatSession, LlmChatMessage). AI entry points in the UI are available after the article is saved with a revision.
{
"enabled": true,
"chat": { "models": [{ "id": "gpt-4o-mini", "label": "GPT-4o mini" }] },
"audit": { "models": [{ "id": "gpt-4o-mini", "label": "GPT-4o mini" }] },
"image": {
"models": [
{
"id": "gpt-image-1",
"label": "GPT Image 1",
"defaultAspectRatioId": "16:9",
"aspectRatios": [{ "id": "16:9", "label": "16:9", "size": "1536x1024" }]
}
]
}
}Article audit
POST /api/v1/llm/article-audit — JSON mode; results are stored in LlmArticleAudit, list via GET /api/v1/llm/article-audit. Response types include preview, content, seo sections with scores, strengths, issues, and recommendations.
{
"audit": {
"preview": {
"score": 8,
"summary": "Preview matches the topic.",
"strengths": ["Clear lead"],
"issues": ["Long second sentence"],
"recommendations": ["Shorten the subtitle"]
},
"content": { "summary": "…", "strengths": [], "issues": [], "recommendations": [] },
"seo": { "summary": "…", "strengths": [], "issues": [], "recommendations": [] },
"overall": "Ready to publish with preview tweaks."
},
"usage": { "promptTokens": 1200, "completionTokens": 400, "totalTokens": 1600 },
"model": "gpt-4o-mini",
"savedId": "67d…"
}SEO, preview, and body (structured API responses)
Route | Purpose | Key JSON fields |
|---|---|---|
| Meta and OG |
|
| Preview card |
|
| Article body |
|
Sample SEO response (SeoSuggestApiResponse fields):
{
"suggest": {
"metaTitle": "New article: introduction to the platform",
"metaDescription": "A short overview of the editor and public delivery.",
"ogTitle": "New article — platform",
"ogDescription": "Same for social previews.",
"keywords": "platform, articles, markdown, seo"
},
"usage": { "promptTokens": 800, "completionTokens": 200, "totalTokens": 1000 },
"model": "gpt-4o-mini"
}Sample content response (ContentSuggestApiResponse):
{
"suggest": {
"markdown": "## Introduction\n\nGenerated article body in Markdown…",
"rationale": "Structure: intro, three sections, conclusion."
},
"usage": { "promptTokens": 2000, "completionTokens": 1500, "totalTokens": 3500 },
"model": "gpt-4o-mini"
}Image generation
Routes: POST /api/v1/llm/image/generate, .../image/generate/stream, .../image/prompt/stream. Result: a media asset and proxyUrl, plus promptUsed and usage. In the UI: MediaUrlUploadField (article preview, OG, image dialog in the editor).
{
"asset": { "id": "…", "mimeType": "image/png" },
"proxyUrl": "https://…",
"usage": { "promptTokens": 0, "completionTokens": 0, "totalTokens": 1000 },
"model": "gpt-image-1",
"aspectRatioId": "16:9",
"promptUsed": "illustration for the platform article, flat style"
}Listen audio (TTS)
POST /api/v1/article/listen-audio/generate — mp3 generation, updates listenAudioAssetId and related fields on Article. Usage: source: listen_tts (numeric fields carry character counts for TTS billing alignment).
Article views
POST /api/v1/article/view — increments counters on Article and published ArticleRevision; deduplication via Redis ~24h per visitor/user + article + revision when Redis is available. Admin: GET /api/v1/article/views/dashboard, GET /api/v1/article/views/by-article/[articleId], page /admin/article-views.
LLM usage tracking
LlmUsageEvent log with sources: chat_stream, article_audit, seo_suggest, preview_suggest, content_suggest, listen_tts, image_generate, image_prompt_stream, image_prompt_article.
GET /api/v1/llm/usage/dashboard?days=— admin summary (/admin/llm-usage).GET /api/v1/llm/usage/article?articleId=&revisionId=— per article/revision summary for editors.
{
"since": "2026-03-01T00:00:00.000Z",
"until": "2026-03-27T23:59:59.999Z",
"days": 14,
"totals": {
"eventCount": 420,
"totalPromptTokens": 900000,
"totalCompletionTokens": 300000,
"totalTokens": 1200000
},
"bySource": [
{ "source": "chat_stream", "count": 200, "totalTokens": 400000 },
{ "source": "seo_suggest", "count": 50, "totalTokens": 80000 }
],
"topUsers": [{ "userId": "…", "eventCount": 80, "totalTokens": 200000 }],
"recent": []
}HTTP API summary
Area | Method | Path |
|---|---|---|
Chat | POST |
|
Chat | GET |
|
Models | GET |
|
Audit | POST, GET |
|
SEO / preview / content | POST |
|
Images | POST |
|
Usage | GET |
|
TTS | POST |
|
Views | POST |
|
Views (admin) | GET |
|
Public: Markdown | GET |
|
Public: signal | GET |
|
Summary
The platform provides a single workflow for editorial work: AI is tied to the saved document, structured responses map to forms and the editor, media and voice stay on the CDN path, model spend and views are visible in admin. Separately, public Markdown delivery shares content negotiation on the same URL as HTML: it lowers token cost for downstream LLMs, simplifies integrations, and works with **Content-Signal** and **x-markdown-tokens** — without a separate “human” vs “machine” site URL.
Additional materials in the repository
Detailed implementation checklists and plans: docs/AI_FEATURES_ROADMAP.md, docs/PRODUCT_ROADMAP.md.