MOS API — external builder quickstart
Build a "vibe app" that sits adjacent to MOS: your app holds a MOS team
token and calls the API; MOS is the system of record, your app is a UX shell.
You never touch the database — the token's team_id + RLS are the tenant
boundary, so your app can only ever see the one team it was issued for.
The full surface map (what's exposed, what's a gap) is in
agentic-rails.md. This is the 5-minute on-ramp.
1. Mint a token
UI: sign in to your team app (https://<your-team>.marduk.app) → Admin →
Settings → API Keys (/admin/settings/api-keys) → New token. Copy the
jst_… value once — it is shown only at creation and stored hashed.
Token types
| Type | Expiry | Trust (on /api/v1/mcp/actions) |
Use for |
|---|---|---|---|
service |
none (rotate manually) | trusted — mutations execute immediately | server-to-server integrations, your adjacent app's backend |
human |
1h access + 30d refresh | untrusted — mutations queue for human approval | a user acting through your app |
break_glass |
4h | untrusted | audited emergency access |
Scopes. A token carries a JSONB scope list ([{resource, actions[]}]).
Leaving it empty grants full team access (the wildcard *). Today most v1
routes require the wildcard, so least-privilege tokens are limited — see
agentic-rails.md §G-1. Scope resources include
contacts, forms, files, comms, emr, audit, tokens, *; actions are
read / write / delete / admin.
Every request is Authorization: Bearer jst_….
2. The first three calls
Base URL is your team host, e.g. https://acme.marduk.app. (The versioned API is
served from every team subdomain.)
a) List tables
curl -s https://acme.marduk.app/api/v1/tables/tables \
-H "Authorization: Bearer $MOS_TOKEN"
# → { "tables": [ { "id": "...", "name": "...", "display_name": "..." }, ... ] }
⚠️ The route is
tables/tables(notdata/tables, which the/api/v1index currently mis-advertises — G-2).
b) Create a row
curl -s -X POST https://acme.marduk.app/api/v1/tables/tables/$TABLE_ID/rows \
-H "Authorization: Bearer $MOS_TOKEN" -H "Content-Type: application/json" \
-d '{ "data": { "name": "Widget", "qty": 3 } }'
c) "Subscribe to changes" — ⚠️ not yet available. There is no
token-facing events/webhook stream (G-16). Until it lands,
poll: GET .../rows?sort=updated_at:desc (via MCP query_rows) on an
interval, tracking the newest updated_at you've seen. A real event stream is
the top-ranked gap.
Discover the whole surface at runtime:
curl -s https://acme.marduk.app/api/.well-known/openapi.json # OpenAPI 3.1, no auth
curl -s https://acme.marduk.app/api/v1 -H "Authorization: Bearer $MOS_TOKEN"
3. MCP config (Claude / Cursor / any MCP client)
MOS speaks MCP over plain HTTP (JSON-RPC 2.0) at /api/v1/mcp — 10 tools:
list_tables, query_rows, insert_row, list_forms, list_bases,
list_workflows, create_invoice, create_quote, create_checkout,
create_subscription.
{
"mcpServers": {
"mos": {
"type": "http",
"url": "https://acme.marduk.app/api/v1/mcp",
"headers": { "Authorization": "Bearer jst_YOUR_TOKEN_HERE" }
}
}
}
The #1 gotcha: an empty token → 401 {code:-32000}. If you template the
token from an env var ("Bearer ${MOS_MCP_TOKEN}") and the var is unset, the
client sends Bearer with no value and every call 401s. The server now tells
you exactly which case you hit ("empty Bearer token…", "not a MOS team API
key…", "invalid, revoked, or expired"). Sanity-check with curl before wiring a
client:
curl -s -X POST https://acme.marduk.app/api/v1/mcp \
-H "Authorization: Bearer $MOS_MCP_TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# → {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"jst-client-api",...}}}
The URL must include /api/v1/mcp. A bare host (https://acme.marduk.app)
gives the client's own ENDPOINT_NOT_FOUND.
There is a second, richer MCP server at /api/v1/mcp/actions that exposes every
MOS action — but it is off by default (per-tenant modules.mcp_export) and
returns 403 until enabled. See agentic-rails.md.
4. Rate limits
Per token, sliding window, fail-open (a flaky limiter never takes the API down):
| Token type | Limit |
|---|---|
service |
10 000 / min |
human, break_glass |
1 000 / min |
Over the limit → 429 with a Retry-After header (seconds). Back off and retry.
5. What "adjacent app" means
- Your app holds the token. Store it server-side; never ship a service token
to a browser. For end-user flows, mint short-lived
humantokens. - MOS is the system of record. Don't mirror-and-diverge; read and write through the API so RLS, audit logging, and approval gates stay authoritative.
- The team boundary is enforced for you. A token is bound to one
team_id; every query is scoped to it. You cannot read another team's data even by id. - Mutations may be gated. On the actions MCP surface,
human/break_glasstokens propose changes for human review rather than applying them — design your UX to show "pending approval" states.
Questions or a gap that blocks you? The ranked gap list (contacts, files,
events/webhooks, …) is in agentic-rails.md; each is a
tracked task under mos-hq6q4.
Last verified: 2026-09-04 (task mos-hq6q4.76).
