Auto-generated REST API
Declare your data model once in backend/collections.toml and the framework mounts a full REST API for it at startup — list, create, read, update, delete, and ?search — with no handwritten endpoint code. The schema is the single source of truth; validation, storage, and search all derive from it.
If backend/collections.toml is absent, nothing changes: the server serves your site exactly as before, with zero collections.
Declaring collections
# backend/collections.toml
[[collection]]
name = "posts"
[[collection.field]]
name = "title"
type = "text"
required = true
[[collection.field]]
name = "body"
type = "text"
embed = true # auto-embedded for semantic search (?search=...)
Field types: text, int, float, bool, relation, file (case-insensitive).
required = true— the field must be present (and non-null) on create.embed = true— (text only) the field is indexed for semantic?searchwhen
an embedding endpoint is configured. Otherwise it is ignored.
collection = "<target>"— required for arelationfield; names the
collection it points at (see Relations below).
Relation fields
A relation field stores the integer id of a record in another collection. It needs a sibling collection key naming the target; omitting it fails akurai serve at startup.
[[collection]]
name = "posts"
[[collection.field]]
name = "author"
type = "relation"
collection = "users" # the target collection (required)
On create/update the referenced id is existence-checked: if the target collection is known and the id does not exist, the write is rejected with 400. On read, pass ?expand= to inline the referenced record.
File fields
A file field holds an uploaded file. Files arrive over multipart/form-data, not in a JSON body — see File uploads below and the Uploads page. The stored value is a small descriptor object:
{ "blob": "<32-char-hex-id>", "filename": "data.bin",
"content_type": "application/octet-stream", "size": 1234 }
In a JSON create/update a file field may be omitted or null (a non-object, non-null value is a 400 validation error). The bytes themselves are fetched from the download endpoint.
The engine owns two reserved keys on every record: id (auto-increment) and created (unix seconds). Your input may not set them.
A malformed collections.toml (TOML syntax error, or an unknown field type) fails akurai serve loudly at startup with the file and line — it never silently drops your routes.
Routes
For a collection named posts:
| Method | Path | Purpose | | ------ | ---- | ------- | | GET | /api/collections | Manifest of every collection and its endpoints | | GET | /api/collections/posts/records | List records, newest first | | GET | /api/collections/posts/records?search=<q>&limit=<n> | Search | | GET | /api/collections/posts/records?expand=<field>[,<field>...] | List with relations inlined | | POST | /api/collections/posts/records | Create (JSON or multipart) | | GET | /api/collections/posts/records/<id> | Fetch one record | | GET | /api/collections/posts/records/<id>?expand=<field> | Fetch with relations inlined | | PATCH | /api/collections/posts/records/<id> | Partial update (JSON or multipart) | | DELETE | /api/collections/posts/records/<id> | Delete | | GET | /api/collections/posts/records/<id>/<field> | Download a file field's bytes |
The collection manifest is also merged into /api/_meta under collections.
Expanding relations
Add ?expand=<field> (comma-separate several) to a GET list or single-record request. For each named relation field, the referenced record is inlined under a sibling key <field>_expanded (the original id key is left untouched). A reference that is absent, dangling, or points at an unknown collection expands to null; names that are not relation fields are ignored. Without ?expand, behavior is unchanged — you get the raw relation id.
curl 'http://localhost:8090/api/collections/posts/records/1?expand=author'
{ "id": 1, "created": 1750000000, "title": "Hello", "author": 7,
"author_expanded": { "id": 7, "created": 1749990000, "name": "Ada" } }
File uploads (multipart)
To set a file field, POST (or PATCH) a multipart/form-data request instead of JSON. Each part whose name matches a file field has its bytes stored in the content-addressed blob store and the field is set to the {blob,filename,content_type,size} descriptor. Parts matching non-file fields are taken as text and coerced to the field's declared type. A request with a JSON Content-Type is handled exactly as before — multipart is opt-in via the header.
curl -X POST http://localhost:8090/api/collections/documents/records \
-F 'title=My Document' \
-F 'attachment=@./data.bin;type=application/octet-stream'
{ "id": 1, "created": 1750000000, "title": "My Document",
"attachment": { "blob": "9f8e...c0", "filename": "data.bin",
"content_type": "application/octet-stream", "size": 1234 } }
Downloading a file
GET /api/collections/<name>/records/<id>/<field> streams the stored bytes back with the descriptor's content_type (default application/octet-stream) and a Content-Disposition naming the stored filename. A missing record, field, or blob is a 404.
curl -OJ http://localhost:8090/api/collections/documents/records/1/attachment
Examples
Create a record:
curl -X POST http://localhost:8090/api/collections/posts/records \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","body":"a wonderful world"}'
{ "id": 1, "created": 1750000000, "title": "Hello", "body": "a wonderful world" }
List records (newest first):
curl http://localhost:8090/api/collections/posts/records
[ { "id": 2, "created": 1750000100, "title": "Other", "body": "..." },
{ "id": 1, "created": 1750000000, "title": "Hello", "body": "..." } ]
Fetch, update, delete:
curl http://localhost:8090/api/collections/posts/records/1
curl -X PATCH http://localhost:8090/api/collections/posts/records/1 \
-H 'Content-Type: application/json' -d '{"title":"Hello again"}'
curl -X DELETE http://localhost:8090/api/collections/posts/records/1
Status codes
201on create,200on list/get/update/search/download,204on delete.400for a bad JSON/multipart body, a malformed id, a validation failure, or
a relation id that references a non-existent record (the body is { "error": "..." }).
404for an unknown collection name, a missing record id, or a download of a
field/record/blob that is not present.
Search: semantic vs substring
?search=<query> works in two modes, transparently:
- Substring (default). With no embedding endpoint configured,
?search
matches the query as a case-sensitive substring over the collection's text fields, newest first. No setup required.
- Semantic. When
AKURAI_EMBED_URLis set, the query is embedded and ranked
by cosine similarity against the stored vectors of each record's embed text fields. Results come back in ranked (most-similar-first) order.
If the embedding endpoint is unreachable or returns an error for a given query, search falls back to substring matching for that request — it never errors out. The response is always a plain JSON array of records in either mode.
Embedding configuration
Three environment variables, all optional:
| Variable | Meaning | Default | | -------- | ------- | ------- | | AKURAI_EMBED_URL | OpenAI-compatible /v1/embeddings endpoint | unset → substring search | | AKURAI_EMBED_MODEL | Embedding model name | embeddinggemma | | AKURAI_EMBED_API_KEY | Bearer token for protected embedding routers | unset |
AKURAI_EMBED_URL=http://127.0.0.1:4219/v1/embeddings \
AKURAI_EMBED_MODEL=intfloat/multilingual-e5-small \
AKURAI_EMBED_API_KEY=akr_... \
akurai serve
Embeddings live in data/embeddings.db (separate from the record store at data/collections.db). They are written best-effort after each successful create/update and removed on delete — a failed embedding never fails the write; the record is always saved, and a warning is printed to stderr.
Plain HTTP only (no TLS)
The built-in embedding client speaks plain HTTP — it has no TLS stack, to keep the framework dependency-free. An https:// endpoint is rejected. Point AKURAI_EMBED_URL at a plain-HTTP embeddings server (e.g. one running on your LAN or behind a local reverse proxy that terminates TLS).