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, 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 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. 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. 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:
Put the element anywhere in your DOM. It renders nothing itself. The bridge script defines it and watches its direct <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 with data-badge on the link, or badge on the item:
The count shows beside the section in the sidebar, the header tabs and the rail flyout, and above 99 it shows as 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:
The tones are 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”:
A badge label follows the same rules as a section label (up to 32 characters, never PHI) and needs 0.10.0. Leave urgency to 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 through navigation.subscribe. You must subscribe and route there. Without it the address bar moves and your screen stays put.
Don’t call 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:
Match paths follow the same rules as 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 badge shows only for a whole number above zero; any other value is dropped and the item kept. An unknown badgeTone is dropped and the count shown in the default tone.
  • Each item keeps at most 8 match paths, each checked like path below. A bad one, a duplicate, or one that is any item’s own path is 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 //host links, 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.
  • collapsed accepts 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.
Labels appear in the dashboard’s own sidebar and header. Use screen names only. Never put patient names, counts tied to a patient, or other PHI in a label.

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
Render <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 through maxcare.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.
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. 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 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:

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 the read: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.
  • userId is the App Bridge token’s sub, byte for byte: a raw, dashed uuid on every API version. It is not rewritten to the usr_… readable form that maxcare.user().userId returns, and it is not a Clerk id. Key per-user data on it and join it against the sub of a verified token.
  • userId can be null. 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 use clerkUserId if you need a handle before userId appears.
  • role is Clerk’s spelling, open vocabulary (org:admin, org:member, custom role keys) — the same contract as the token’s org_role claim.
  • email is the member’s primary email, or null when they sign in with a phone number or username and have none on record.
  • hasAppAccess is 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.
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
  • 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_id in error states so users can report issues