API Reference
Full signatures for the @todayai-labs/webview-bridge client.
createWebViewBridge
function createWebViewBridge(options?: WebViewBridgeOptions): WebViewBridgeResolves the active host channel
(window.webkit.messageHandlers.todayWebViewBridge on iOS/macOS or
window.todayWebViewBridge on Android/Linux/Windows) and returns a client bound
to it. If neither channel exists, the returned client is fully functional but
inert — see safe degradation.
interface WebViewBridgeOptions {
/**
* Override the global lookup name. Defaults to "todayWebViewBridge".
* Intended for staged channel migrations.
*/
channelName?: string
/**
* Inject a channel directly, bypassing the window lookup.
* Intended for tests and custom transports.
*/
channel?: NativeChannel
}isAvailable
isAvailable(): booleanReturns true only when a Today host channel is present. Use it to gate
host-only features. It performs no I/O and is safe to call synchronously during
render.
isAvailable()andplatformare environment hints, not a security boundary — they read page-visible globals that page JS can spoof. Rely on them for rendering and feature-gating, never for a trust decision. See Not a security boundary.
platform
readonly platform: Platform // 'ios' | 'android' | 'linux' | 'macos' | 'windows' | 'web'The detected platform, resolved synchronously at createWebViewBridge()
time — not a method, not a Promise. Safe to read during render. 'web' means no
host bridge (the same condition as isAvailable() === false). See the
Environment detection contract for how it is
resolved and what the host must inject to disambiguate shared transports.
if (bridge.platform === 'ios' || bridge.platform === 'macos') {
// Apple-specific layout
}track
track(event: string, properties?: TrackProperties): voidReports an analytics event. Fire-and-forget: returns void, never throws,
and must not be awaited. The host enriches the event with base fields and
forwards it to PostHog.
type TrackProperties = Record<string, string | number | boolean>properties accepts scalar values only. Nested array / object / null
values are dropped by the host. The public (event, properties) arguments map
onto the wire fields ename / parameters, which stay stable for the analytics
pipeline. See the track contract.
getHeaders
getHeaders(): Promise<Record<string, string>>Returns the headers the web must attach to its own fetch / XHR requests —
Authorization plus any server-driven extra headers. Never rejects: resolves
to {} when no host is present and on any transport fault. Callers (and the
embed boot path) rely on this not throwing.
const headers = await bridge.getHeaders()
// { Authorization: "Bearer …", "X-Today-Extra": "…", … }The result depends on the current page URL and login state. Outside a trusted feed domain, or when logged out,
Authorizationmay be absent. Never assume it is present. See theheaderscontract.
refreshToken
refreshToken(): Promise<RefreshTokenResult>Forces the host to mint a fresh access token. Call it when one of your own
requests returns 401. Concurrent calls are coalesced by the host into a single
real refresh, so it is safe to call from multiple in-flight requests at once.
interface RefreshTokenResult {
authorization: string // ready-to-use header value, e.g. "Bearer …"
}The result carries only authorization (a ready-to-use header value) — never the
raw token. The SDK normalises the host reply to exactly this field and ignores
any extras, keeping the secret surface minimal.
Rejects with one of three BridgeError types — distinguish them, because
only one means the user is signed out:
| Rejection | Cause | Force re-auth? |
|---|---|---|
BridgeUnavailableError | No native host | No — no host |
BridgeError | Host rejected (terminal auth failure) | Yes |
BridgeProtocolError | Host replied with a malformed / empty result | No — host bug |
A BridgeProtocolError is a protocol/serialization bug on the native side, not
a signed-out user — do not force re-authentication on it. Always wrap the call:
try {
const { authorization } = await bridge.refreshToken()
retryWith(authorization)
} catch (e) {
if (e instanceof BridgeProtocolError) {
// malformed reply — host bug, not a real auth failure; do not re-auth
}
// BridgeUnavailableError → no host; plain BridgeError → terminal auth failure
}See the refreshToken contract and
Error handling.
emit
emit(type: string, payload?: Record<string, unknown>): voidPosts a custom app-specific event through the same native channel.
Fire-and-forget: returns void, never throws, and must not be awaited.
bridge.emit('todayPage.firstBatchRendered', {
schemaVersion: 1,
requestId: 'refresh-...',
batchId: 'batch-a',
})The SDK sends one flat wire object:
{ ...payload, type }If payload also contains a type field, the first argument wins.
Use emit for additive, app-specific events whose result is not needed by the
web page. The host should resolve undefined after accepting the event. See the
Today Page V2 contract for the native Today tab
lifecycle events built on this helper.
getAgentName
getAgentName(): Promise<string | null>Reads the current account's agent name from the native host using
{ type: 'agent.getName', schemaVersion: 1 }. Returns a trimmed name, or null
when unavailable or unsupported. Rejects malformed replies, transport failures,
and reads exceeding three seconds. Subscribe to agent.nameChanged through
onNativeEvent and re-read on visible exposure; see the
Agent Name contract.
onNativeEvent
onNativeEvent(handler: (event: NativeEventMessage) => void): () => voidSubscribes to native → web lifecycle events delivered through
todayWebViewBridge:nativeEvent. Returns an unsubscribe function.
const unsubscribe = bridge.onNativeEvent((event) => {
if (event.type === 'webView.visibilityChanged') {
// event.visible is the native-visible state
}
})The SDK only validates that event.type is a non-empty string. Unknown event
types are ignored by consumers, not rejected by the transport.
establishSession
establishSession(options?: { refresh?: boolean }): Promise<boolean>Asks the host to mint the same-origin embed_bearer session cookie itself.
The bearer is written into the WebView cookie store natively and is never
returned to JS — the call resolves a plain boolean, not a token. Pass
{ refresh: true } to force a fresh server token before the mint (the 401
path).
Never throws. Resolves false with no host, an unsupported (older) host, or
a failed mint — so the caller falls back to the header bootstrap:
// Preferred bootstrap: native mint, header exchange as the fallback.
if (!(await bridge.establishSession())) {
await bootstrapFromHeaders() // getHeaders() → POST /api/embed/auth/session
}
// After a 401: force a refresh, then re-mint.
if (!(await bridge.establishSession({ refresh: true }))) {
await refreshFromHeaders() // refreshToken() → POST /api/embed/auth/session
}This is the hardened path: because the cookie is HttpOnly, the bearer never
enters page JS at all. See the
establishSession contract and
the Security model.
presentLiveWidgetCreation
presentLiveWidgetCreation(): Promise<LiveWidgetCreationPresentationResult>Asks an embedded native host to present its existing Live Widget creation flow.
The host must advertise live_widget_creation.v1; callers should gate the Add
affordance with both isEmbeddedSurface() and supports(...).
if (bridge.isEmbeddedSurface() && bridge.supports(LIVE_WIDGET_CREATION_CAPABILITY)) {
const { presented } = await bridge.presentLiveWidgetCreation()
// `presented` acknowledges the sheet presentation, not widget creation.
}The method rejects with BridgeUnavailableError when no host exists,
BridgeUnsupportedError when the capability is absent, and
BridgeProtocolError when the host reply is malformed. { presented: false }
is a valid response when the native presenter is busy or unavailable. See the
normative Live Widget Creation contract.
presentNativeDialog
presentNativeDialog(options: NativeDialogOptions): NativeDialogSessionReturns a session handle synchronously and starts a capability-gated iOS/Android native dialog request immediately. The SDK generates a fresh UUID; the caller cannot supply or accidentally reuse it.
const session = bridge.presentNativeDialog({
variant: 'prompt',
title: 'Rename widget',
actions: [
{ id: 'cancel', title: 'Cancel', role: 'cancel' },
{ id: 'save', title: 'Save', role: 'default' },
],
fields: [{ id: 'name', label: 'Name', initialValue: currentName }],
})
const result = await session.result
if (result.outcome === 'action' && result.actionId === 'save') {
rename(result.values?.name ?? '')
}session.sessionId is available before Native answers. Every terminal result must echo that exact
ID; a mismatch rejects with BridgeProtocolError. session.dismiss() targets this session and is
equivalent to dismissNativeDialog(session.sessionId).
The host must advertise native_dialog.v1. Missing host and missing capability reject
session.result with BridgeUnavailableError and BridgeUnsupportedError, respectively. See the
normative Native Dialog contract.
dismissNativeDialog
dismissNativeDialog(sessionId: string): Promise<NativeDialogDismissResult>Requests dismissal of one exact native dialog session. The acknowledgement always echoes
sessionId; dismissed is true only when the host found the pending or active session and initiated
dismissal. Unknown or already-settled IDs return dismissed: false. The original presentation result
then settles once with dismissed/programmatic.
fetch
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>A convenience wrapper around the platform fetch that:
- calls
getHeaders()and merges the result into the request headers (your explicitinit.headerswin over injected ones on this initial request); - issues the request;
- if the response is
401and the request is replayable, callsrefreshToken()and retries once with the refreshedAuthorization(which takes precedence over the caller header on the retry); - returns the final
Response.
A request is not replayable — and so the 401 is returned without a retry —
when input is a Request object, or when init.body is a ReadableStream.
Both are consumed by the first fetch and cannot be re-sent, so the SDK returns
the 401 rather than throwing an opaque "body already used" error. For a
retryable authenticated request, pass a URL string with a string / Blob /
ArrayBuffer body.
When no host is present it is just fetch — no headers, no retry. This is the
recommended entry point for authenticated requests; reach for the lower-level
methods only when you need to control merging or retry yourself.
const res = await bridge.fetch('/feed/api/cards', { method: 'GET' })Errors
All rejections are instances of BridgeError. Missing host and capability cases use
BridgeUnavailableError and BridgeUnsupportedError; a malformed host reply uses
BridgeProtocolError. See
Error handling for the full taxonomy.