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, receiving context about the current user and organization, and declaring your app’s navigation menu (0.6.0 or later).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.
A reported path with a . or .. segment (including %2e spellings) is
ignored, and the dashboard logs an error to the browser console. In the address
bar it would resolve outside /apps/<your-slug>/, to a URL your app doesn’t own.
Navigation menu
Don’t draw your own tab row or sidebar inside the iframe. The dashboard already has a sidebar and a header, and a second nav inside your frame takes space from your content. Declare your sections instead, and the dashboard renders them in its own chrome:<a> children, so adding, removing or
relabelling a link — or changing collapsed — updates the dashboard without
any extra call. Removing the element from the page clears the menu (the bridge
sends an empty list), so an element that lives in one route’s layout takes its
menu with it when the user leaves that route.
The element and maxcare.navMenu need @max-ai/app-bridge 0.6.0 or later.
A dashboard that predates the menu ignores it, so your app keeps whatever
navigation it still draws itself.
Each href is a path inside your app, in the same form you pass to
navigate(path, { target: "app" }). The dashboard links each item to
/apps/<your-slug>/<path>, so a user can Cmd-click any section to open it in a
new tab.
If you build the list in code, set it directly instead:
Counts on sections
A section can carry a count, such as items waiting for review. Set it withdata-badge on the link, or badge on the item:
99+. Only a whole number above zero shows:
0, a negative or fractional number, or anything that is not a number shows no
count, but the section itself is kept. Changing data-badge re-sends the menu,
so keep it in step with your data as you would any other attribute. Counts need
@max-ai/app-bridge 0.7.0 or later; an older dashboard ignores them.
A count reads as a plain number by default, in a muted badge: right for a queue
length. When it means something needs action now, give it the attention tone
with data-badge-tone, or badgeTone on the item, and it shows in red:
neutral (the default) and attention; any other value shows the
count in the default tone. A dashboard before 0.8.0 shows every count in red.
While the sidebar hides your sections (a closed sheet, or a rail set to
sidebar-flyout or hidden), an attention count also puts a dot on the
dashboard’s sidebar toggle.
Screen readers hear the label and the number, “Inbox, 7”. Say what the number
counts with data-badge-label, or badgeLabel on the item, and they hear
“Inbox, 7 unread messages”:
data-badge-tone: a label
that mentions “attention” is dropped, because the dashboard says “needs
attention” itself for that tone.
Following clicks in the menu
When a user clicks one of your sections, the dashboard updates the address bar and tells your app throughnavigation.subscribe. You must subscribe and
route there. Without it the address bar moves and your screen stays put.
navigate() from this callback: the dashboard has already moved its
address bar.
You don’t report which item is active. The dashboard highlights it from the
current URL: an exact match first, then the longest matching prefix. Keep
reporting your own in-app moves with navigate(path, { target: "app" }) and
the highlight follows.
When a section owns pages outside its own path, list those paths in
data-match (space-separated) or match on the item. The section is then
highlighted on them and the pages under them, as it is on its own path:
path and count the same way: an exact
match (on the path or any match path) wins, otherwise the longest matching
prefix across all of them. They need 0.8.0; an older dashboard ignores them.
Choosing where your sections appear
The dashboard sidebar is either expanded or collapsed to a narrow icon rail. The user chooses which, and the dashboard remembers it. When the sidebar is expanded, your sections always appear nested under your app in the sidebar, the same for every app. You can’t change this. When the sidebar is collapsed, the rail has no room for labels, so you choose where your sections go:
Your sections only ever appear in one place at a time. Below 1280px wide —
phones, tablets and narrow desktop windows — the dashboard sidebar becomes a
slide-out sheet, and there your sections always appear as a tree under your
app, whatever you choose. The header shows no tabs at those widths.
Pick
sidebar-flyout when you have many sections and want to keep the header
clear. Pick hidden only if your app has one main screen and the others are
rarely visited.
Knowing whether your sections are on screen
The dashboard tells your app where your sections render right now, and whether they (and so their counts) are on screen. Use it to show an in-app cue, such as an alert chip, only when the dashboard’s own badge is out of view:
The tree counts as in view only when all of it is: if the user has collapsed
the Apps group or scrolled the sidebar so part of your sections is hidden, it is
false; it is also false while any dashboard menu or dialog is open. A false you get wrongly only shows your cue next to the
dashboard’s badge; a wrong true would hide both, so the dashboard leans to
false.
Your callback runs at once with the current placement when the dashboard has
already sent one, and again on every change: the sidebar expanded or collapsed,
the window crossing 1280px, the sheet opened or closed, your sections scrolled
into or out of view. It is sent only while your app has sections, and each page
your frame loads hears it again once that page has declared its menu. A page
that declares no sections never hears it, so getPlacement() stays null; a
page that withdraws its sections hears hidden once. Placement needs a
dashboard on @max-ai/app-bridge 0.9.0 or later. The dashboard serves the
script, so on an older one getPlacement and subscribePlacement do not
exist: call them as maxcare.navMenu.getPlacement?.(), treat null or
undefined as “unknown”, and keep your cue showing.
Limits
The dashboard checks the menu before showing it:- At most 16 items. Extra items are dropped. The sidebar tree and the rail flyout list every item; header tabs show the first 8 and put the rest in a More menu. (A dashboard older than 0.7.0 keeps 8.)
- A
badgeshows only for a whole number above zero; any other value is dropped and the item kept. An unknownbadgeToneis dropped and the count shown in the default tone. - Each item keeps at most 8
matchpaths, each checked likepathbelow. A bad one, a duplicate, or one that is any item’s ownpathis dropped on its own (a path belongs to the section that links to it); the item is kept. - Labels are trimmed and stripped of control and bidirectional-text characters, then cut to 32 characters (counted as Unicode code points). An item whose label ends up empty is dropped.
- Each path must start with exactly one
/and stay inside your app. Absolute URLs, protocol-relative//hostlinks,javascript:links and paths that climb out of your app with..(including%2e%2e) are dropped. Every item links under/apps/<your-slug>/. - An item whose path repeats an earlier item’s path is dropped.
collapsedaccepts only the values in the table above. If it has any other value, the dashboard ignores that whole menu update, keeps showing the previous menu, and logs an error to the browser console. Check the console while developing.
React and Next.js
@max-ai/app-bridge 0.6.0 adds <maxcare-app-nav> to React’s JSX types as
soon as you import anything from the package, with collapsed checked against
the three values above. A Next.js App Router layout:
app/admin/layout.tsx
<AppNav /> inside <AppBridgeProvider>. The
app template ships this
pattern in src/app/admin/layout.tsx.
What stays in your app
The menu is for navigation only. Keep filters in your page, such as a facility picker or a date range, along with search and actions. Don’t rebuild what the dashboard already shows: the organization switcher and the user menu are in the dashboard sidebar, and you can read both throughmaxcare.organization() and
maxcare.user().
Users can also pin apps they use often. A pinned app moves to the bottom of
the dashboard sidebar with its own icon on the rail, and users drag pinned apps
to reorder them. While your app is open, its sections are also listed in the
dashboard’s Cmd-K palette and open the same way a sidebar click does. All of
this is the dashboard’s; you don’t need to do anything to support it.
Before your menu arrives
Until your app declares a menu, the dashboard shows your app as a single row, exactly as it does for apps that never declare one. It shows no placeholder, so apps without a menu keep working unchanged. When a new page loads in your frame, the previous page’s menu goes with it. A page that loads the bridge declares its menu again right after the handshake. A page that doesn’t (an OAuth callback, a static error page) never answers the handshake, and the dashboard drops the previous page’s sections within a second of it loading, rather than leaving them on screen.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. Its grantedPermissions holds only
the clinic’s optional opt-ins, not your effective scopes; read those from
GET /v4/marketplace/me. See Reconciliation
for the org_… id form.
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:
Listing the organization’s members
To show people by name — a per-user permissions page, an assignee picker — list the installing organization’s roster. Requires theread:members scope.
- The complete roster, not a page. There is no pagination. The listing is read from
the identity provider in pages behind the scenes, so a membership change that lands
mid-read can, rarely, leave one member out of one response. Retire someone’s grants
on
member.removed, or on absence from two consecutive reads, not on one. userIdis the App Bridge token’ssub, byte for byte: a raw, dashed uuid on every API version. It is not rewritten to theusr_…readable form thatmaxcare.user().userIdreturns, and it is not a Clerk id. Key per-user data on it and join it against thesubof a verified token.userIdcan benull. Someone just added in Clerk is listed before their Max AI user record exists (normally seconds to minutes). They cannot open your app yet either — no token can be minted for them — so there is nothing to join. Show them, and useclerkUserIdif you need a handle beforeuserIdappears.roleis Clerk’s spelling, open vocabulary (org:admin,org:member, custom role keys) — the same contract as the token’sorg_roleclaim.emailis the member’s primary email, ornullwhen they sign in with a phone number or username and have none on record.hasAppAccessis whether an organization admin granted this person access to your app. Without it they cannot open the app.- The organization is the one in
X-Organization-Id, and only if your app is installed there — the same check every data endpoint runs. Nothing else in the request selects it.
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 - Declare your sections with
<maxcare-app-nav>instead of drawing your own tab row or sidebar (see Navigation menu) - 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
