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)/v1/apps/:id/sessionResponse (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.
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.