App Template
The fastest way to get started is by cloning the official app template:- Pre-configured authentication with the Max AI platform
- SDK packages (
@max-ai/componentsand@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 withscrolling="no". Two modes decide how tall that
iframe is.
Content sizing (the default)
Fill mode — for a fixed frame around a scrolling region
onHeight callback. It fires once on entry and
again whenever the available height changes — window resize, dashboard chrome
reflow — so relayout on every call.
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 overpostMessage. 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:
- 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>:
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:
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: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.
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: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:
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
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 wrapswindow.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.
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 receivesx-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.
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.
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.
fly.toml ready for deployment:
- 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/componentsfor a consistent look and feel - Show loading states while data is being fetched
- Handle error states gracefully with user-friendly messages
- Display the
trace_idin error states so users can report issues
