HTTP API reference
Every route is registered under the /api prefix (createServer mounts aiRoutesPlugin
with prefix: '/api'). Request bodies and responses are validated against Zod schemas
derived from your providers; validation failures return the structured error
described at the end.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET |
/api/health |
Liveness check. |
GET |
/api/client/schema |
The client manifest — every provider’s schemas and metadata. |
POST |
/api/providers/:providerId/actions/:actionId |
Invoke an action. |
POST |
/api/providers/:providerId/entities/:entityId |
Search an entity. |
GET |
/api/providers/:providerId/entities/:entityId/:recordId |
Fetch one record by id. |
GET |
/api/buckets |
List the exposed buckets. |
GET |
/api/buckets/:bucketId |
List objects in a bucket. |
GET |
/api/buckets/:bucketId/* |
Read an object’s bytes. |
HEAD |
/api/buckets/:bucketId/* |
An object’s size, content type, and metadata. |
PUT |
/api/buckets/:bucketId/* |
Write an object. |
DELETE |
/api/buckets/:bucketId/* |
Delete an object. |
POST |
/api/nlp/classify |
Match a phrase against declared intents. |
POST |
/api/nlp/execute |
Run an intent returned by classify. |
POST |
/api/eval |
Run a script in the sandbox. |
GET |
/api/eval/actions |
List actions callable from scripts. |
GET |
/api/eval/actions/:functionName |
One action’s detail and typings. |
GET |
/api/docs |
Interactive Scalar/OpenAPI reference. |
Provider routes exist only for providers registered on the host; the action, entity, and
record ids are those the provider defines. Bucket routes exist only for buckets a provider
registered and marked exposed — see
Store blobs in a bucket.
GET /api/health
Section titled “GET /api/health”Liveness check. No parameters, no body.
Response 200
{ "status": "ok" }GET /api/client/schema
Section titled “GET /api/client/schema”The client manifest — the host’s machine-readable self-description. For every registered
provider it lists the actions, entities, and events, each with metadata and JSON Schemas, and
each action’s allocated functionName. This is the source
@grundlag/client compiles into a typed
client and a runtime tool set.
Request body — none.
Response 200
{ "providers": { "notes": { "actions": { "create": { "id": "create", "name": "Create note", "functionName": "notesCreate", "input": { "type": "object", "properties": { "title": { "type": "string" } } }, "output": { "type": "object", "properties": { "id": { "type": "string" } } } } }, "entities": {}, "events": {} } }}Each entity entry carries searchInput and data schemas; each event entry carries payload
and filter schemas.
POST /api/providers/:providerId/actions/:actionId
Section titled “POST /api/providers/:providerId/actions/:actionId”Invoke a provider action.
Path params
| Param | Description |
|---|---|
providerId |
Registered provider id. |
actionId |
Action id within that provider. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
input |
the action’s input schema |
yes | The action input. |
resumption |
{ data, state? } |
no | Answer to a prior interrupt; state is the checkpoint the interrupt carried. |
items |
array of item snapshots | no | The caller’s current conversation items, seeding the action’s latest lookups. |
Response 200 — an envelope covering both outcomes:
| Field | Type | Description |
|---|---|---|
status |
"completed" | "interrupted" |
Whether the action finished or paused for a decision. |
result |
the action’s output schema |
Present when completed. |
message |
string |
Present when interrupted — the interrupt’s message. |
data |
unknown |
Present when interrupted — what the caller must decide on. |
state |
unknown |
Present when interrupted — opaque checkpoint; pass back as resumption.state. |
items |
array of item snapshots | The items the call emitted. |
An interrupt is returned as a normal 200 with status: "interrupted" — it is not an error
status.
Errors — 400 validation error if the body does not match the request
schema; the action’s execute never runs. A 500 serialization error if the
action returns a value that does not match its output schema.
POST /api/providers/:providerId/entities/:entityId
Section titled “POST /api/providers/:providerId/entities/:entityId”Search an entity.
Path params
| Param | Description |
|---|---|
providerId |
Registered provider id. |
entityId |
Entity id within that provider. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
input |
entity’s searchInput schema |
yes | The search criteria. |
cursor |
string |
no | Opaque pagination cursor from a prior response. |
limit |
number |
no | Maximum results to return. |
Response 200
| Field | Type | Description |
|---|---|---|
results |
array of the entity’s data schema |
The matching records. |
nextCursor |
string (optional) |
Pass back as cursor for the next page; absent when there are no more pages. |
Errors — 400 validation error if the body does not match the schema.
GET /api/providers/:providerId/entities/:entityId/:recordId
Section titled “GET /api/providers/:providerId/entities/:entityId/:recordId”Fetch a single record by id.
Path params
| Param | Description |
|---|---|
providerId |
Registered provider id. |
entityId |
Entity id within that provider. |
recordId |
The record’s id. |
Request body — none.
Response 200 — one record, shaped by the entity’s data schema.
GET /api/buckets
Section titled “GET /api/buckets”List the buckets exposed over HTTP.
Request body — none.
Response 200
{ "buckets": [{ "id": "reports" }] }GET /api/buckets/:bucketId
Section titled “GET /api/buckets/:bucketId”List the objects in a bucket, ordered by key.
Path params
| Param | Description |
|---|---|
bucketId |
Exposed bucket id. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
prefix |
string |
no | Only keys starting with this prefix. |
Response 200
{ "objects": [ { "key": "monthly/june.md", "size": 6, "contentType": "text/markdown", "metadata": { "author": "morten" }, "updatedAt": "2026-06-30T12:00:00.000Z" } ]}GET /api/buckets/:bucketId/*
Section titled “GET /api/buckets/:bucketId/*”Read an object’s bytes. The key is the whole trailing path, so
/api/buckets/reports/monthly/june.md addresses the key monthly/june.md.
Response 200 — the raw bytes, with Content-Type set to the object’s content type
(application/octet-stream when it has none) and Last-Modified to its write time.
Errors — 404 when no object is stored under the key; 400 when the key is malformed
(see key rules).
HEAD /api/buckets/:bucketId/*
Section titled “HEAD /api/buckets/:bucketId/*”An object’s metadata, without its body.
Response 200 — no body. Content-Length is the object’s size, Content-Type its
content type, Last-Modified its write time, and each metadata entry is returned as
x-bucket-meta-<name>.
Errors — 404 when the object does not exist.
PUT /api/buckets/:bucketId/*
Section titled “PUT /api/buckets/:bucketId/*”Write an object, replacing anything already stored under the key.
Request body — the bytes to store, whatever the content type. Bodies are capped at 32 MiB.
Request headers
| Header | Description |
|---|---|
Content-Type |
Becomes the object’s content type. |
x-bucket-meta-<name> |
Becomes the metadata entry <name>. |
Both are replaced wholesale by each write: a write that sends no metadata clears what the previous write stored.
Response 200 — the resulting object, same shape as one entry of the listing above.
Errors — 400 when the key is malformed.
DELETE /api/buckets/:bucketId/*
Section titled “DELETE /api/buckets/:bucketId/*”Delete an object.
Response 204 — no body. Idempotent: deleting a key that holds nothing succeeds.
Errors — 400 when the key is malformed.
POST /api/nlp/classify
Section titled “POST /api/nlp/classify”Match a phrase against the utterances every registered provider declared — the cheap first pass, so an unambiguous request need not reach a model. See Match spoken phrases for declaring them.
The corpus is trained on first use from the providers’ live slot values and rebuilt whenever a slot reports a change, so this reflects the current state of the system rather than what was true at boot.
Request body
{ "text": "turn off the kitchen light", "minScore": 0.9 }minScore is optional and overrides the host’s own floor for this call — lower it to inspect
weak matches, raise it to be stricter.
Response 200
{ "match": { "score": 0.99, "text": "turn off the kitchen light", "intent": { "id": "5f6d…", "name": "Turn lights off", "providerId": "home-assistant", "entities": { "a1b2…": [{ "value": { "id": "room-1", "name": "Kitchen" }, "text": "the kitchen", "accuracy": 1 }] } } }}match is null whenever nothing cleared the floor — the ordinary case, and not an error. A
caller should read it as “ask the model”.
The split is deliberate. score and text describe how the match was arrived at; intent
is the executable part, and it is exactly what
/api/nlp/execute takes back. Deciding whether a match is strong enough
happens once, here.
score is not calibrated, and a threshold does not transfer between hosts: unrelated input
scores around 0.5 against a corpus of two slot-bearing intents but above 0.9 once a short
slot-free intent joins it. Probe your own corpus with phrases that should be rejected before
settling on a floor.
id is the matched intent’s generated id and name its display name. Ids are regenerated each
time the host starts, so persist name rather than id if a record has to outlive the process.
entities is keyed by slot id, and every key holds an array — one phrase can fill a single
slot several times over (“the kitchen and the hallway lights”). Each entry’s value is the
provider’s own object for the match, not the words that matched it; text is those words. A
slot the phrase left unfilled is absent.
Errors — 400 when text is empty.
POST /api/nlp/execute
Section titled “POST /api/nlp/execute”Run an intent that /api/nlp/classify returned.
Request body — the match.intent from a classify response, unchanged:
{ "intent": { "id": "5f6d…", "name": "Turn lights off", "providerId": "home-assistant", "entities": { "a1b2…": [{ "value": { "id": "room-1", "name": "Kitchen" }, "text": "the kitchen", "accuracy": 1 }] } }}No score is asked for, and sending one changes nothing. Whether the match was good enough is settled against the score classify returned, before calling this.
Response 200
{ "status": "completed" }An intent that raises an interrupt answers { "status": "interrupted", "message": "…" } with a
200, because needing a human is an outcome rather than a failure. Unlike an action, there is
no resumption channel — the intent cannot be continued once answered.
Errors — 404 when the intent id names nothing, which is what a match held across a
restart looks like, since ids are minted per process. 400 when the intent is malformed.
Anything the intent itself throws surfaces on its own terms — an action reached through it
answers 400 with the offending fields if the mapping does not fit its input schema.
POST /api/eval
Section titled “POST /api/eval”Run a TypeScript script in a sandbox built from every registered provider’s actions.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
script |
string |
yes | The TypeScript source to run. |
data |
object (record of unknown) |
yes | Initial data made available to the script. |
callLog |
array of call-log entries (each nullable) | no | A call log from a prior interrupted run, to replay deterministically. |
resumption |
{ position, data, state? } |
no | The answer to a prior interrupted run. position and state come from that run’s interrupt object; data is the answer handed to the paused call. Pass it together with the prior callLog. |
Call-log entries may be null in a resumed log: an interrupted call reserves its position
without recording while concurrent calls complete behind it.
Response 200
| Field | Type | Description |
|---|---|---|
status |
"completed" | "interrupted" |
"completed" if the script finished; "interrupted" if an action paused the run. |
data |
object (record of unknown) |
The script’s resulting data. |
callLog |
array of call-log entries (each nullable) | The recorded call log; pass it back to resume. |
console |
array of console entries | Everything the script logged. |
interrupt |
{ position, data?, state? } (optional) |
Present only when status is "interrupted". position is the paused call’s slot in the call log; data is the action’s caller-facing payload (what to decide on); state is its opaque checkpoint. Pass all three back under resumption to resume. |
An interrupt is returned as a normal 200 with status: "interrupted" — it is not an error
status. To resume, call again with the same script, the returned callLog, and a
resumption built from the interrupt object (position, state) plus your answer (data).
GET /api/eval/actions
Section titled “GET /api/eval/actions”List the actions callable from eval scripts.
Request body — none.
Response 200 — array of:
| Field | Type | Description |
|---|---|---|
functionName |
string |
The name the script calls the action by. |
name |
string |
Human-readable action name. |
description |
string (optional) |
Action description. |
GET /api/eval/actions/:functionName
Section titled “GET /api/eval/actions/:functionName”Detail for one callable action, including generated TypeScript typings.
Path params
| Param | Description |
|---|---|
functionName |
The action’s function name (from the list route). |
Response 200
| Field | Type | Description |
|---|---|---|
functionName |
string |
The name the script calls the action by. |
name |
string |
Human-readable action name. |
description |
string (optional) |
Action description. |
typings |
string |
Generated TypeScript declaration for the function’s input and output. |
Errors — 500 if no action matches functionName.
GET /api/docs
Section titled “GET /api/docs”Interactive Scalar/OpenAPI reference for the host, served in the browser. The OpenAPI document is generated from the same Zod schemas that validate the routes above.
Error shape {#error-shape}
Section titled “Error shape {#error-shape}”Request-validation and response-serialization failures return a structured JSON body rather than an opaque payload.
Request validation — 400
{ "error": "Validation Error", "message": "Request does not match the expected schema — `title`: Required", "statusCode": 400, "details": { "location": "request", "method": "POST", "url": "/api/providers/notes/actions/create", "issues": [{ "path": "title", "message": "Required", "code": "invalid_type" }] }}Response serialization — 500 — same shape with error: "Response Serialization Error",
statusCode: 500, and details.location: "response". This means the server produced a value
that did not match its declared response schema.
issues entries
| Field | Type | Description |
|---|---|---|
path |
string |
Dotted path to the offending field; (root) for the top level. |
message |
string |
Why it failed. |
code |
string |
The validation issue code. |
expected |
unknown (optional) |
Expected value or type, when applicable. |
received |
unknown (optional) |
Received value or type, when applicable. |
Two storage failures carry their own shape, since neither is the server’s fault: a missing
object returns 404 with error: "Not Found" and details: { bucket, key }, and a
malformed object key returns 400 with error: "Invalid Key" and
details: { key, reason }.
Other errors that are not schema failures propagate as thrown errors and surface as a
standard 500.