Lesstruct API Reference (/api/v1)#
Lesstruct exposes a versioned, API-key-authenticated REST API at /api/v1 for creating, reading, updating, and deleting Content and Media. It is designed for programmatic consumers — the lesstruct-cli, MCP servers, AI agents (Claude Code, OpenCode, Hermes, …), and human integrators — and accepts Markdown as a first-class authoring format.
This reference documents the implemented surface. For the design intent, see _bmad-output/planning-artifacts/architecture-ai-cli.md.
Overview#
- Base URL. The API is served from the same origin as your Lesstruct server, under the
/api/v1prefix. Example:https://your-lesstruct.example/api/v1/content. - Transport. HTTPS in production. All request and response bodies are
application/json, except media upload which ismultipart/form-data. - Authentication. Every
/api/v1request carries an API key as a Bearer token (see Authentication)./api/v1is Bearer-only — there is no cookie/JWT fallback. - Versioning. The
v1URL segment pins the contract. Breaking changes ship under a new version segment. - JSON conventions. Keys are
camelCase. Strings are UTF-8. Timestamps are ISO 8601 strings.
Authentication#
Requests authenticate with a personal API key in the Authorization header:
| |
The key string has the format lesstruct_<keyID>_<secret>:
lesstruct_— a recognizable prefix (like GitHub’sghp_), so keys are easy to detect in logs and scanners.keyID— 12 hex characters (e.g.a1b2c3d4e5f6). This is the public, safely-displayable identifier.secret— 32 hex characters (≥128 bits). It is stored only as a salted hash and is never logged.
Creating keys#
API keys are created in the admin panel under Profile → API Keys (this is a browser/JWT action, not part of /api/v1). When you create a key:
- You give it a human-readable name (e.g. “Claude Code”).
- The full key string is shown exactly once, with a copy button and a “you won’t see this again” warning. Save it immediately.
- Thereafter, the key is displayed only as its prefix (
lesstruct_a1b2c3d4e5f6••••).
You can revoke a key at any time from the same view; revoked keys immediately stop authenticating.
Logging hygiene#
Only the keyID is ever logged — the secret and the full key string are redacted in all log output. Integrators should apply the same redaction in their own logs.
Authorization#
A key acts as the user who created it. It inherits that user’s role and permissions, and every operation is scoped to that user’s own resources (you can only list/read your own content and media, unless your role is Admin). Lesstruct’s existing role-based access control governs every request unchanged.
Conventions#
Response envelope#
All responses use a uniform envelope with three optional top-level keys: data, error, and meta.
Single resource (create / get / update, and single media get):
| |
List (content list, media list) — data is a bare array, not wrapped in an object:
| |
Watch the asymmetry. A single content item is
{"data":{"content":{…}}}(wrapped undercontent), but a list is{"data":[…]}(bare array). This is intentional and is the most common source of client bugs. Empty lists render as"data":[](the key is always present).
Error:
| |
Pagination#
List endpoints use cursor (keyset) pagination, which is stable across inserts and deletes (unlike offset pagination).
| Parameter | Default | Range | Notes |
|---|---|---|---|
limit | 50 | 1–100 | Missing/invalid/negative → 50; over 100 → clamped to 100. |
cursor | (omit) | opaque | Omit for the first page. Pass the nextCursor from the previous response. |
The cursor is an opaque, unpadded base64url token encoding the id of the last item on the current page. Do not construct or inspect it — treat it as opaque and echo it back. An invalid cursor returns 400 VALIDATION_ERROR "Invalid cursor".
The response includes meta.pagination:
nextCursor— present only whenhasMoreistrue. Pass it as the next request’scursor.hasMore— whether another page exists.
| |
Lists are scoped to the caller’s own resources — an API key cannot enumerate another user’s content or media (Admin-role keys excepted, per the role inheritance above).
Visibility (no-enumeration model)#
To avoid disclosing which resources exist, operations on a resource you don’t own (and aren’t an Admin for) return 404 NOT_FOUND — never 403 FORBIDDEN:
- Drafts are readable only by their owner.
- Published content is readable by any authenticated key.
GET/PUT/DELETEon a resource you don’t own →404 NOT_FOUND(existence is not disclosed).
Content#
The Content resource lets you publish posts, pages, and other content types over the API. Content is stored as canonical Tiptap JSON; you may submit Markdown and let the server convert it (see Authoring in Markdown).
Content object#
| |
| Field | Type | Notes |
|---|---|---|
id | int | Stable identifier. |
title | string | 1–200 chars. |
slug | string | URL slug. Auto-generated from the title; the slug you send in create/update is not honored. |
body | string | The canonical content — a Tiptap JSON document string. |
status | string | "draft" or "published". |
postType | string | Content type (e.g. post, page), from your configured post types. |
language | string | Language code. Settable on create/update; must be in the server’s configured languages list, else 400 ErrInvalidLanguage. |
tags | string[] | Tags. Settable on create/update; normalized server-side (lowercased, trimmed, deduped, length-bounded) via ValidateTags. |
customFields | object | TOML-defined, server-validated custom-field values. |
author | string | Display name of the author. Read-only — derived from the API key’s user. |
createdAt / updatedAt | string | ISO 8601 timestamps. |
Create content#
| |
Request body:
| |
| Field | Required | Notes |
|---|---|---|
title | yes | 1–200 chars. |
body | yes | The content. With format: markdown it is Markdown (converted server-side to Tiptap); with format: tiptap (the default) it must be a valid Tiptap JSON document string. |
format | no | "markdown" or "tiptap". Defaults to "tiptap". Matched case-insensitively after trimming leading/trailing whitespace. |
postType | no | Content type. |
tags | no | Array of tag strings. Server normalizes (trim, lowercase, dedupe, length-bound) via ValidateTags; an invalid tag returns 400 VALIDATION_ERROR. |
language | no | Language code (e.g. "en", "id"). Must be in the server’s configured languages list (config.toml [languages]); an unknown code returns 400 VALIDATION_ERROR (ErrInvalidLanguage). |
slug | no | Accepted for API stability but not honored — the server auto-generates the slug from the title. |
customFields | no | Custom-field values, validated through the same path the admin uses. |
isPublished | no | true → "published"; false/omitted → "draft". |
Response 200 OK:
| |
Create returns
200 OK(not201 Created) by design — consistent with the other/api/v1success responses.
Errors: 400 VALIDATION_ERROR (bad/missing fields, invalid Tiptap, custom-field validation, or Markdown that converts to Tiptap the server rejects — see Authoring in Markdown).
Get content#
| |
Response 200 OK: {"data":{"content":{…}}}.
Returns 404 NOT_FOUND if the content does not exist or you are not allowed to read it (a draft owned by someone else). Published content is readable by any authenticated key.
List content#
| |
Returns the caller’s own content (drafts and published), newest-first, using cursor pagination. All filters AND together with the cursor; pass multiple tag values to AND-of-tags (the post must carry every tag).
Query parameters:
| Param | Type | Notes |
|---|---|---|
limit | int | Default 50, max 100. |
cursor | string | Opaque token from a previous list call. |
tag | string (repeatable) | AND-of-tags — the post must carry every tag. |
language | string | Filter by language code. |
status | draft | published | Unknown values return 400 VALIDATION_ERROR. |
post_type | string | Filter by post type. |
author | string | Admin only. Non-admins receive 403 FORBIDDEN. |
search | string | Title / meta-description substring (case-insensitive). Min length 2; shorter values are dropped. |
Response 200 OK:
| |
Update content#
| |
Accepts title, body, format, postType, customFields, isPublished, tags, and language. SEO metadata (metaDescription, ogTitle, ogDescription), allowComments, and translationGroupId are preserved from the existing item and cannot be changed via this endpoint — any values you send for them are ignored. slug is accepted for API stability but not honored (the server auto-generates the slug from the title). format: markdown converts the body to Tiptap before storing.
Response 200 OK: {"data":{"content":{…}}} with the updated item.
Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin) — existence is not disclosed (see Visibility). Errors: 400 VALIDATION_ERROR.
Delete content#
| |
Response 204 No Content (empty body) on success. A subsequent GET returns 404 NOT_FOUND.
Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin).
Publish content#
| |
Standalone status-toggle verb. No request body. On the draft → published transition the server auto-generates SEO metadata (when the SEO service is configured) and fires the AfterPublish plugin hook. Publishing an already-published post is a 200 no-op: the row is persisted unchanged, no hook fires, no SEO is regenerated.
Response 200 OK: {"data":{"content":{…}}} with the item now in status: "published".
Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin) — existence is not disclosed. Errors: 400 VALIDATION_ERROR (bad id).
| |
Unpublish content#
| |
Standalone status-toggle verb. No request body. Sets status: "draft". Never fires the AfterPublish hook (the hook is wired to the draft → published edge only). Unpublishing an already-draft post is a 200 no-op.
Response 200 OK: {"data":{"content":{…}}} with the item now in status: "draft".
Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin). Errors: 400 VALIDATION_ERROR (bad id).
| |
Media#
Upload, retrieve, and list media (images). Media is deduplicated by content hash and stored with generated variants (e.g. WebP + thumbnails).
Media object#
| |
| Field | Type | Notes |
|---|---|---|
id | int | Stable identifier. |
filename / originalFilename | string | Stored / uploaded filename. |
mimeType | string | Source MIME type (JPG/PNG/GIF/WebP). |
fileSize | int | Bytes. |
width / height | int | Pixel dimensions. |
altText | string | Accessibility text. |
isWebp | bool | Whether the primary stored file is WebP. (Note the key isWebp, not isWebP.) |
hash | string | Content hash used for dedup. |
url | string | The absolute URL to reference this media in content (e.g. https://your-lesstruct.example/uploads/media/<file>). |
variants | object | Map of variant name → { "url", "width" } (e.g. thumbnail). |
createdAt / updatedAt | string | ISO 8601 timestamps. |
Upload media#
| |
The body is multipart/form-data with:
file(required) — the image part (JPG, PNG, GIF, or WebP). A missingfilepart returns400 VALIDATION_ERROR "file part is required".metadata— a JSON part:{"altText":"A scenic mountain view"}. A non-emptyaltTextis required for accessibility. The part is optional only in the multipart sense: omitting it (or sending emptyaltText) causes the service to reject the upload with400 VALIDATION_ERROR. Always send it.
| |
Response 200 OK: {"data":{"media":{…}}}.
| Status | Code | When |
|---|---|---|
200 | — | Uploaded; a new media item was created and stored. |
400 | VALIDATION_ERROR | Missing file part; unsupported/oversized file; empty altText. |
409 | CONFLICT | A file with the same content hash already exists. The upload is not stored; upload a different file or use the existing media’s url. |
Duplicate handling. The API returns
409 CONFLICTfor a duplicate upload (rather than returning the existing item, as the admin panel does). This keeps the contract honest: the upload was not stored.
Get media#
| |
Response 200 OK: {"data":{"media":{…}}}.
Returns 404 NOT_FOUND if the media does not exist or you are not its owner (and not Admin) — existence is not disclosed.
List media#
| |
Returns the caller’s own media, newest-first, using cursor pagination. Same envelope as the content list: a bare data array plus meta.pagination.
Shared path note.
GET /api/v1/mediaandGET /api/v1/media/{id}are shared with the browser admin panel; the server dispatches to the agent handler when the request presents alesstruct_-prefixed Bearer token, and to the browser handler otherwise. For agent clients this is transparent — always send the Bearer key.POST /api/v1/media(upload) is agent/Bearer-only.
Errors#
Errors use the envelope’s error object: {"error":{"code":"…","message":"…"}}. (A details field is reserved on the object but is not currently populated by the /api/v1 handlers.)
Error catalog#
| HTTP | Code | Meaning | Emitted by |
|---|---|---|---|
401 | UNAUTHORIZED | No / undecodable identity. | handler / middleware |
401 | INVALID_API_KEY | The key is malformed or unknown. | auth middleware |
401 | REVOKED_KEY | The key has been revoked. | auth middleware |
401 | EXPIRED_KEY | The key has expired. | auth middleware |
400 | VALIDATION_ERROR | Bad request body, invalid Tiptap, custom-field validation, invalid cursor, invalid id, missing file part, bad alt text, etc. | handler |
404 | NOT_FOUND | Resource does not exist, or you don’t own it (and aren’t Admin) — existence is not disclosed. | handler |
403 | FORBIDDEN | Reserved for service-layer rejections. Not the response for resources you don’t own — those return 404 (no-enumeration). Rarely emitted on the agent surface. | handler |
409 | CONFLICT | Duplicate media upload. | media handler |
429 | RATE_LIMITED | You have exceeded the per-key rate limit. | rate-limit middleware |
500 | INTERNAL_ERROR | Unexpected server error. | handler |
No-enumeration#
Resource existence is never disclosed: a request for a resource you don’t own (and aren’t Admin for) returns 404 NOT_FOUND, not 403 FORBIDDEN. Treat 404 on GET/PUT/DELETE as “not found or not yours”.
Authoring in Markdown#
Set format: "markdown" on create/update to author content in Markdown. The server parses it with goldmark (core CommonMark) and converts it to canonical Tiptap JSON, which is what is stored. Raw Markdown is never persisted.
| |
Supported Markdown#
| Markdown | Result |
|---|---|
# H1 … ###### H6 | Headings (levels 1–6). |
| Plain text | Paragraphs. |
**bold**, __bold__ | Bold. |
*italic*, _italic_ | Italic. |
`code` | Inline code. |
```lang … ``` (fenced) | Code block, with language from the info string. |
| Indented code | Code block (no language). |
---, ***, ___ | Horizontal rule. |
> quote | Blockquote (nestable). |
- a / * a / + a | Bullet list. |
1. a | Ordered list. |
 | Image (src/alt/title). |
[text](url "title") | Link (href/title). |
<https://example.com> | Autolink (→ link). |
Hard line break (··\n or text\) | Hard break. |
Sanitized / not enabled#
- Raw HTML is sanitized. Inline and block raw HTML is reduced to safe plain text (tags are stripped, visible text is kept) via bluemonday. Raw HTML markup is never stored. Converting rich HTML formatting to Tiptap marks is out of scope — only the visible text survives.
- Tables, task lists, and strikethrough are not enabled (core CommonMark only). They render as plain text/paragraphs.
URL safety#
The converted document must pass Lesstruct’s Tiptap validator, which restricts URL schemes:
- Link
hrefmust behttp,https,mailto, or empty. - Image
srcmust behttp,https, or empty.
A link or image with another scheme (javascript:, data:, file:, …) causes the converted document to fail validation and the request returns 400 VALIDATION_ERROR. This is intentional and applies site-wide (including admin-authored content). Use an http(s) URL or upload the media first (see Images).
Markdown is an ingest format only. It is always converted to Tiptap JSON before storage; you cannot retrieve the original Markdown. Round-trip (Tiptap → Markdown) is out of scope.
Images#
External images —
passes through unchanged; thesrcis stored as-is (subject to thehttp(s)scheme rule above).Local media — to embed an image you upload, first upload it via
POST /api/v1/media, then reference the returnedurlin your Markdown:1 2 3 4 5 6 7 8# 1. Upload curl -H "Authorization: Bearer lesstruct_<...>" \ -F "file=@photo.jpg" -F 'metadata={"altText":"..."}' \ "https://your-lesstruct.example/api/v1/media" # → { "data": { "media": { "url": "https://your-lesstruct.example/uploads/media/a1b2c3d4.webp", ... } } } # 2. Reference the returned url 
Rate limiting#
/api/v1 is rate-limited per API key (not per IP) for attribution and fairness, using the same token-bucket limiter as the rest of the API. When you exceed the limit you receive 429 RATE_LIMITED. Browser/admin routes are rate-limited per IP.
If you hit the limit, wait and retry with backoff. The limit is shared across all requests made with a given key.
OpenAPI snippet#
A machine-readable OpenAPI fragment for the Content create endpoint. A full OpenAPI specification is deferred to post-MVP; this snippet is suitable for agent tooling consumption.
| |
The same pattern extends to the remaining /api/v1/content[/{id}] and /api/v1/media operations described above. A complete OpenAPI document will be generated in a follow-up.