Skip to main content

App Surfaces

Give an installed app its own UI: embedded in the Talonic shell with a postMessage handshake, or standalone over OAuth PKCE, with role-capped surface tokens.

An installed app can bring its own user interface — a surface — that people open from Talonic. The manifest's surface block declares how: embed: "iframe" means the Talonic shell frames the surface at /apps/{id}/surface and hands it a session; embed: "link" (the default) means the surface opens in its own tab and signs in by itself. scopes lists the OAuth scopes a surface session carries — any of apps:read, apps:operate, records:write, and identity:read, default ["apps:read"] — and url is the surface address — taken from the manifest for a hosted app (inside its allowed origins) and from the install's surface_url for a pushed one. Publish rejects an iframe surface whose scopes name neither apps:read nor apps:operate, since such a token would reach no Apps route.

Both channels end in the same place: a short-lived bearer token for the public API, scoped to what the manifest declared and capped by the person using it. Every token issued to a person carries their workspace role, and the Apps guard admits the lower of what the scopes allow and what the role allows — a viewer opening a surface that asks for apps:operate still reaches only the read tier. The platform states the resulting tier back to the surface (read, run, operate, or configure), so a surface reads its tier instead of recomputing it and hides controls the person cannot use.

Embedded: the shell handshake

For an embedded surface, the shell names itself in the frame URL's fragment, #talonic_shell=<origin>. The surface pins that origin, posts ready, and waits for init; from then on it accepts messages only from the pinned origin and the framing window, and posts only to that origin — never * — so a page that frames the surface without being the shell can neither read nor answer the handshake. Every message is an envelope { talonic: 1, type, payload }. The token arrives in init and is renewed by a session message before it expires; there is no refresh token. Theme and locale arrive as prefs.

Handshake messages

surface → shell   ready    { sdk_version }
shell → surface   init     { api_base, app_id, workspace_id, session, user, prefs }
shell → surface   session  { access_token, expires_at, scopes, role, tier }   (renewal)
shell → surface   prefs    { theme, locale }                                   (on change)
surface → shell   renew    {}
surface → shell   navigate { run_id }        (open the Run Inspector in the shell)
surface → shell   resize   { height }        (measure a content-sized element)
POST/v1/apps/:id/session

Response (201)

{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 900,
  "expires_at": "2026-10-06T10:15:00.000Z",
  "scopes": ["apps:read"],
  "role": "member",
  "tier": "read",
  "api_base": "https://api.talonic.com",
  "surface_url": "https://app.vendor.example/embed",
  "allowed_origin": "https://app.vendor.example"
}

Standalone: OAuth with PKCE

Without a shell fragment, a surface signs in on its own: the OAuth 2.1 authorization-code flow with PKCE (S256) against the platform's /oauth/* endpoints, keeping its state in sessionStorage rather than cookies or localStorage. The install's oauth_client_id is a pre-consent: when the request names only scopes the app's active version declares in surface.scopes, and exactly one of the person's workspaces holds an install bound to that client, the code is issued without a consent page. Any other scope — or several matching workspaces — falls through to the normal consent screen, and apps:decide is never pre-consented. Ask for apps:read unless the manifest declares more; omitting scope entirely gets the legacy default set rather than the Apps scopes. A standalone token cannot be renewed in place: a 401 sends the person back through authorization.

A browser SDK for surfaces, @talonic/app-surface, wraps both channels behind one connect() call that returns the same session object either way, with helpers for runs, results, and events. It is not published to npm; it is available on request. The protocol on this page is complete on its own, so a surface can implement it directly.

TypeScript — a minimal embedded surface without the SDK

const shell = new URLSearchParams(location.hash.slice(1)).get('talonic_shell');
if (!shell) throw new Error('not framed by the Talonic shell');

let token = '';
let apiBase = '';
window.addEventListener('message', (e) => {
  if (e.origin !== shell || e.source !== window.parent) return;   // pinned origin only
  const msg = e.data;
  if (msg?.talonic !== 1) return;
  if (msg.type === 'init') { token = msg.payload.session.access_token; apiBase = msg.payload.api_base; void load(msg.payload.app_id); }
  if (msg.type === 'session') token = msg.payload.access_token;     // renewal
});
window.parent.postMessage({ talonic: 1, type: 'ready', payload: { sdk_version: 'custom' } }, shell);

async function load(appId: string) {
  const res = await fetch(`${apiBase}/v1/apps/${appId}/runs?limit=20`, { headers: { Authorization: `Bearer ${token}` } });
  render(await res.json());
}

A surface's browser calls come from its own origin, so the install's allowed_origin admits that exact origin to the API's CORS policy; it must match the host of one of the OAuth client's registered redirect URIs. Everything the surface does goes through the same public endpoints and the same tier checks as any other client — embedding grants no private capability. The app.session.issued audit event records each embedded session, and a surface can call navigate to open a run in the Run Inspector so people move between the app's own screens and Talonic's ledger without losing their place.

Frequently asked questions

Should my surface be embedded or standalone?+
Embed it when people should work inside Talonic with the shell's navigation, theme, and session handling; the shell then renews tokens for you. Use a link when the surface needs top-level navigation, its own sign-in, or a full browser tab — a framed surface can never start the OAuth redirect itself.
Why does my surface only get the read tier?+
The tier is the lower of the token's scopes and the person's workspace role. A manifest asking for apps:operate still yields read for a viewer, and apps:read yields read for an owner. Read the tier from the session rather than inferring it, and hide controls above it.
Can a machine caller mint a surface session?+
No. POST /v1/apps/:id/session is for human sessions only, because the token it mints carries the person's role. Backend work belongs to the install's service key or to a decide-granted key working decision tasks.
How do I get the surface SDK?+
The @talonic/app-surface package is not on npm; ask Talonic for it. It is optional — the handshake and the PKCE flow described here are the full protocol, and a surface can implement them directly.