Moseik platform docs
Moseik hosts small-business websites that are fast, accessible, SEO-strong, and safe to edit — including by AI assistants. This is the public documentation: any person or AI can read it here (rendered) or fetch the raw Markdown by appending .md to any page URL.
Building a website for your human? You can do all of it, free for 180 days and with no key: connect to https://api.moseik.app/mcp and follow Getting started. Already connected to a site? Read Authoring pages before you write.
How the platform works
- A page is freeform HTML. You author a sanitized HTML body plus optional page CSS. The platform owns the document shell (
<head>, the theme, SEO meta, JSON-LD, the security policy) and wraps your body in it, so your markup cannot break the site chrome or leak past the security boundary. There is no composition JSON, no fixed vocabulary, no utility-class allowlist. - Reuse without repetition. A per-site stylesheet holds shared CSS. Partials are named fragments you reference from many pages (
<mk-partial ref="cta">) and edit in one place. Behavior with a backend comes from headless components (<mk-component name="contact-form">) that render functional markup with stable class hooks and zero styling opinion. - First-party by physics. Every page serves under a strict Content-Security-Policy: scripts and connections are same-origin only. Even your own per-site JavaScript cannot load a third-party script or exfiltrate data. Imported media is mirrored first-party.
- A site's address is its
www.hostname. The root domain forwards to it with a 301 and is never the canonical one. Take the address fromhostnames[]inGET /v1/site— the entry withisPrimary: true— rather than assuming either form in links, copy, or structured data. A root domain takes anArecord; it can never take a CNAME. - Every change is safe. Edits go through one API where they are versioned, run through a safety gate (sanitize → placeholder resolution → budgets → compliance), classified, audited, and made reversible. Routine copy edits publish automatically; anything the pipeline flags is held for a human. A broken page is one revert from the last good version.
Don't build these — the platform already has them
Read this list before you start building — by the time you'd look something up, you'll have written it yourself. Roughly two-thirds of it exists because the alternative is either wrong or physically blocked: every page serves under a same-origin Content-Security-Policy, so a third-party script, font or fetch fails to load.
<!-- BEGIN GENERATED: capability-table (edit the domain file under apps/platform/src/core/capability-inventory/, then run: pnpm tsx tools/gen-capability-table.ts) --> <!-- prettier-ignore -->
| Instead of… | Use | Where |
|---|---|---|
| waiting to be told what to build, or hearing about a client's Hivoma form request second-hand | GET /v1/integration-requests — claim one, do the page work, report the route back | API |
| a local copy you push over the top, or net-size checks to catch bad overwrites | nothing — a write deleting >20 live lines is refused (write.destructive_overwrite) with the count and what would be lost; re-read and merge | API |
| a lease, a 409, or a guess to find out whether someone else edited since you synced | lastWrittenBy / lastWrittenAt on every content read — and a write that REMOVES another credential's lines is refused (write.cross_writer_overwrite) naming them; a merged copy passes free, and so does one that merely restructures their lines | API |
| re-reading every doc each run, or trusting a cached copy that went stale silently | GET /docs/versions.json and the ETag on /v1/manifest — both content-hashed, so a conditional read is 304 when nothing moved | API |
| sleeping after a stylesheet publish, or diffing the served CSS against your local file to guess whether the re-render landed | X-Mk-Stylesheet-Version on the served page — compare it to the publishedVersionId the PUT returned | API |
| writing CSS for a component state you cannot make happen, and shipping it having never rendered | ?mkstate=agents:unavailable (also :empty, :nophoto, :unconfigured, and listings:error) on any page URL | authoring |
a <div role="dialog"> plus island code for the focus trap, Esc, the dim and focus restore | <dialog> + showModal() — the browser supplies all four, and ::backdrop styles the dim | authoring |
hand-writing a <form> and finding somewhere to POST it | <mk-component name="form"> → the leads pipeline, spam-filtered | authoring |
| asking a client to copy the enquirer's address out of a notification, or emailing a manager separately so they see enquiries | nothing for the reply — Reply-To is already the enquirer. For a copy: PUT /v1/site { notifyCc } / { notifyBcc }, or data-notify-cc / data-notify-bcc per form | API |
| retyping every agent's details onto their page, and guessing which routing key is theirs | one /agents/:slug template over the synced roster collection, which keeps every agent page current; GET /v1/roster reads the same records live. Setting a brokerage up start to finish is one ordered procedure in authoring | API |
| copying UTM parameters or click ids into hidden form fields | nothing: each lead's attribution records how the visitor arrived | API |
| carrying ?agent= or ?ref= to a form through a hidden field or localStorage | PUT /v1/site { "landingParams": ["agent"] } | API |
| an island caching the landing agent and writing it into every consumer form | nothing — a roster page claims the visit; data-session-agent="false" keeps a form out | authoring |
| one notification address for the whole site and forwarding by hand, or a separate form per person so the recipient can be hard-coded | PUT /v1/site { leadRouting } — the recipient is resolved at submission time from a submitted field, with an inbox or round-robin fallback | API |
a submit handler, a thank-you page, or a ?submitted=1 check to confirm a submit | nothing — forms post without reloading and swap themselves for a confirmation; validation errors render in place. data-success-message changes the wording | authoring |
| a fetch to a Proof product's API from site.js to show its reply after a sign-up | data-response-fields on the form, filled into data-mk-response-field slots on success | API |
| a JS island that builds a query string and opens a booking/ticketing engine | data-action on <mk-component name="form"> — a real form to an allowlisted origin, so it works with no JS | authoring |
| an upload widget or "email us your résumé" | "type": "file" in a form schema — private, linked with the lead, kept one year | authoring |
| a hardcoded copy of the field types a form accepts | GET /v1/manifest → forms.fieldTypes, derived from the renderer's own constant | API |
| hard-coding a list of events or offers the client then cannot change or expire | <mk-component name="collection"> over rows in /v1/collections — each row has a visibility window, so a finished event stops rendering on its own | authoring |
| hand-authoring a second grid because one collection needs to appear as two different subsets | data-where="<field>=<value>" on <mk-component name="collection"> — the grouping lives on the row, so both grids stay client-editable | authoring |
| letting a collection card fetch a picture sized for the whole viewport, or capping the row value and softening the detail page | data-image-width on <mk-component name="collection"> — the card box in px; the row value's ?w= stays free for the detail page | authoring |
a JS island that turns a collection row's image field into an <img>, or hard-coding card art into the page | a collection field of type image or link — real <img> / <a> in the served HTML, with the platform's own width/height/srcset treatment | authoring |
| building one page per event/offer, and deleting them as they pass | one page at /<base>/:slug with <mk-component name="collection-detail"> — the router binds the row, and its URL enters and leaves the sitemap on its own | authoring |
| a page at /llms.txt (or /robots.txt), or a hand-maintained list of the site's pages for AI agents | nothing — /robots.txt, /sitemap.xml, /feed.xml and /llms.txt are served for you; improve /llms.txt by writing page meta.descriptions, starting with the home page's | authoring |
| integrating an MLS feed (or pasting sample listings) | listings + listing-detail components, server-rendered with the CREA obligations attached to the rows that owe them | authoring |
| hand-writing a card per new-construction lot, or giving a builder's lot the MLS® number and REALTOR.ca link it does not have | the same listings / listing-detail components over Evrylist — the CREA duties follow the row, and an unpriced lot renders price-on-request rather than $0 | authoring |
| hand-writing one agent card per person for a meet-the-team page, which then goes stale the day somebody joins or leaves | <mk-component name="agent-roster"> over the DDF Member feed — no email or bio for anyone; everything else is per-member, so check what their feed returns | authoring |
| standing up a separate site for an agent so their page can show only their listings | data-agent-key on the listings component — the platform pairs it with the site's own office, so a key that is wrong or has moved brokerage renders empty rather than someone else's inventory | authoring |
| pasting a seller-lead widget, or building the market panel for a 'what is my home worth' page from Places JS and a listings API | <mk-component name="seller-comparables"> bound to a signed ?mk_addr= token from /__data/places/geocode — server-rendered, and it is NOT a valuation | authoring |
| writing stage + thumbnail-rail JS for a listing detail page, or re-arranging the lightbox's markup to build one | data-gallery="stage" on listing-detail — thumb clicks drive an unstyled stage, and the stage click opens the lightbox at that photo | authoring |
| copying a mortgage or land-transfer calculator | window.mk.mortgage() / window.mk.landTransferTax() — put data-mk-finance on any element of the page first, or the bundle never loads and window.mk carries only bind | authoring |
| writing a neighborhood page from the model's impression of the town, or asking the client for the local colour | GET /v1/areas?name=… for the boundary id, then <mk-component name="neighborhood-stats"> — server-rendered per-area demographics and market data; a figure the vendor lacks is omitted, never printed as zero | authoring |
| hard-coding one neighborhood into a listing detail template, so every property claims the same one | data-boundary="listing" on neighborhood-stats / community-map / market-trends — resolved from the listing's own coordinates by containment, falling back from neighborhood to city | authoring |
| embedding a third-party map widget or an iframe just to show a neighborhood outline | <mk-component name="community-map"> — a same-origin image of the boundary; needs no CSP change and the provider key stays server-side | authoring |
| charting market figures a client emailed you, or loading a charting library from a CDN | <mk-component name="market-trends"> — monthly closings and median sale price; renders not-covered rather than drawing a market the provider only partly has | authoring |
| typing a community's average price and listing count into the page, or averaging a grid in JS | <mk-component name="listing-stats"> — averages over the active feed, refreshed daily, with an inline SVG property-type donut | authoring |
| guessing which neighborhoods an agent should farm, or asking the model which area is 'up and coming' | GET /v1/areas/rank?lat=&lng= — inventory, price, velocity and turnover per area, with the raw numbers and the method beside every score; relative to the result set, so it ranks rather than recommends | authoring |
| writing carousel / tabs / scroll-reveal / lightbox / dropdown JS | the mk-* hook classes — behaviour only, you style it | authoring |
writing a scroll-snap slider — arrow stepping, end-disabled arrows, a counter — or forcing mk-carousel to show four tiles at once | .mk-track — your own overflow-x row; [data-mk-prev]/[data-mk-next] step by item width, the ends disable themselves, and swipe is native scrolling | authoring |
| writing thumb-click or dot-click JS to move a carousel, or settling for a thumb rail that opens the lightbox | [data-mk-goto="n"] on a control inside .mk-carousel — 1-based, alongside [data-mk-prev]/[data-mk-next] | authoring |
a <noscript> fallback so scroll-reveal sections aren't blank without JS | nothing — .mk-reveal ships revealed and the runtime un-reveals below the fold, so a failed script leaves content readable. Do write the .is-in-view rule | authoring |
| an iframe with your own Google Maps key | <mk-component name="map"> (the frame URL is signed for you) | authoring |
| plotting listings on a map yourself, or re-querying the feed for coordinates | <mk-component name="listings-map"> beside a listings grid — it re-queries the viewport as the visitor pans, so a "search this area" button is one you must not build | authoring |
| asking a human for a brokerage's or agent's DDF key, or pasting one you were given | ask Moseik with the agent's or brokerage's name — the keys are looked up from it; people give the board-scoped MlsId, which is not globally unique | authoring |
| asking a client for their Google Place ID, or lifting one out of a Maps URL | ask Moseik with the business name and town — a CID and the 0x…:0x… hex pair are NOT Place IDs, and both look plausible enough to paste | authoring |
| loading the Google Places JS library, or putting a Maps key in page source | GET /__data/places/autocomplete and /geocode (by placeId, with the autocomplete session) — same-origin, key stays server-side; connect-src 'self' means a third-party fetch fails to load, not just breaks a rule | authoring |
| a Google reviews widget, an embed, or any script that fetches reviews in the browser | <mk-component name="google-reviews"> — Place Details fetched SERVER-side, so the words are in the HTML a crawler reads; a vendor script cannot run under the page CSP | authoring |
| hard-coding a Place ID or maps URL to build a "see all our reviews" button | data-mk-maps-url on the rendered .mk-reviews block, or reviews.mapsUrls on GET /v1/site — resolved from config, so it cannot drift | authoring |
| typing the options for a listing filter, or tallying them by hand from a page of results | GET /__data/listings/facets — the values the feed actually holds, scoped to this site; an invented value returns an empty grid with no error | authoring |
| filtering listings by price/beds/baths/size in the browser, or hiding cards with CSS | data-min-* / data-max-* on the listings component, and the same names on /__data/listings — one upstream query behind both the indexed grid and the visitor's | authoring |
| sorting the rows you were handed, or a sort control that reorders one page | data-sort="price-asc" / ?sort= — ordered across the WHOLE set, so page 2 follows page 1; a price sort returns sale listings only | authoring |
a House / Condo / Townhouse dropdown built from data-property-type values | data-structure-type / ?structureType= — the feed files all three under one subtype, so dwelling type is a different field; take its values from facets | authoring |
| a town or neighbourhood page whose filter quietly returns four other places, or a tile shipped as dead text because the search cannot be written | data-match="exact" — the feed token-ORs, so "Fort Frances" otherwise matches every Fort and every Frances, and a board-prefixed neighbourhood goes wider still | authoring |
an island that reads location.search to make a listings search shareable | data-url-filters="all" on listings — the query string reaches the SERVER-rendered grid, which an island cannot do; ?page=N becomes real and the variant is noindex | authoring |
an island that appends /__data/listings pages onto a grid as the visitor scrolls | data-paging="auto" (or "more") on listings — appends, and keeps the crawlable ?page=N link a scroll sentinel would delete | authoring |
| hand-writing the filter form's JS — URL sync, back button, facets, 502-vs-empty | <form class="mk-listings-filters" method="get"> with control names = filter names; a plain GET form that works with JS off, enhanced by the runtime | authoring |
pasting a <script type="application/ld+json"> block | PATCH /v1/pages/:id { meta: { schema } } — WebSite/Organization/LocalBusiness/BreadcrumbList already ship on every page | authoring |
| writing RealEstateListing schema for a property page | nothing — listing detail routes emit it automatically from the feed | authoring |
fetch() to an external API from your island | GET /__data/<provider> — the only route out under the CSP | authoring |
| guessing whether the CSP blocks what you are about to build | GET /v1/site → csp.effective — this site’s actual header, generated by the code that serves it | API |
| probing endpoints to learn whether a feature is on, or filling a gap by hand | GET /v1/site → capabilities[] — every capability, on or off, with a reason code and who can turn it on | API |
| posting form data to a CRM from the page, embedding a HubSpot form/script, or hand-rolling a webhook because the client's CRM is not HubSpot | nothing in the page — build the form normally, then PUT /v1/lead-webhook { url, secret } (allowlist the origin first), or PUT /v1/crm-credential { token } for HubSpot | API |
| assuming every form on a site has to send its leads to the same place, or routing them yourself on the receiving end | PUT /v1/lead-webhooks/<name> { url, secret } per destination, then data-lead-destination="quotes" on the form. A form naming none still goes to all of them | API |
| rebuilding a failed lead by hand and POSTing it at the CRM yourself, or telling the client an enquiry is unrecoverable | POST /v1/leads/<id>/redeliver — re-attempts the destinations that failed, idempotently. { destination } for one, { force: true } to override a terminal failure you have fixed | API |
| pointing every agent on a brokerage site at one shared inbox, or resolving the agent yourself on the receiving end | PUT /v1/lead-webhooks/<name> { provider: "engage", agentFromRouting: "email" } — no key to obtain; the lead reaches the routed agent's own MoxiWorks dashboard | API |
| embedding Mailchimp's own signup form or script, or collecting newsletter signups for somebody to paste in by hand later | PUT /v1/lead-webhooks/<name> { provider: "mailchimp", apiKey, audienceId }, then route only the signup form to it — added as pending (double opt-in) unless the destination is set to subscribe directly | API |
| posting an event enquiry to Tripleseat from the page, or writing a receiver whose only job is to re-shape our lead into theirs | PUT /v1/lead-webhooks/<name> { provider: 'tripleseat', apiKey } — the venue's lead-form public key, no url — then data-lead-destination on the enquiry form. Fields outside Tripleseat's twelve fold into additional_information | API |
| posting to a sheets API from the page, embedding a Google Form, or writing a receiver whose only job is to append a row | PUT /v1/lead-webhooks/<name> { provider: 'sheet', sheetId, tabName } — no credential; the call verifies the sheet is reachable | API |
| telling a client that connecting their HubSpot needs a Moseik staff member, or waiting on one | PUT /v1/crm-credential { token } with their Private App token — contacts-only scopes, and the client confirms the HubSpot account id it reports back | API |
| publishing and hoping the client likes it, or describing a change in words | hold: true on POST /v1/pages (or ?hold=1 on a payload write), then POST /v1/pages/:id/versions/:vid/preview-link for a link they can open with no login | API |
| leaving an inherited old URL 404ing, or making a page just to hold a redirect | POST /v1/redirects { from, to } — to is a page path (router-resolved, so a collection row counts), a #fragment, a /media/<id> asset, or an allowlisted https:// URL; from may end /* to cover a whole subtree; refuses ones that shadow something live or point at a 404 | API |
| hand-maintaining a blog index, or modelling posts as collection rows and losing their meta | meta.publishedAt on each post page + <mk-component name="page-list" data-under="/blog/">; /feed.xml follows automatically | authoring |
living with the generic not-found page, or building a real page at /404 | PUT /v1/notfound { html, css? } — rendered with your chrome, still 404 + noindex | API |
| hotlinking images, resizing by hand, or uploading a small copy for a thumbnail | POST /v1/media/import → mirrored first-party; every request resized (1920px cap) + AVIF/WebP on serve, CSS url() included. ?w= overrides for small boxes | API |
a Google Fonts <link> | vendored theme families, or POST /v1/fonts for a licensed woff2 | API |
| hard-coding colours and sizes on every page | theme tokens → var(--color-primary) | authoring |
| copy-pasting a header into forty pages | chrome, partials, the site stylesheet | authoring |
| pasting a gtag or Meta pixel snippet, or building a cookie banner | PUT /v1/site { "analytics": { "ga4MeasurementId": "G-…", "metaPixelId": "…" } } — the platform loads both AND shows one consent banner | API |
| assuming the phone number and address on your contact page are what search engines read | PUT /v1/site { "contact": { "phone", "email", "hours", "address" } } — the only source for the site's LocalBusiness data; fields merge, and it re-renders the site | API |
| typing the business's phone number and hours into every page that shows them | <mk-component name="site-field" data-field="phone"> — prints one field of the contact record, so one PUT /v1/site { contact } corrects every page at once | authoring |
| guessing what a page looks like | GET /v1/pages/:id/screenshot — a real browser under the real CSP | API |
| writing a contrast checker, or driving four viewports yourself to find overflow | GET /v1/pages/:id/verify — real browser: WCAG contrast, alt/label/heading a11y, mobile overflow, wrong image ratios, [hidden] that still has a box | API |
| submitting a form in a browser by hand, then deleting your test leads | POST /v1/forms/:formKey/test — the real submit handler, reporting the stage it reached; the row it stores is filtered out of GET /v1/leads | API |
| assuming a form delivers because it renders | GET /v1/forms — which forms the handler will actually accept, and whether notifyEmail is set | API |
| a JS helper so your own screenshots aren't blank below the fold | ?nolazy=1 / ?nomotion=1 on any page URL — handled at the serve layer, so it works on any site, including one you did not build | authoring |
| re-authoring content you overwrote, from the copy that overwrote it | POST /v1/pages/:id/revert { toVersionId } — same for the stylesheet, script, chrome and partials | API |
| deleting a page, or leaving a dead one published | POST /v1/pages/:id/archive → 301 to a successor or 410 Gone; the URL keeps answering, the sitemap updates, the client gets a restore link | API |
| building a site locally, then asking your human where to host it | POST /v1/drafts → build it, then hand them the claimUrl (free 180 days) | getting started |
| discovering the page cap or a frozen page by being refused one write at a time | GET /v1/site → plan.pages.remaining + plan.upgrades[], and frozen on each GET /v1/pages row — plan.restricted: false means nothing is limited, never "unknown" | API |
| assuming a vendor origin will work under the CSP | GET /v1/allowlist — check before you build; POST proposes one (reaches connect-src/form-action only, never script-src) | API |
a <link rel=icon> in your HTML (the platform owns the <head>) | PUT /v1/favicon { assetId } | API |
| asking your human to rename the site's moseik.app address | PUT /v1/site/address { "label" } | API |
| letting a build collide with another agent editing the same site | POST /v1/lease first, DELETE /v1/lease when done — a 409 names who holds it, and the acquisition response says what other credentials wrote since you were last here (sinceYourLastWrite) and names the assets they touched (changed[]) so you re-read those before editing. It lasts 300s; writing extends it, GET /v1/lease says whether you still hold it (youHoldIt, expiresInSeconds), and a write without it is 409 lease.required — a different code from a version conflict, so branch on the code for the remedy. The status tells you whether a retry can work at all: 409 is concurrency (do something, retry), 422 is the content guards (merge or acknowledge; no retry clears it) | API |
| spending a write to find out if it passes | PUT /v1/pages/:id/payload?dryRun=1 | API |
| one round trip per page | GET /v1/pages?include=payload, POST /v1/pages/batch | API |
| reading a product's own API to learn what fields the form it asked for needs, or guessing them | GET /v1/integration-requests/<id>/fields — mapped to Moseik field types, identity fields folded in, and a refusal naming any question this platform cannot render | API |
<!-- END GENERATED: capability-table -->
GET /v1/manifest is the machine-readable form of this table (its capabilities array), plus the live budgets and the current component list. If you read one thing programmatically before building, read that.
Start here
- Getting started — connect over MCP and build a site for your human, free for 180 days.
- Authoring pages — the page model, what HTML/CSS you can write, components, partials, per-site JS, theme, media, and a worked example.
- Content API reference — every endpoint, auth, the writer lease, edit classes, and the edit loop.
- Cloning a site — the pixels-first protocol for reproducing an existing site at high fidelity.
- Changelog — what changed in the contract, dated. Read this first if you're coming back after a gap.
For AI assistants
If you were handed an existing site's context and asked to make changes:
- Read Authoring pages and the API reference.
- Record
docsVersionandmanifestVersion, and check them at the start of every later run.GET /docs/versions.jsongives the first plus a hash per doc;GET /v1/manifestgives the second plus a hash per section, and both honourIf-None-Match— so "has anything changed?" is one conditional request that usually answers304.
Your copy of this platform is a cache, and other agents and platform staff change the contract while you work. When a version moves, compare the per-item hashes to see which part moved, read the changelog, and re-read only that. A component description that changed under you is invisible any other way.
- Take the writer lease, then work through the content API only. You author per-site content (HTML, CSS, JS, theme tokens); you never edit platform code.
- Use the screenshot endpoint (
GET /v1/pages/:id/screenshot) to see your own work — build one section at a time and compare against the target.
Every doc here is also available as raw Markdown for easy fetching — e.g. /docs/authoring.md, /docs/api.md.
Docs version af8c54ba7452 — machine-readable at /docs/versions.json. Returning after a gap? The changelog says what moved.