Mobile & JSON API
One registry of operations, served to the browser and to mobile clients from the same definitions.
Every capability this app has is declared once, in apps/web/src/api/modules/,
and served from two mounts. The browser calls one; a mobile app or a script
calls the other. No handler is written twice, so an operation cannot exist on
one surface and be missing from the other, and a permission added to one is
added to both.
| Browser | Mobile and scripts | |
|---|---|---|
| Path | /api/v1/{workspace}/{module}/{operation} | /api/mobile/v1/{workspace}/{module}/{operation} |
| Credential | Session cookie | Authorization: Bearer … |
| CSRF check | Yes, on writes | Not needed — see below |
| Response | The bare value, or 204 | Always { ok, … } |
Shape of a call
Reads are GET with query parameters. Writes are POST with a JSON body. There
is no PATCH or DELETE: an operation is named by what it does, so the id
travels in the body rather than in a path segment.
curl https://your-host/api/mobile/v1/acme/companies/list \
-H "Authorization: Bearer sk_live_…"{ "ok": true, "data": [{ "id": "…", "name": "Jayam Housing" }] }curl -X POST https://your-host/api/mobile/v1/acme/notes/add \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"resourceType":"company","resourceId":"…","body":"Chased the renewal."}'Every response has the same shape
{ "ok": true, "data": … }
{ "ok": false, "error": { "code": "forbidden", "message": "Your role cannot do that." } }One shape means one decoder. The browser API returns the bare value and a 204
when there is nothing to say, which is fine for TypeScript talking to itself and
poor for a typed client in another language — two response shapes are two
decoders, and an empty body is a third case. On this mount an operation with
nothing to return sends data: null.
Branch on code, never on message. Messages are written for people and get
reworded; a code is a contract.
code | Status | Meaning |
|---|---|---|
unauthenticated | 401 | No credential, or it is no longer valid |
forbidden | 403 | Authenticated, but the role lacks the operation's permission |
not_found | 404 | No such workspace — or one you are not a member of |
unknown_module | 404 | The module segment is not a module |
unknown_operation | 404 | The module has no such operation for this method |
invalid_request | 400 | The body failed validation; the message names the field |
internal | 500 | Something went wrong that is not yours to fix |
A 404 for a workspace you cannot reach is deliberate and not a mistake: a probe
must not be able to learn that a workspace exists but is not yours.
Authenticating
Two kinds of bearer credential, and neither is the session cookie. A cookie rides along on any request a browser makes to this origin, so accepting one here would let any page on the internet drive this API as whoever is signed in. A bearer token has to be supplied deliberately — which is also why this mount needs no CSRF check and the browser's does.
- API keys — Settings → Connect to Agents. For a client someone configures by
hand. A key names its own workspace, so the
{workspace}segment must match it or the call is refused. Only a hash is stored and the token is shown once. - OAuth — for a client that cannot be handed a secret, like an app shipped to a store. The grant is to a person, never to a workspace, so the segment selects one and membership authorizes it.
Either way the credential ends at the same membership join the web app uses. A token carries its owner's access and cannot exceed it: demote them and the next call is narrower; remove them and it stops working.
Discovering what a build can do
Unauthenticated on purpose
The manifest names capabilities, not data. A client has to be able to tell "my token expired" from "this server is too old for that feature", and it cannot do that if discovery needs a token.
curl https://your-host/api/mobile/v1/manifest{
"ok": true,
"data": {
"api": "v1",
"app": { "version": "0.1.28", "commit": "a1f41f6", "dev": false },
"modules": [
{
"name": "notes",
"operations": [
{ "name": "add", "method": "POST", "path": "notes/add", "permission": "record:write", "takesBody": true },
{ "name": "list", "method": "GET", "path": "notes/list", "permission": "record:read", "takesBody": false }
]
}
]
}
}It is generated from the registry rather than maintained by hand, so it cannot describe an endpoint that does not exist or miss one that does.
The endpoint reference
Every operation, with its parameters, request body and responses, is under
Reference — one page per endpoint, generated from the
same registry. The bodies there are the actual zod schemas the server validates
with, so a uuid or a minimum length in the reference is a constraint the API
will really enforce.
This page is the part that does not fit an endpoint list: what the envelope is, how to authenticate, and why the version is in the path.
Versioning
v1 is in the path because a mobile build stays on someone's phone for months.
Every other client here updates when the server does; that one does not, so the
surface it was compiled against has to keep answering. A breaking change gets a
new segment rather than a flag.
Permissions
Each operation declares the permission it needs, and both mounts read the same declaration — so neither can be more permissive than the other.
| owner | admin | member | viewer | |
|---|---|---|---|---|
record:read | ✅ | ✅ | ✅ | ✅ |
record:write | ✅ | ✅ | ✅ | — |
record:delete | ✅ | ✅ | ✅ | — |
assistant:use | ✅ | ✅ | ✅ | — |
schema:write | ✅ | ✅ | — | — |
member:manage | ✅ | ✅ | — | — |
workspace:manage | ✅ | — | — | — |
workspace:manage is the only difference between an owner and an admin, and it
gates every stored credential.