Agentic rails
Build an app next to MOS
MOS exposes a versioned REST API (/api/v1/**)
and an MCP server (/api/v1/mcp) so agents and
external apps can build against a team's data with nothing but a team API
token. MOS stays the system of record; your app is a UX
shell. A token can only ever see its own team's rows — RLS + the token's
team are the tenant boundary.
Coverage matrix
Every surface × REST v1 read/write × MCP tool × token scope × gap. Every Live cell was verified by calling the endpoint with a real team key, not by reading code.
| Surface | REST v1 | MCP tool | Scope | Gap |
|---|---|---|---|---|
| Data tables — list/create | R W GET /api/v1/data/tables | list_tables | tables:read / tables:write | Canonical path (fixed in mos-hq6q4.85; legacy /api/v1/tables/tables still works as an alias). No table DELETE. |
| Data tables — rows | R W GET .../{id}/rows | query_rows, insert_row | tables:read / tables:write / tables:delete | Full row CRUD (G-3 closed, mos-hq6q4.83). PATCH merges data over the row; DELETE is a soft delete (restorable 30 days via PATCH {action:"restore"}). GET accepts ?computed=1 to evaluate formula columns server-side (mos-hq6q4.76.3) — each row gains a computed map keyed by formula column name, raw data untouched. |
| Data tables — columns | R W GET .../{id}/columns | — | tables:read / tables:write / tables:delete | Full column CRUD (G-3 closed, mos-hq6q4.83). Formula expressions are validated against the formula parser at write time (mos-hq6q4.76.4). Soft-FK conflicts return 409 column_in_use + impact list; retry with force. |
| Data tables — formulas | R W via columns + rows | — | tables:read / tables:write | Closed (G-4, mos-hq6q4.90) — formula columns are created/edited through the column endpoints (same write-time validation as the admin UI) and evaluated via GET .../{id}/rows?computed=1. |
| Data tables — views | R W GET .../{id}/views, GET .../{id}/views/{viewId} (with ?rows=true server-side evaluation) | — | tables:read / tables:write / tables:delete | Closed (G-4, mos-hq6q4.90) — full saved-view CRUD over the same service as the admin UI: filters/sorts/visible-hidden columns/column order/conditional formats/favorite/kind, record-review layouts validated at write time (validateLayout), whitelisted fields only. Legacy /api/v1/tables/tables/... alias. No MCP tool. |
| Data tables — row audit | R W GET .../{id}/rows/{rowId}/history | — | tables:read | Closed (G-4, mos-hq6q4.90) — a row's state-field transition history (data_row_state_history, newest first, ?field= narrows); the audit trail behind state-machine fields and row-audit columns. Foreign row/table → clean 404. |
| Knowledge bases | R W GET /api/v1/bases | list_bases | bases:read / bases:write | Read/append only. No block update/delete, no base delete. |
| Forms — schema + submissions | R W GET /api/v1/forms · ✅ POST /api/v1/forms/{slug}/submissions · ✅ GET /api/v1/forms/{slug}/submissions | list_forms | forms:read / forms:write | Submit a response keyed by field id (same lifecycle as the storefront) and list responses via token — mos-hq6q4.91, live 2026-09-06 (21da77332) |
| Review packets | R W GET /api/v1/review/packets · ✅ GET .../{id}/answers (items + verdicts + every reviewer's answer sheet + readiness) | — | review:read / review:write | Closed (G-6, mos-hq6q4.89). The advise-and-consent state is readable via token: the answers endpoint folds in the per-reviewer signoff verdicts on the current walkthrough version (status + verdict + note + readiness derivation), the declared questionnaire items, and each reviewer's saved answer sheet (answeredCount judged by the same isItemAnswered rule as the UI lanes). Team-scoped; module-gated on modules.review (404 when off); malformed id → clean 404. Read-only by design: answers and verdicts are attributed to a reviewer handle and a team token names none, so writes stay on the /desk/<handle> session lanes (the old /todo path redirects) (mirrors the EMR surface's no-write rule, mos-hq6q4.88). Live-verified 2026-09-06: happy path (items + pending signoffs + readiness) on a real cmf.marduk.app packet, unknown/malformed id 404s, no-token 401, and the modules.review gate 404 on teams with review off (app.marduk.app, givemethedirt). No MCP tool. |
| Artifacts | R W GET /api/v1/artifacts, GET /{id} | — | artifacts:read / artifacts:write / artifacts:delete | Full CRUD (the most complete surface). No MCP tool. |
| Workflows | R W ️ GET /api/v1/workflows 500 live | list_workflows (same 500) | workflows:read | Live 500 — schema drift in the query (G-7). No run/trigger via token. |
| Context (RAG) | R W POST /api/v1/context | — | * (deliberate) | Retrieval only. No MCP tool. Cross-resource fan-out (tables + contacts + bases) — no single resource covers it, so the wildcard is documented, not an oversight (mos-hq6q4.81). |
| Wellness | R W GET /api/v1/wellness, .../consent, .../observations | — | wellness:read / wellness:write (phi) | Fully wired (best-covered vertical). No MCP tool. Observations + consent are health data → phi: true. |
| Commerce (Stripe) | R W — | create_invoice, create_quote, create_checkout, create_subscription | commerce:write | REST twins of the four MCP tools, same agentActions service (shared kill switch + audit log) — mos-hq6q4.92, live 2026-09-07 |
| Browser extension | R W GET .../extension/{rules,tools,vault} | — | extension:read/extension:write; vault → vault:read; connect → tokens:write; tools/call → * | Purpose-built for the Ask-Page extension; tools is module-gated (403 when off). Secrets live under their own vault resource — rule-pack access never implies credential decryption. tools/call stays wildcard (dispatch). |
| MCP (unified actions) | R W GET /api/v1/mcp/actions | (is itself MCP) | per-action scope (dynamic) | mos-7kajg.37: every tool entry carries its scope ({resource, action}); tools/call enforces the action's DECLARED scope (undeclared actions need the wildcard), and modules.mcp_export is now the WRITE gate only — read-scope actions ride ordinary token scopes, writes/deletes (and undeclared actions) still need the flag (off by default). Exposes every defineAction. |
| Registry action dispatch | R W GET /api/v1/actions/{name} (per-action schema + scope) | via tools/list | per-action (declared) | mos-7kajg.37: ONE route for every defineAction that declares resource/scopeAction — registering an action exposes it here, in the /api/v1 discovery doc's actions list, and on the MCP servers with no new route file. Reads execute directly; writes route through executeCommand (service tokens execute, human/break-glass propose). No mcp_export requirement — ordinary token scopes govern. |
| Contacts / CRM | R W GET /api/v1/contacts, GET .../{id} | — | contacts:read / contacts:write | Full CRUD via token (G-9 closed, mos-hq6q4.79). First non-wildcard scope consumer. No MCP tool. DELETE is a soft-delete (archive), scoped as contacts:write. Shipped in mos-hq6q4.79 — not yet live-verified against prod (see header). |
| Files / media | R W GET /api/v1/files, GET /{id} (stream / ?download=1 / ?mode=signed) | — | files:read / files:write | Closed (mos-hq6q4.80). List + multipart upload + stream/download/signed-URL + delete, backed by the same media/asset service and storage client as the session routes (identical RLS; private buckets are served via createSignedUrl — getPublicUrl is never minted for them). Upload is team-scope only; uploadedBy is the API key id. No rename/move, no MCP tool. |
| Scheduling | R W GET /api/v1/scheduling, .../event-types (+ /{id}), .../availability, .../bookings (+ /{id}) | — | scheduling:read / scheduling:write | Closed (G-11, mos-hq6q4.84). Event-type catalogue, day-slots + range counts, and full booking create/list/read/cancel — all backed by the same $lib/services/booking engine as the storefront /book flow and admin board, sharing a new $lib/server/booking-lifecycle helper so a token books/cancels with the identical meeting + calendar + email side-effects. Team-scoped via event_types.team_id. No payment gate on the token path (trusted integration, same as /api/admin/scheduling); contact_id never accepted from input. POST accepts "notify": false (and DELETE accepts ?notify=false) to suppress the confirmation/reminder/cancellation emails for an integration handling its own comms. DELETE is a soft cancel, scoped scheduling:write. Agent holds (mos-hq6q4.114): POST .../event-types creates an event type (name, slug (auto-slugified, unique per team → 409), duration_minutes (default 60, ≤ 1440), host_id (team-checked), location_type (remote\ |
| EMR read surfaces | R W GET /api/v1/emr, .../patients (+ /{id}), /{id}/{problems,medications,allergies,labs} | — | emr:read (phi) | Closed (G-12, mos-hq6q4.88). Roster list + chart summary + problem/medication/allergy lists + labs (latest value per LOINC analyte _and_ the report headers), over the same emr_* tables and services as the chart page. Gated on modules.emr.enabled (404 when off) and, for labs, modules.emr.labs.enabled. Opens its own token-team connection (createTeamContext(tokenTeamId)), never locals.db — that one is pinned from the HOST, so a tenant token presented to app.marduk.app would otherwise run under the operator team. (FHIR uses withRLS for the same reason; this surface needs the Sql-shaped handle the EMR services take.) The scope check is re-asserted in every handler — shadow mode must not open a PHI path. Enrolment in emr_patient_roster — not mere contact existence — is the per-patient gate, satisfied by inactive as well as active so a discharged patient's chart stays readable. Writes are deliberately absent, not missing: charting is an attributed clinical act (emr_chart_provenance.accepted_by is NOT NULL) and a team token names no clinician. No MCP tool. FHIR R4 remains the standards-based twin. Live-verified on cmf.marduk.app 2026-09-06: every route against a real 125-patient roster, the module gate 404ing on an EMR-less tenant, and audit_log.phi_accessed = true on every authenticated request — including the 404s, since an attempted chart lookup is a PHI access event whether or not it returns rows. |
| Comms — send | R W — | — | comms:write (phi) | Closed (G-13, mos-hq6q4.87). Consent-gated programmatic send: `{ to (contact id \ |
| Articles / blog | R W GET /api/v1/articles, `GET .../{id\ | ✅ POST, `PATCH .../{id\ | slug}, DELETE .../{id\ | slug}` (soft) |
| Config | R W GET /api/v1/config | — | config:read | Closed (G-15, mos-hq6q4.94). Public-safe team config for adjacent apps: effective module map (every MODULE_KEYS name → boolean, resolved through the real isModuleEnabled defaults so defaults-on/defaults-off with no row still read correctly), merged cross-app terminology + status_labels, and team brand (name/logo/favicon/primary color — the same app.yaml-compiled columns the extension surface carries). Loaded via loadTeamConfigMap behind an explicit suffix allowlist (.terminology / .status_labels) — default deny; secrets, URLs, and every other project_config key are dropped before serialization, proven by a test asserting secret-looking keys (*_api_key, *_secret_key, *_enc, *_token) AND their values never appear in the payload. Deliberately no write handler (config changes route through /admin/settings). Own least-privilege resource (config) so a CRM token doesn't implicitly reveal the module map. No MCP tool. Pinned in src/routes/api/v1/server.test.ts (index reconciliation) + src/routes/api/v1/config/server.test.ts. Not yet live-verified against prod (see header). |
| Events / webhooks | R W GET/POST /api/v1/webhooks, GET /api/v1/events?since=<cursor> (poll) | — (delivery is push; event history is fetched via the poll feed) | webhooks:read / webhooks:write / webhooks:delete | Closed (G-16, mos-hq6q4.78). Token-driven "subscribe to changes" with BOTH sides: a token registers an outbound webhook endpoint into the SAME pipeline the admin UI uses (webhook_endpoints → webhook_deliveries → /api/cron/webhook-deliver, HMAC-SHA256 X-Webhook-Signature, 5-attempt exponential backoff), and an adjacent app that cannot receive inbound HTTP polls GET /api/v1/events?since=<cursor> for the same stream instead — it reads the SAME webhook_deliveries queue the push pipeline consumes, NEWEST-LAST, ≤500/page (default 50), opaque composite (created_at, id) cursor so same-timestamp events never skip or repeat, raw-ISO since as a backfill floor, optional ?endpoint=<uuid> per-endpoint stream, and per-row status echo (pending/success/failed — a failed row's payload is recoverable ONLY here, after push retries exhaust). Secret generated server-side, returned exactly once at creation (never again on list/detail). ?events=1 serves the subscribable event-type catalog (contact.created/updated, invoice.created/paid, quote.accepted, form.submitted, message.sent, asset.checked_out/returned, email.rule_matched). URL validated through the shared assertPublicUrl SSRF guard at create/patch (the delivery worker re-asserts at delivery time). Every query bound to the token's team (the poll feed through the owning endpoint row). No MCP tool (subscriptions and the feed are configuration/data access, not actions). Not yet live-verified against prod (see header). |
R = REST v1 read · W = REST v1 write · badge color: green = verified live,
amber = present but caveated, ghost = absent. Full legend in the quickstart and the
source doc docs/api/agentic-rails.md.
The gaps, ranked
What an external builder cannot do yet, ranked by how much it blocks the "adjacent app" promise. P0 blocks a real build today. Each gap is a tracked task under the launch-readiness epic.
/api/v1/webhooks + /{id} shipped — token-driven subscription CRUD wired to the existing admin delivery pipeline (HMAC-signed POST, backoff retry) — plus GET /api/v1/events?since=<cursor> as the poll fallback for consumers that cannot receive inbound HTTP (same delivery queue, composite cursor, newest-last). The quickstart's "subscribe to changes" third call is now real on both sides.
/api/v1/contacts + /{id} shipped — full CRUD, scoped contacts:read/contacts:write.
/api/v1/files + /{id} shipped — team-scope list, multipart upload (ZIP auto-extracted, jst_files + media dual-write under one id), stream/?download=1/?mode=signed GET, DELETE — all through the same asset service, storage client and RLS as the session routes. Not yet live-verified against prod (see header).
Every v1 resource route now declares its true scope resource — new resources artifacts, bases, review, workflows, wellness, extension, vault join contacts/tables/forms/files/etc. — so least-privilege tokens are mintable (e.g. a forms:read-only token can list forms and gets 403 on tables; pinned by test). The cross-resource dispatch surfaces that legitimately have no single resource: /api/v1 index (discovery doc), /api/v1/context (fan-out read), /api/v1/extension/tools/call keep the wildcard, each with a code comment. The MCP servers (/api/v1/mcp, /api/v1/mcp/actions) and /api/v1/actions/[name] went dynamic in mos-7kajg.37 — RouteAccess.dynamic tells scopeMiddleware to defer, and the route enforces each action's declared resource/scopeAction per-request, so least-privilege tokens work against them (the "per-tool/per-card scoping is future polish" note is now closed for actions; per-card scoping remains open). Wildcard tokens are unaffected and tenant isolation (team_id) is unchanged.
Live 500 (schema drift). The one workflow read surface is broken; list_workflows MCP tool inherits it.
Row PATCH/DELETE + column POST/PATCH/DELETE shipped on /api/v1/data/tables (+ legacy alias), scoped tables:read/tables:write/tables:delete. Formula expressions validated at write time. Soft deletes are restorable.
/api/v1/scheduling/** shipped — event-types (list + detail), availability (day slots + range counts), and bookings (list/create/read/cancel), scoped scheduling:read/scheduling:write. Reuses the booking service via a shared booking-lifecycle helper that the storefront /book + /cancel routes now also call, so every path fires the same meeting/calendar/email side-effects. Not yet live-verified against prod (see header).
Resolved: /api/v1/data/tables (+ /{id}/rows, /{id}/columns) are the canonical, advertised, tested routes; tables/tables remains working as an alias. A reconciliation test (src/routes/api/v1/server.test.ts) fails if the index ever advertises a path with no route behind it.
POST /api/v1/comms/send shipped consent-gated (see the Comms-send row above): to (contact id / email / phone), channel (sms\
/api/v1/emr/** shipped — discovery, patient roster, chart summary, problems, medications, allergies, labs — all emr:read and phi: true. Read-only on purpose (writes stay on the attributed session/agent lanes). Token-team connection, module-gated, roster-gated per patient, and every response body is an explicit field list so an internal column added later cannot silently egress (payload and match_candidates are excluded from lab reports for exactly that reason). Ratchet-tested in src/routes/api/v1/server.test.ts (§G-12) and src/routes/api/v1/emr/emr.test.ts, and live-verified end-to-end on cmf.marduk.app (2026-09-06).
GET /api/v1/review/packets/{id}/answers shipped — the packet's questionnaire items, per-reviewer signoff verdicts on the current round (the sign-off matrix + derived readiness), and every reviewer's saved answer sheet, scoped review:read, module-gated, team-scoped. Read-only by design: verdict/answer writes are attributed acts that need a reviewer identity a token doesn't have — they stay on the /todo/<handle> lanes. Pinned by the index reconciliation + scope tests in src/routes/api/v1/server.test.ts (§G-6) and branch guards beside the route.
Views (GET/POST .../{id}/views, GET/PATCH/DELETE .../{id}/views/{viewId}, ?rows=true server-side evaluation) and row state-history (GET .../{id}/rows/{rowId}/history) shipped on /api/v1/data/tables (+ legacy alias), scoped tables:read/tables:write/tables:delete. Formula columns were already exposed via the column endpoints (write-time validated) + ?computed=1. Record-review layouts validated at write time. Pinned by the index reconciliation + scope tests in src/routes/api/v1/server.test.ts and handler tests beside the routes.
POST + GET /api/v1/forms/{slug}/submissions shipped in mos-hq6q4.91 (21da77332), live on app.marduk.app.
POST /api/v1/commerce/{invoices,quotes,checkout,subscriptions} shipped in mos-hq6q4.92; non-MCP clients can now reach every commerce action.
/api/v1/articles + `/{id\
GET /api/v1/config shipped — public-safe team config read scoped config:read: effective module map, cross-app terminology/status labels, team brand, behind an explicit key-allowlist that can never emit secrets (test-pinned).
Ready to build?
Mint a team token, make your first three calls, and point Claude or Cursor at the MCP server — the quickstart walks you through it in five minutes.
Open the quickstart