This is an API you can point existing tools at. It stores your data, answers questions about it over plain URLs, and can suggest what to do next. This page is everything you need to build against it.
Six terms come up constantly. They are defined here so nothing below assumes you already know them.
| Term | What it means |
|---|---|
| the board | the engine that stores your data and answers questions about it. You never talk to it directly. |
| the gate | the HTTP server in front of the board — the thing you actually call. Everything on this page is the gate. |
| a world | one dataset with its own rules. A shop, a clinic, a school timetable. Your data lives in a world. |
| a lens | a single HTML file that displays a world. Changing the lens changes what you see, never the data underneath. |
| an adapter | a translator that makes the gate speak a protocol you already have a client for — S3, PostgREST, Meilisearch, Redis. You keep your existing client and change the URL. |
| a signal | one plain fact about right now, as a short string: cheese_in_basket, friday. You send signals; you get back suggested actions. |
Every route this server offers is listed in an OpenAPI document that the server generates about itself. It cannot go out of date, because it is not written by hand. Start here:
curl -s $GATE/openapi.json | jq '.paths | keys'
If a path appears there, it works. You can generate a client from that file with the usual
tools. Its servers entry is the public address you should call, which on a hosted
box is not the port the process is listening on internally — so use what the document says,
not what the logs say.
| Route | What it does |
|---|---|
POST /api/observe | send signals, get suggested actions back as a stream |
/api/{collection} | your data: list, create, read, update, replace, delete |
/api/verb · /api/witness | run an action, and read the tamper-evident log of every action run |
/v1/chat/completions | the same suggestions through an OpenAI-compatible endpoint, so an existing chat client works |
/s3/{bucket}/{key} | file storage that speaks S3 — use any S3 client |
/lenses/… · /x/… | published lenses, and any adapters this box declares |
/health | what this box has switched on, and what is sealed |
Deleting hides a record; it never destroys it. A delete writes a
“this is gone now” marker instead of removing the row, so the old values are still
there. That is why you can always ask what a record said last week — see
/remember below.
This is the part that suggests what to do next. You send a few short facts about the current
situation; you get back suggested actions. The reply is a stream
(text/event-stream), so results arrive as they are worked out rather than all at
the end.
In the example below: someone has cheese in their basket, it is Friday evening, and your page
has one slot named hero you are willing to fill.
curl -N -X POST $GATE/api/observe \
-H 'content-type: application/json' \
-d '{"signals":["cheese_in_basket","friday","evening_slot"],"slots":["hero"],"hour":19}'
Five events arrive, always in this order:
address — an id for this exact combination of signals. The same signals always give the same address.peaks — the six situations that best match what you sent, strongest first.pairings — items that tend to go together here, worked out from the data rather than configured.directives (what to do) or probe (it does not know, and here is its best guess) — never both.done — the stream ends here.The mistake almost everyone makes: writing a client that only
listens for directives. You will get probe instead whenever the system
does not recognise the situation, and your page will silently show nothing.
Handle both.
Why there are two: it only gives you a confident answer when it recognises the whole situation.
If a known pattern needs four signals and you sent three, it will not fill in the fourth and
pretend. It sends a probe instead — a labelled guess you may accept or ignore.
Being told “I am not sure, but possibly this” is more useful than a confident wrong
answer.
Two smaller rules:
slots lists the places
on your page you are willing to fill. It will never suggest filling one you did not list.hour is the visitor's local hour, 0–23, not your
server's. Send theirs, or evening advice arrives at breakfast.You query by building a URL. There is no query language to learn and no query parameters — filters, sorting and paging are path segments, read left to right in the order you wrote them. Any query is therefore a link you can bookmark, cache or send to someone.
About sixty of these words exist. The complete list lives next to the code at
packages/baas-data/GRAMMAR.md.
/api/orders/where/status/eq/paid/order-by/total/desc/limit/10
/api/orders/include/customer/match/customer_id
/api/orders/count
/api/products/tags
/api/products/neighbours/ceramics,handmade
| Group | Words |
|---|---|
| Recall and naming | /identifier /hash /version /remember /exists /meta /seed |
| The select | /where /or /and /order-by /skip /limit |
| Arithmetic | /count /any /sum /avg |
| Shape | /tags /neighbours /include…/match /union /i18 /convert |
| Wave and walk | /superpose /merge /next /previous |
Four rules decide how a URL is read. They matter when a query does something you did not expect:
/include without a following
/match is ignored rather than guessed at./where/status/eq/count, count is the value you are filtering for,
not the count operator./count you have a
number, and nothing can follow it./remember returns every previous version of a record, oldest first
— the audit trail, free, because updates add versions rather than overwrite.
An adapter makes this server answer as though it were a service you already use, so you keep
your existing client library and change only the URL and the key. An adapter with no key
configured is sealed: it refuses everything and says so, rather than half
working. /health lists which are open on any box:
curl -s $GATE/health
| Adapter | Speaks | Configure with |
|---|---|---|
| storage | S3, SigV4 | S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
| supabase | PostgREST | SUPABASE_KEYS, SUPABASE_SERVICE_KEYS |
| meilisearch | Meilisearch | MEILI_KEYS, MEILI_ADMIN_KEYS |
| redis | RESP | REDIS_PORT, REDIS_PASSWORD |
| panel | a judgement port | PANEL_URL, PANEL_KEY, PANEL_TOKENS |
| identity / oauth / entra | sign-in | AUTH_JWT_SECRET, OAUTH_*, ENTRA_* |
Each also takes an optional *_PREFIX if you want it on a different path. Point
your existing S3 or PostgREST client at the gate with the matching key and it should work
unchanged.
The panel is optional. It forwards a question to several
language models, then asks one more to weigh their answers and decide. You supply the models:
PANEL_URL is where they live, PANEL_DELIBERATORS names the ones that
answer, PANEL_CHAIR the one that decides. Without PANEL_URL it stays
sealed.
You do not need this repository. An adapter is four facts, so it is four environment variables — declare one and the gate mounts it on the next deploy.
CONNECTOR_SHEETS_URL=https://api.example.com/v4 # declares it. Required.
CONNECTOR_SHEETS_KEYS=k1,k2 # who may call. Default: GATE_TOKENS
CONNECTOR_SHEETS_UPSTREAM_KEY=… # injected. Never the caller's.
CONNECTOR_SHEETS_PREFIX=/x/sheets # default /x/<name>
CONNECTOR_SHEETS_HEADER=x-api-key # optional: header instead of Bearer
Then GET /x lists every declaration — including the ones that did not
load, and why. A connector that silently failed to load is exactly what that list
exists to prevent.
Two credentials that never meet. You prove yourself to the gate
with a key the gate knows. The gate proves itself to your upstream with a credential you never
see. Your Authorization header is dropped, not forwarded.
Private upstreams are refused by default. Loopback, link-local,
RFC1918 and *.internal addresses do not load — a gate that would proxy to
169.254.169.254 is a credential leak with a REST interface. If you mean it, opt that
connector in by name with CONNECTOR_<NAME>_ALLOW_PRIVATE=1.
A lens is one HTML file that displays a world. Anyone can read every published lens; the two shelves differ only in who is allowed to put something on them.
| Shelf | Who can publish | Who can read |
|---|---|---|
verified | the owner of this system only, using a cryptographic signature | anyone |
community | anyone holding a key | anyone |
curl -s $GATE/lenses/index.json
curl -X PUT $GATE/lenses/community/my-lens \
-H "authorization: Bearer $KEY" \
--data-binary @my-lens.html
Every entry in the listing says which shelf it came from, so a page cannot show a community lens with a verified badge. No key of any kind can publish to the verified shelf — it takes a signature from a private key that is not issued to anyone.
| Key | Opens | How you get it |
|---|---|---|
| an invite key the one you probably want | building worlds, the lens shelf, /v1/*, publishing a community lens | apply, or paste one you were given into Gain access |
| an adapter key | one adapter only — the S3 key opens S3 and nothing else | set by whoever runs the box |
| a short-lived grant | a single write, for a few minutes | issued automatically; you rarely handle one |
| the owner's signature | the verified shelf, the operator tools | not issued to anyone. It is a private key held on one machine. |
Send an invite key as a bearer token:
curl -s $GATE/v1/models -H "authorization: Bearer $KEY"
A box with no keys configured is sealed: you can read, every
write returns 401, and /health says so plainly. That is a deliberate setting, not a
broken box — a public demo is meant to be sealed.
The mimic is a testing tool. It records the real API calls your app already makes, then replays them — so you can point the app at this gate instead of its real backend and check nothing breaks. If your app cannot tell the difference, the replacement is faithful.
mimic --verify # replay every captured call, byte for byte
mimic --listen 8900 # serve it, so a client can be pointed here
--verify replays every recorded call and compares the response byte for byte. Any
difference fails. Endpoints that generate something new each time are excluded from that
comparison, because replaying a recorded suggestion would only ever repeat an old one.
With gidget: the extension watches a real app, records what it asks for, and the mimic serves that back. Download it top right, unzip it, and load it unpacked in your browser’s extension page. The button only appears on a box that carries the navigator.
The mimic is a command you run yourself, not an endpoint. It is included in the container, but no URL exposes it.
Everything above can be checked against the running server. Start with
$GATE/openapi.json and
$GATE/health — and if they disagree with this page,
the server is right and this page is out of date.