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).

an embedding endpoint is configured. Otherwise it is ignored.

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

a relation id that references a non-existent record (the body is { "error": "..." }).

field/record/blob that is not present.

Search: semantic vs substring

?search=<query> works in two modes, transparently:

matches the query as a case-sensitive substring over the collection's text fields, newest first. No setup required.

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).