Skip to content

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.

25
live surfaces
10
MCP tools
16
tracked gaps

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.

SurfaceREST v1MCP toolScopeGap
Data tables — list/create
R W GET /api/v1/data/tables
list_tablestables:read / tables:writeCanonical 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_rowtables:read / tables:write / tables:deleteFull 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:deleteFull 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:writeClosed (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:deleteClosed (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:readClosed (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_basesbases:read / bases:writeRead/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_formsforms:read / forms:writeSubmit 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:writeClosed (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:deleteFull CRUD (the most complete surface). No MCP tool.
Workflows
R W ️ GET /api/v1/workflows 500 live
list_workflows (same 500)workflows:readLive 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_subscriptioncommerce:writeREST 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/listper-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:writeFull 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:writeClosed (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:writeClosed (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:readClosed (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:deleteClosed (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.

~~P0~~ G-16 ~~No events/webhooks for tokens~~ CLOSED (mos-hq6q4.78)

/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.

~~P0~~ G-9 ~~Contacts absent from v1~~ CLOSED (mos-hq6q4.79)

/api/v1/contacts + /{id} shipped — full CRUD, scoped contacts:read/contacts:write.

~~P0~~ G-10 ~~Files/media absent from v1~~ CLOSED (mos-hq6q4.80)

/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).

~~P1~~ G-1 ~~Scopes are wildcard-only~~ CLOSED (mos-hq6q4.81)

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.

P1 G-7 `/api/v1/workflows` 500

Live 500 (schema drift). The one workflow read surface is broken; list_workflows MCP tool inherits it.

~~P1~~ G-3 ~~Table rows/columns are read+append only~~ CLOSED (mos-hq6q4.83)

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.

~~P1~~ G-11 ~~Scheduling absent from v1~~ CLOSED (mos-hq6q4.84)

/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).

— G-2 ~~Index advertises the wrong tables path~~ FIXED (mos-hq6q4.85)

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.

~~P2~~ G-13 ~~Comms send absent~~ CLOSED (mos-hq6q4.87)

POST /api/v1/comms/send shipped consent-gated (see the Comms-send row above): to (contact id / email / phone), channel (sms\

~~P2~~ G-12 ~~EMR read surfaces absent from v1~~ CLOSED (mos-hq6q4.88)

/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).

~~P2~~ G-6 ~~Review answers/verdicts not readable~~ CLOSED (mos-hq6q4.89)

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.

~~P2~~ G-4 ~~Table formulas/views/audit unexposed~~ CLOSED (mos-hq6q4.90)

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.

✅ closed G-5 Form submit/list-submissions

POST + GET /api/v1/forms/{slug}/submissions shipped in mos-hq6q4.91 (21da77332), live on app.marduk.app.

✅ closed G-8 Commerce REST twins

POST /api/v1/commerce/{invoices,quotes,checkout,subscriptions} shipped in mos-hq6q4.92; non-MCP clients can now reach every commerce action.

~~P2~~ G-14 ~~Articles absent from v1~~ CLOSED (mos-hq6q4.93)

/api/v1/articles + `/{id\

~~P2~~ G-15 ~~No general config read~~ CLOSED (mos-hq6q4.94)

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

© 2026 MOS Labs LLC. All rights reserved.