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 *). Every v1
resource route declares its true resource (G-1 closed, mos-hq6q4.81), so a
least-privilege token works — e.g. forms:read can list forms but gets 403 on
tables. Scope resources include contacts, tables, forms, files,
artifacts, bases, review, workflows, wellness, extension, vault,
comms, emr, webhooks, audit, tokens, *; actions are
read / write / delete / admin. The MCP dispatch surface still requires
the wildcard — see agentic-rails.md §G-1.
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/data/tables \
-H "Authorization: Bearer $MOS_TOKEN"
# → { "tables": [ { "id": "...", "name": "...", "display_name": "..." }, ... ] }
/api/v1/data/tablesis the canonical path (what the/api/v1index advertises). The older/api/v1/tables/tablesspelling still works as an alias — both run the same handlers.
b) Create a row
curl -s -X POST https://acme.marduk.app/api/v1/data/tables/$TABLE_ID/rows \
-H "Authorization: Bearer $MOS_TOKEN" -H "Content-Type: application/json" \
-d '{ "data": { "name": "Widget", "qty": 3 } }'
c) Subscribe to changes — register a webhook endpoint and MOS pushes domain events to you (G-16, closed by mos-hq6q4.78):
curl -s -X POST https://acme.marduk.app/api/v1/webhooks \
-H "Authorization: Bearer $MOS_TOKEN" -H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.example/hooks/mos", "events": ["contact.created", "invoice.paid"] }'
# → 201 { "id": "…", "url": "…", "events": ["…"], "secret": "…" } ← secret shown ONCE
Every matching event is POSTed to your URL, HMAC-SHA256-signed so you can
verify it: X-Webhook-Signature: sha256=<hex digest of the raw body using your secret>. Failed deliveries retry with exponential backoff (1 → 2 → 4 → 8 →
16 min) before giving up. Manage your subscriptions and inspect the last 25
deliveries per endpoint through the same surface:
curl -s https://acme.marduk.app/api/v1/webhooks -H "Authorization: Bearer $MOS_TOKEN" # list + delivery stats
curl -s https://acme.marduk.app/api/v1/webhooks?events=1 -H "Authorization: Bearer $MOS_TOKEN" # subscribable event types
curl -s https://acme.marduk.app/api/v1/webhooks/$ID -H "Authorization: Bearer $MOS_TOKEN" # detail + recent deliveries
Requires the webhooks:read / webhooks:write (and webhooks:delete to
remove) scopes — a wildcard token has them all. Polling is still fine for
low-volume use: GET .../rows?sort=updated_at:desc, tracking the newest
updated_at you've seen.
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.
For a person connecting ChatGPT, Claude.ai, Claude Code, Codex or pi, prefer
the OAuth door over a pasted token: the same URL, discovered via
/.well-known/oauth-protected-resource, with a scoped, expiring, revocable
consent grant. See agentic-rails § "Connecting an outside agent"
and the per-harness guides in docs/agent-door/.
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,
…) is in agentic-rails.md; each is a
tracked task under mos-hq6q4.
Last verified: 2026-09-06 (task mos-hq6q4.78 — G-16 webhook subscriptions live).