feat: llms.txt and llms-full.txt, openapi.json, MCP server
This commit is contained in:
1 parent
daba3c04c0
commit
4cff43fefb
25 files changed
+1691
-1
No files matched your search
@@ -0,0 +1,67 @@
|
||||
# Gotchas
|
||||
|
||||
> What trips up API clients: session-only routes, error texts, rate limits and draft visibility.
|
||||
|
||||
## Session-only routes
|
||||
|
||||
An API key cannot call these routes. They answer `403` with a plain-text body and no JSON:
|
||||
|
||||
```
|
||||
HTTP/1.1 403 Forbidden
|
||||
|
||||
This action needs a real session.
|
||||
```
|
||||
|
||||
- `/v1/api-key/*`, `/v1/system/*`, `POST /v1/auth/verify`, `DELETE /v1/auth/register`.
|
||||
- Every activity route under `/v1/activity`.
|
||||
- Writes on sites (`POST`, `PATCH`, `DELETE` under `/v1/site`, so a key cannot create a site) and on authors (`POST`, `PATCH`, `DELETE` under `/v1/author`).
|
||||
- `PATCH /v1/settings`.
|
||||
|
||||
Variant routes under `/v1/media/{id}/variants` answer `401` to a key instead. The sign-in routes (login, logout, register, password reset) ignore a key and give no `403`.
|
||||
|
||||
When a task needs one of these routes, stop and tell the user to do it in the app. Do not try to log in with a password.
|
||||
|
||||
## Error texts are plain text
|
||||
|
||||
Errors are not JSON. The body is one sentence; branch on the status code, and show the sentence to the user.
|
||||
|
||||
| Status | Typical text |
|
||||
| --- | --- |
|
||||
| `400` | `The request cannot be processed due to a mismatch in the format of the body.` |
|
||||
| `401` | `You must be logged in to access this resource.` |
|
||||
| `403` | `This action needs a real session.`, `Only the owner can do this.`, `Only owners and admins can do this.`, `You must verify your account to access this resource.` |
|
||||
| `404` | `Article not found`, `Image not found` |
|
||||
| `429` | `Too many requests. Please try again later.` |
|
||||
|
||||
An invalid, unknown, expired or revoked key answers `401`, even on public routes. It is never treated as anonymous.
|
||||
|
||||
## Rate limit: 429
|
||||
|
||||
Each key may make 120 requests per minute. The `429` response carries a `Retry-After` header holding the seconds to wait:
|
||||
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 46
|
||||
|
||||
Too many requests. Please try again later.
|
||||
```
|
||||
|
||||
Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop. Prefer one list call with `size=100` over many single reads.
|
||||
|
||||
## Draft versus online
|
||||
|
||||
- Anonymous callers and unverified users only see online articles. A draft answers `404` with `Article not found`, the same as an id that does not exist.
|
||||
- A verified session or a key sees drafts as well. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page.
|
||||
- `POST /v1/article/` always creates a draft. Publishing is a separate call, `PATCH /v1/article/{id}/publish`.
|
||||
- Sitemaps list online articles only.
|
||||
|
||||
## Writes
|
||||
|
||||
- A title cannot be changed after creation. Create a new article instead.
|
||||
- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
|
||||
- Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally.
|
||||
- Ids in paths are unsigned integers. Anything else answers `400`.
|
||||
|
||||
## Permissions follow the key's creator
|
||||
|
||||
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Recipe: create, edit and publish an article
|
||||
|
||||
> Create a draft, replace its body, check its SEO, publish it and read it back, with an API key.
|
||||
|
||||
Set the key once. Every request below is real output from a running instance; ids and timestamps differ on yours.
|
||||
|
||||
```sh
|
||||
export RENEWCMS_API_KEY=rcms_...
|
||||
```
|
||||
|
||||
## 1. Create a draft
|
||||
|
||||
Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
|
||||
|
||||
```sh
|
||||
curl -X POST "{{BASE_URL}}/v1/article/" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"How we price a bathroom renovation","body":"<p>A bathroom renovation is priced by room size, plumbing changes and finish level.</p>"}'
|
||||
```
|
||||
|
||||
Status `201`:
|
||||
|
||||
```json
|
||||
{"id":1,"title":"How we price a bathroom renovation","body":"A bathroom renovation is priced by room size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":1,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.767761797+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"","seo_title":"","seo_description":"","cover_image_id":null,"cover_image_alt":"","social_title":"","social_description":"","social_image_id":null,"canonical_url":"","noindex":false,"seo_dismissed":[]}
|
||||
```
|
||||
|
||||
The article starts offline (`is_online: false`) on the default site.
|
||||
|
||||
## 2. Replace the body, move it to a site, set the excerpt
|
||||
|
||||
`PATCH` takes only `body`, `site_id`, `author_ids` and the SEO fields. At least one is required; `{}` answers `400`. Send `{"body": ""}` to empty a body.
|
||||
|
||||
```sh
|
||||
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","site_id":2,"excerpt":"What drives the price of a bathroom renovation."}'
|
||||
```
|
||||
|
||||
Status `200`:
|
||||
|
||||
```json
|
||||
{"id":1,"title":"How we price a bathroom renovation","body":"## What drives the price\n\nRoom size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.771008847+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"What drives the price of a bathroom renovation.","seo_title":"","seo_description":"","cover_image_id":null,"cover_image_alt":"","social_title":"","social_description":"","social_image_id":null,"canonical_url":"","noindex":false,"seo_dismissed":[]}
|
||||
```
|
||||
|
||||
## 3. Check the SEO before publishing
|
||||
|
||||
An empty JSON object checks the saved article. Pass any SEO field to check a change without saving it.
|
||||
|
||||
```sh
|
||||
curl -X POST "{{BASE_URL}}/v1/article/1/seo/check" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
Status `200`:
|
||||
|
||||
```json
|
||||
{"status":"suggestions","suggestions":[{"code":"desc_short","field":"seo_description","params":{"length":47,"min":70}},{"code":"cover_none","field":"cover_image_id","params":{}},{"code":"no_au","field":"author_ids","params":{}},{"code":"short","field":"body","params":{"length":66,"min":300}}],"slug":{"available":true,"suggestion":null}}
|
||||
```
|
||||
|
||||
Suggestions never block publishing.
|
||||
|
||||
## 4. Publish
|
||||
|
||||
```sh
|
||||
curl -X PATCH "{{BASE_URL}}/v1/article/1/publish" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
Status `200`, same article object with `"is_online":true`. `PATCH /v1/article/{id}/unpublish` reverses it.
|
||||
|
||||
## 5. Read it back without credentials
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/article/1?format=markdown"
|
||||
```
|
||||
|
||||
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Recipe: upload an image and attach it to an article
|
||||
|
||||
> Upload a file with multipart form data, find it in the library and use it as an article cover.
|
||||
|
||||
## 1. Upload
|
||||
|
||||
Send the file in a form field named `file`. PNG, JPEG, WEBP and SVG are accepted.
|
||||
|
||||
```sh
|
||||
curl -X POST "{{BASE_URL}}/v1/media/" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-F "file=@north-wall.png"
|
||||
```
|
||||
|
||||
Status `201`:
|
||||
|
||||
```json
|
||||
{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0}
|
||||
```
|
||||
|
||||
`url` is relative to the instance: the public address is `{{BASE_URL}}` followed by `url`. `duplicate: true` means the same bytes were already in the library.
|
||||
|
||||
## 2. Find an image
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/media/?size=5" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
Status `200`:
|
||||
|
||||
```json
|
||||
{"items":[{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0}],"total":1,"page":1,"size":5,"counts":{"all":1,"unused":1,"duplicates":0}}
|
||||
```
|
||||
|
||||
Filter with `q`, `hash`, `filter` (`all`, `unused`, `duplicates`), `sort` and `variants`.
|
||||
|
||||
## 3. Attach it as the cover
|
||||
|
||||
An image is attached by id. `cover_image_alt` is the alternative text.
|
||||
|
||||
```sh
|
||||
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cover_image_id":1,"cover_image_alt":"A tiled bathroom wall"}'
|
||||
```
|
||||
|
||||
Status `200`, the article object with `"cover_image_id":1` and `"cover_image_alt":"A tiled bathroom wall"`. Use `social_image_id` for the sharing image, and `null` to remove either image.
|
||||
|
||||
## 4. See where an image is used
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/media/1/usage" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
```json
|
||||
[{"type":"article","id":1,"title":"How we price a bathroom renovation","published":true,"fields":["cover"]}]
|
||||
```
|
||||
|
||||
Deleting or merging an image that is in use changes those articles, so read this first.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Recipe: paginate, filter and choose a body format
|
||||
|
||||
> List articles page by page, search and sort them, and read bodies as markdown, HTML or JSON blocks.
|
||||
|
||||
`GET /v1/article/` always answers an envelope, never a bare array:
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/article/?format=html&size=5&page=1" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
```json
|
||||
{"items":[{"id":1,"title":"How we price a bathroom renovation","body":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","body_format":"html","is_online":true,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:24.236138156+02:00","excerpt":"What drives the price of a bathroom renovation.","slug":"how-we-price-a-bathroom-renovation","seo_status":"suggestions","seo_suggestions":3}],"total":1,"page":1,"size":5,"counts":{"all":1,"online":1,"draft":0,"suggestions":1}}
|
||||
```
|
||||
|
||||
In JSON, `<` and `>` are escaped as `<` and `>`; a JSON parser returns the plain characters.
|
||||
|
||||
## Walking every page
|
||||
|
||||
1. Request `page=1` with the `size` you want (`5`, `25`, `50` or `100`).
|
||||
2. Read `total` and `size`. The number of pages is `ceil(total / size)`.
|
||||
3. Request the next pages until `page * size >= total`. A page past the end answers `200` with `items: []`, so an empty `items` also ends the loop.
|
||||
|
||||
Ordering is applied to the whole list before slicing and ends with the id, so pages never overlap or skip.
|
||||
|
||||
## Filters
|
||||
|
||||
Combine them freely.
|
||||
|
||||
| Parameter | Example | Effect |
|
||||
| --- | --- | --- |
|
||||
| `q` | `q=grout` | Title contains the text, ignoring case and accents. Up to 200 characters. |
|
||||
| `status` | `status=draft` | `online` or `draft`. |
|
||||
| `site` | `site=renew-blog` | Only that site's articles. |
|
||||
| `seo` | `seo=suggestions` | `ready`, `suggestions` or `hidden`. |
|
||||
| `sort` | `sort=title:asc` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc`. Default `updated_at:desc`. |
|
||||
|
||||
`counts` ignores every filter, so tab totals do not move while searching. `total` follows the filters.
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/article/?site=renew-blog&status=draft&sort=title:asc&q=grout" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
```json
|
||||
{"items":[{"id":2,"title":"Draft about grout","body":"x","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:12:24.922895943+02:00","updated_at":"2026-10-23T01:12:24.926728869+02:00","excerpt":"","slug":"draft-about-grout","seo_status":"suggestions","seo_suggestions":4}],"total":1,"page":1,"size":25,"counts":{"all":2,"online":1,"draft":1,"suggestions":2}}
|
||||
```
|
||||
|
||||
## The `format` parameter
|
||||
|
||||
| Value | `body` | `body_format` |
|
||||
| --- | --- | --- |
|
||||
| absent or `markdown` | markdown string | `markdown` |
|
||||
| `html` | the stored HTML | `html` |
|
||||
| `json` | array of blocks | `json` |
|
||||
|
||||
It applies to every article route, including the writes. Request bodies of `POST` and `PATCH` stay HTML whatever the format. An invalid `format` is reported before any other error.
|
||||
|
||||
## Invalid values
|
||||
|
||||
A bad `page`, `size`, `status`, `sort` or `seo` answers `400` with a plain-text body:
|
||||
|
||||
```
|
||||
The request cannot be processed due to a mismatch in the format of the body.
|
||||
```
|
||||
|
||||
Media lists paginate the same way (`GET /v1/media/?page=2&size=50`) with their own `counts`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Recipe: sites and sitemaps
|
||||
|
||||
> Create a site (session only), move an article onto it and read its public sitemap.
|
||||
|
||||
An article belongs to one site. A site has a `key` (a slug used in URLs) and a `base_url` (the public address its pages are served from).
|
||||
|
||||
## 1. List the sites
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/site/" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY"
|
||||
```
|
||||
|
||||
```json
|
||||
[{"id":1,"name":"Default","key":"default","base_url":"http://localhost:18080","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":true,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""}]
|
||||
```
|
||||
|
||||
## 2. Create a site: not with a key
|
||||
|
||||
Creating, editing, defaulting and deleting a site need a browser session from the owner. A key answers `403`:
|
||||
|
||||
```sh
|
||||
curl -X POST "{{BASE_URL}}/v1/site/" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"x","key":"x","base_url":"https://x.example"}'
|
||||
```
|
||||
|
||||
```
|
||||
HTTP/1.1 403 Forbidden
|
||||
|
||||
This action needs a real session.
|
||||
```
|
||||
|
||||
Ask the owner to create the site in the app, or send the request with the owner's `jwt` cookie. With a session the same request answers `201`:
|
||||
|
||||
```json
|
||||
{"id":2,"name":"Renew blog","key":"renew-blog","base_url":"https://blog.renew.example","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":false,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""}
|
||||
```
|
||||
|
||||
## 3. Put an article on the site
|
||||
|
||||
Use the site `id` as `site_id`:
|
||||
|
||||
```sh
|
||||
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
|
||||
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"site_id":2}'
|
||||
```
|
||||
|
||||
## 4. Read the sitemap
|
||||
|
||||
The sitemap is public and lists the online articles of one site, by its `key`:
|
||||
|
||||
```sh
|
||||
curl "{{BASE_URL}}/v1/site/renew-blog/sitemap"
|
||||
```
|
||||
|
||||
```json
|
||||
[{"url":"https://blog.renew.example/how-we-price-a-bathroom-renovation","modified_at":"2026-10-22T23:11:24Z","noindex":false}]
|
||||
```
|
||||
|
||||
`url` is the site's `base_url` plus the article slug. Drafts are not listed. The response header `X-Total-Count` holds the number of entries. Page with `page` and `size` (`25`, `50`, `100` or `500`, default `100`); any other `size` answers `400`. An unknown key answers `404` with `site_not_found`.
|
||||
|
||||
List only one site's articles with `GET /v1/article/?site=renew-blog`.
|
||||
@@ -0,0 +1,22 @@
|
||||
# LLM documentation
|
||||
|
||||
A running instance serves its own documentation for language models, versioned with the binary:
|
||||
|
||||
| URL | Content |
|
||||
| --- | --- |
|
||||
| `/llms.txt` | Short index with links, following llmstxt.org. |
|
||||
| `/llms-full.txt` | Every page below in one file. |
|
||||
| `/llms/<page>.md` | One page: `api-reference`, `api`, `articles`, `api-keys`, `gotchas`, `recipe-articles`, `recipe-media`, `recipe-sites`, `recipe-pagination`, `configuration`. |
|
||||
| `/openapi.json` | OpenAPI 3.1 description of the routes in the catalogue. |
|
||||
|
||||
All of them are public. `{{VERSION}}` and `{{BASE_URL}}` in the sources are replaced by the binary's version and `PUBLIC_URL` when served.
|
||||
|
||||
## How it is built
|
||||
|
||||
`task build:llms` runs `scripts/generate-llms.ts` (bun) and writes into `internal/infrastructure/llmdocs/generated/`, which is gitignored and embedded in the binary. `task build`, `task release`, `task dev:api` and the Docker image run it first. Without it the routes answer 404 with a notice.
|
||||
|
||||
- The API reference and `openapi.json` come from `web/src/data/apiCatalogue.ts`, the same file the Integration page renders. Edit the catalogue, not the output.
|
||||
- `docs/api.md`, `docs/api/*.md`, the variables table of `docs/configuration.md` and every `docs/llm/*.md` are included as written. A page in `docs/llm/` starts with `# Title`, then a `> one-line summary` used by `llms.txt`.
|
||||
- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes.
|
||||
|
||||
`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# MCP server
|
||||
|
||||
`cmd/mcp` builds `renewcms-mcp`, a stdio [MCP](https://modelcontextprotocol.io) server for Claude Desktop, Claude Code, Cursor and similar clients. It is an HTTP client of the public API: it holds no database access and sends the API key as `Authorization: Bearer`, so what the key's user may do is the limit of what the model may do.
|
||||
|
||||
Build it with `task build:mcp` (`bin/renewcms-mcp`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `RENEWCMS_URL` | Address of the instance, for example `https://cms.example`. |
|
||||
| `RENEWCMS_API_KEY` | An API key created at `/integration/api-keys`. |
|
||||
|
||||
They belong to the MCP binary only and are not server variables.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"renewcms": {
|
||||
"command": "/path/to/renewcms-mcp",
|
||||
"env": { "RENEWCMS_URL": "https://cms.example", "RENEWCMS_API_KEY": "rcms_..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
`list_articles`, `get_article`, `create_article` (always a draft), `update_article_body`, `publish_article`, `check_seo`, `list_media`, `upload_media` (local `path`, or `base64` with `filename`), `list_sites`, `get_sitemap`.
|
||||
|
||||
`--allow-destructive` adds `delete_article` and `delete_media`.
|
||||
|
||||
Routes that need a browser session, such as creating a site, are not exposed. API errors come back as tool errors with the status and the plain-text message.
|
||||
|
||||
## Resources and prompts
|
||||
|
||||
The resource `renewcms://docs/llms-full.txt` is the instance's own [`/llms-full.txt`](llms.md), so the model reads the documentation of the exact version it talks to. The prompt `write_and_publish_article` drafts, checks the SEO and asks before publishing.
|
||||
Reference in new issue
Block a user