Skip to main content
Max AI provides tools to help you build marketplace apps quickly — a template app, SDK packages, and deployment guidance.

App Template

The fastest way to get started is by cloning the official app template:
The template includes:
  • Pre-configured authentication with the Max AI platform
  • SDK packages (@max-ai/components and @max-ai/app-bridge) already installed
  • Example pages showing how to fetch and display clinic data
  • Ready-to-deploy configuration

SDK Packages

Two npm packages are available for building Max AI apps:

@max-ai/components

A component library that provides UI elements matching the Max AI design system. Use these to build interfaces that feel native to the Max AI platform.

@max-ai/app-bridge

Handles communication between your app and the Max AI platform. This is used for embedding your app within the Max AI dashboard, exchanging authentication tokens, and receiving context about the current user and organization.

App Lifecycle

1. Draft

When you create a new app, it starts in Draft status. You can configure settings, set required scopes, and develop locally.

2. Submit for Review

When your app is ready, submit it for review from the app settings page. The Max AI team will review your app’s:
  • Functionality and UX
  • Required scopes (are they justified?)
  • Security practices
  • Data handling

3. Approved / Denied

The Max AI team will approve or deny your submission with feedback. If denied, you can address the feedback and resubmit.

4. Live

Once approved, your app becomes available in the Max AI marketplace. Clinics can discover and install it, granting your app access to their data through the approved scopes.

Sizing your app in the dashboard

Your app runs in an iframe with scrolling="no". Two modes decide how tall that iframe is.

Content sizing (the default)

The SDK reports your content height and the host matches the iframe to it, so your app never shows a scrollbar. Right for forms, settings panes, detail views — anything that should just be as tall as it is.

Fill mode — for a fixed frame around a scrolling region

The host gives the iframe the height available in its own layout and tells you what that height is, via the onHeight callback. It fires once on entry and again whenever the available height changes — window resize, dashboard chrome reflow — so relayout on every call.
Do not try to build this with content sizing and a bounded scroll container. It cannot work, and not as a matter of tuning: to scroll internally the container needs a height, that height is what gets reported to the host, and the host then grows the iframe to match. Viewport units do not escape it either — inside a content-sized iframe 100vh is the height the host just derived from your own output, so the measurement feeds back into itself and the list bounces as you scroll.
Fill mode does not give you a nested browser scrollbar — the frame keeps scrolling="no". The overflow is yours to handle inside your own DOM. The cleanup returned by startFillMode hands height control back to content sizing, so a fill screen and a content screen can coexist in one app. Call it on unmount:
startFillMode requires @max-ai/app-bridge 0.2.0 or later. On an earlier version the export simply does not exist, so a feature-detecting app silently falls back to content sizing forever. Use the wire protocol below if you cannot upgrade.

The wire protocol is a supported interface

Every SDK helper is a thin wrapper over postMessage. The protocol is the contract — you never have to wait on a package release to use a host capability. Fill mode over the wire, in full:
Two behaviours worth knowing before you build against this, because neither is guessable:
  • Your content observers keep running in fill mode, and their reports are suppressed. One reaching the host flips it back to content sizing and silently undoes the mode. If you are hand-rolling the protocol rather than using startFillMode, stop reporting content heights while in fill mode yourself.
  • Entering fill mode clears any recorded content height, so leaving fill mode without an immediate report cannot apply a height from a screen that is no longer mounted.

Making your app’s screens linkable

By default an app renders at /apps/<your-slug> and its internal screens are invisible to the address bar: a refresh drops the user back on your landing screen and no screen can be shared. Report your internal route with target: "app" and the host mirrors it as /apps/<your-slug>/<your-path>:
On a cold load the host hands that path back to you two ways: appended to your iframe URL, and as initialPath on the init config. Query strings arrive by both routes. A fragment (#…) arrives only by the second route — through subscribe, shortly after your app signals ready. Browsers never send a fragment to the server, so it cannot be part of your iframe URL. If you route on the fragment, read it from subscribe rather than from initialPath, and expect it a beat after boot. Browser back and forward reach you through subscribe:
target defaults to "host", which navigates the dashboard and unmounts your app — the behaviour navigate() has always had. Deep linking is opt-in: pass { target: "app" } explicitly. This is deliberate, because the bridge script is served by the platform and not version-pinned, so changing the default would have silently altered what navigate() does for every app already deployed.

Push or replace — say which, or Back takes two presses

An in-app navigation normally creates two history entries: the one your own router pushed inside the iframe, and the one the host pushes when it mirrors your report. Back then has to be pressed twice to move one screen — and the first press looks dead, because it pops your private stack while the address bar sits still. Tell the host which you meant:
Match history to what your own router did and one Back press moves one screen. It defaults to "push", which is what the host did before the option existed, and a host older than it ignores the field — so there is nothing to version-gate. Two cases ignore it, both deliberately: your first report after boot always replaces (it describes the page load, not a move), and a report of the path the address bar already shows writes no history at all.

Demo mode and Hide PHI

The dashboard has two operator toggles — Demo mode (replace PHI with fake data) and Hide PHI (blur or hide it). The host cannot restyle or rewrite your cross-origin iframe, so it tells you and your app does the masking:
maxcare.visibility.get() is accurate on your first render, not merely once ready() resolves. The state is written onto your iframe URL as well as into the init message, and the bridge script reads it synchronously at load — because the postMessage handshake is a round trip your first paint does not wait for, and a frame of real patient names rendered against Hide PHI is a disclosure, however short. So mask from the first render. Do not gate your whole app on ready() to be safe: a host that never answers would leave every screen blank, which is the failure mode the bridge’s timeouts exist to survive.
Server-rendered apps must read the parameters on the server too. useVisibility() returns all-false during SSR — your server has no host and no bridge — so the HTML the browser paints before hydration is unmasked, and no client-side hook can mask markup that has already been sent. This is the reason the state is on the URL rather than only in a postMessage: your server receives it.
A fully client-rendered app needs none of this — its first render already has the seeded state.
The seed rides on the URL the host loaded you with. If your app replaces its own document — an OAuth redirect back, a POST/redirect/GET, a plain <a href> — it lands on a URL without those parameters, and the host has no way to put them there. That render is unmasked until the init message arrives. Prefer client-side routing inside an embedded app; if a full document load is unavoidable, carry the parameters across yourself or mask until useVisibility() has reported.
Hide PHI is exact on your first render; Demo mode is best-effort. The host can only seed what it knows when it writes your URL: Hide PHI is session state it holds in memory, so it is settled then, while Demo mode comes from stored preferences and permissions that resolve a moment later — on a cold load its parameter is simply absent and you learn it from the init message instead. Subscribe either way, and do not read a missing maxcareDemoMode as proof that demo mode is off.
The parameters (maxcarePhiHidden, maxcareDemoMode) are appended to your iframe URL only when a toggle is on, and the host overwrites them — so a copied URL carrying a stale value never decides whether PHI is masked. Read them through maxcare.visibility rather than parsing them yourself; toggles flipped after load arrive over the message channel, not by changing the URL.

What the app sandbox does not allow

Your app runs in a sandboxed iframe. Most things work, but the sandbox is not transparent, and a denied capability usually fails silently rather than throwing — so it is worth knowing the list. Granted: scripts, same-origin, forms, popups, modals (window.confirm / prompt / alert), and clipboard-write. Not granted, and what happens if you try:
allow-modals is granted, so window.confirm() works. It was missing before August 2026, and its absence was invisible: confirm() returned false and prompt() returned null immediately, with nothing rendered and no exception — so any destructive action guarded by if (!confirm(…)) return; became a dead button whose only trace was a warning in the iframe’s own console. If you are debugging a button that does nothing on an older platform build, that is why.

Authenticating as your app

Your app has one API key, minted once from the developer dashboard. It is not per-clinic, and you never need a clinic to hand you a credential. The key carries your app’s identity. Which organization a request acts on comes from a header:
On every request the platform checks that the organization named in X-Organization-Id has an active installation of your app. So the same key works for every clinic that installs you, and stops working for a clinic the moment it uninstalls or is suspended:
That check is live, not baked into the key at issue time — a cached key never retains access to an organization that revoked your app.
This means onboarding a new clinic requires no manual step and no new credential. Learn about the installation from the app.installed webhook (or from GET /v4/marketplace/installations), then start calling the API with the key you already have and the new organizationId. See Webhooks.

Discovering which organizations you can act on

No X-Organization-Id — this endpoint is about your app, not about one clinic. It lists every organization that has installed you, in every status. Use it to bootstrap on first run and to reconcile on a schedule.

Session tokens on your own API calls

The bridge script wraps window.fetch and attaches the app-scoped token to requests that resolve to the host’s origin. That token is scoped to your app: the Max AI API does not accept it, by design. It does not sign requests to your own origin — so if your API authenticates with that token, put the header on the call yourself:
await maxcare.appToken() is the same refresh-aware value the interceptor uses: it reuses a cached token while its exp is comfortably ahead, and otherwise asks the host for a fresh one. These tokens live two minutes, so read it per request rather than capturing it once at load. It returns null when the platform could not mint one — fail the request rather than falling back to anything broader.
maxcare.idToken() is deprecated and now returns the same app-scoped token. It used to return the dashboard’s own Clerk session token, which named no app and therefore authenticated against the Max AI API — and every other app’s backend — as the signed-in user. That token is no longer sent to embedded apps at all.
This is deliberate, and it is why the SDK does not just sign everything same-origin for you. window.fetch is global: a bridge that signed same-origin requests would put a live credential on every request made by any script in your page — analytics, a chat widget, a session-replay collector that records request headers. Setting the header at your own call sites keeps the credential somewhere you can audit.
A request to any origin other than the host’s is passed through untouched. The host check is a resolve-then-compare-origins test, not a prefix match, so //analytics.example/collect — which starts with / but is not same-origin — never receives the operator’s credential. An explicit Authorization header you set yourself is never overwritten.
Debugging the handshake: the bridge logs every message type it sends and receives to the console, without the payload. Set window.__MAXCARE_BRIDGE_DEBUG__ = true before the script loads to include payloads — off by default because init carries a live app token and title-bar:set carries the patient’s name.

Verifying a session token (do not hand-roll this)

An embedded app receives x-organization-id from the host iframe. That is metadata, not proof — auto-provisioning a tenant from it would let anyone who can reach your app mint tenants for an arbitrary organization.
No X-Organization-Id header — resolving the organization is the point. Get the token from maxcare.appToken() (or app-bridge’s auth:token-request). The field is still named sessionToken for wire compatibility, but the endpoint accepts only the app-scoped token — a Clerk session token is refused like any other unrecognized token. A 429, a timeout, or a 5xx here means ask again shortly — never treat it as an answer about the tenant. The public API is metered per app (1000 requests / 60s, Retry-After on a 429), and folding a rate limit into your auth-failure path tells a real clinic it is the wrong clinic. organizationId is your tenant key and is the same value GET /marketplace/me returns for the organization on the same API version — a raw UUID on v1–v2, prefixed (org_…) from v3. clerkUserId and clerkOrganizationId are Clerk’s own ids and keep Clerk’s prefixes on every version; they are never rewritten into the usr_ / org_ readable form. The endpoint returns 200 only when both hold: the token verifies, and your app has an active installation for the organization it belongs to. So a successful response is simultaneously authentication and authorization — it answers “is this session one of my tenants?”, which is the only question an app is entitled to ask.
Verifying the JWT yourself is possible but easy to get wrong in ways that are authentication bypasses, not bugs: reading the issuer out of the token instead of pinning it (an attacker points you at a JWKS they control), or accepting alg: none or an HMAC algorithm where a public key is expected.Use createAppTokenVerifier from @max-ai/app-bridge/server instead of writing it. It pins the issuer and audience from your config, restricts the algorithm to ES256, and reads no claim until the signature passes:
CommonJS backends can require("@max-ai/app-bridge/server") for the same verifier. It loads jose on the first verification rather than at import, because jose is ESM-only; a Jest suite that exercises the verifier therefore needs NODE_OPTIONS=--experimental-vm-modules.Match errors on error.name or error.reason, not instanceof: a process that loads both the ESM and the CommonJS build holds two copies of AppTokenVerificationError, and a cross-build instanceof is false — which turns a 401 into a 500.App Bridge tokens are ES256, signed by the platform. If you still run a verifier pinned to RS256 for the retired Clerk token, leave that pin alone and give the app token its own verifier — widening either one to accept the other is the classic forgery.
All failures return the same opaque 401. Invalid token, no active organization, and “not installed for that organization” are deliberately indistinguishable — separating them would make this an oracle for probing which organizations exist and which have your app installed.

Verifying a key

GET /v4/marketplace/me with a key and an X-Organization-Id confirms that the key grants access to that organization, and returns the effective scopes. Two failures are worth distinguishing:

Deployment (Beta)

App deployment is currently in beta. The deployment workflow may change as we refine the experience.
You can deploy your app anywhere, but we recommend Fly.io for its simplicity and performance. The app template includes a fly.toml ready for deployment:
Other supported hosting options:
  • Vercel — Great for Next.js apps
  • Railway — Simple container deployments
  • AWS / GCP / Azure — For full infrastructure control
  • Any platform that can run a Node.js application
Your app must be accessible over HTTPS. The Max AI platform will embed your app via iframe, which requires a secure connection.

CLI

The Max AI CLI (@max-ai/cli) is the recommended way to create, develop, and deploy apps. It handles authentication, project scaffolding, local tunneling, and deployment in a single tool.

CLI Reference

Full CLI documentation with all commands and options.

Best Practices

Security

  • Store API keys in environment variables, never in source code
  • Validate all data received from the API before using it
  • Use HTTPS for all communications
  • Implement proper error handling (see Error Handling)

Performance

  • Use pagination efficiently — request only the data you need (see Pagination)
  • Cache responses where appropriate (e.g., provider and facility lists change infrequently)
  • Implement retry logic with exponential backoff for transient errors

User Experience

  • Use @max-ai/components for a consistent look and feel
  • Show loading states while data is being fetched
  • Handle error states gracefully with user-friendly messages
  • Display the trace_id in error states so users can report issues