Both endpoints are public (no login), accept a JSON or form-encoded POST body, return JSON, and are rate limited per client IP address: 10 requests per 60 seconds by default, shared between the two endpoints. The client IP is taken from the CF-Connecting-IP header, then X-Forwarded-For, then the remote address.
POST /chatbot/api/chat
Request:
{
"question": "What Roman artefacts are in the collection?",
"site_id": 1,
"site_slug": "my-site",
"system_prompt": "Optional prompt override",
"item_set_ids": [3, 5]
}
| Field | Required | Description |
|---|---|---|
question |
yes | 1 to 1000 characters. |
site_id |
no | Only use evidence from resources belonging to this site. Omit to search across all sites. |
site_slug |
no | Rewrite source URLs to /s/{site_slug}/.... |
system_prompt |
no | Replaces the default system prompt for this request. |
item_set_ids |
no | Array of item set ids. Only chunks of items in at least one of these sets, and of those items' media, are used as evidence. Blog posts and site pages are excluded when this is sent. Non-positive and non-numeric values are ignored; an empty array means no restriction. |
Response (200):
{
"answer_html": "<p>The collection holds Roman pottery<sup><a href=\"#chatbot-source-1\" class=\"chatbot-citation\">[1]</a></sup> ...</p>",
"sources": [
{
"citation_num": 1,
"title": "Roman Pottery Shard",
"url": "/s/my-site/item/42",
"resource_type": "item",
"resource_id": 42,
"snippet": "Roman Pottery Shard\n\nA fragment of ..."
}
]
}
answer_html is escaped paragraph HTML in which [N] markers have become links to #chatbot-source-N. sources contains only the evidence items the model cited, in citation order. resource_type is item, media, blog_post or site_page. Snippets are the first 200 characters of the matching chunk.
POST /chatbot/api/search
Request:
{
"query": "agricultural buildings",
"page": 1,
"per_page": 10,
"site_id": 1,
"site_slug": "my-site",
"item_set_ids": [3, 5]
}
| Field | Required | Description |
|---|---|---|
query |
yes | 1 to 500 characters. |
page |
no | Default 1. |
per_page |
no | Default 10, maximum 50. |
site_id, site_slug |
no | As for chat. |
item_set_ids |
no | As for chat: only items in these sets and their media are returned; blog posts and site pages are excluded. |
Response (200):
{
"results": [
{
"resource_type": "item",
"resource_id": 42,
"title": "Threshing Barn",
"url": "/s/my-site/item/42",
"distance": 0.2134,
"snippet": "Threshing Barn\n\nTimber-framed barn ..."
}
],
"total": 14,
"page": 1,
"per_page": 10
}
distance is the cosine distance, where 0 is identical. Results are one per resource URL, sorted by distance. total is the number of distinct resources among the fetched chunks (at most 100, or 150 with a site filter), not the size of the collection. Snippets are up to 300 characters.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Question is required."} and similar |
Missing or over-length question or query. |
| 405 | {"error": "Method not allowed."} |
The request was not a POST. |
| 429 | {"error": "Rate limit exceeded. Please try again shortly."} |
The per-IP limit was hit. |
| 503 | {"error": "..."} |
Turso unavailable, an OpenAI error, or another runtime failure. The message is the underlying error. |
| 500 | {"error": "An unexpected error occurred. Please try again."} |
Anything else. |
Example
curl -X POST https://example.org/chatbot/api/chat \
-H 'Content-Type: application/json' \
-d '{"question": "Who built the mill?", "site_id": 1, "site_slug": "my-site"}'