Dashboard UI

Dashboard UI

Your app can ship a full React UI that renders right inside the Telenow dashboard — pages in the sidebar, panels embedded in built-in screens, charts over your own data, buttons that place calls or send WhatsApp. The clinic-crm example is exactly this: a working CRM (patients, appointments, visits, reports) that lives in the dashboard and reads/writes the same records the voice agent touches during calls.

This page covers how the UI runs, the window.telenow bridge and its React hooks, UI slots, realtime, the design system, and the per-install settings form.

How your UI runs: the sandboxed iframe

Your built UI runs in a sandboxed iframe on an opaque origin (the iframe is sandbox="allow-scripts allow-forms allow-popups" — note: no allow-same-origin). That has one big consequence you should internalise:

  • There are no dashboard cookies or tokens in the iframe.
  • There are no API keys in the browser — never ship one.
  • Your code cannot call the Telenow API (or any third-party API) directly.

Instead, the dashboard injects a global object, window.telenow, called the bridge. Every method on it is relayed to the parent window (via postMessage) and performed under the signed-in user, scoped to your app and the current org, and enforced server-side. So telenow.data.list('patient') returns only this org's patients, only for your app, only if the user is allowed to see them — and your code never holds a credential to make that go wrong.

The telenow/react package wraps the bridge in hooks so you rarely touch window.telenow directly.

npm install telenow

Reminder: the npm package is telenow (unscoped). It is not @telenow/app.

Mounting and routing

Your bundle entry mounts a root React component into the host-provided #root element:

// ui/index.tsx
import { mount } from 'telenow/react';
import App from './App';

mount(App);

A user navigates your app through pages you declare in the manifest. Each page with menu: true becomes a sidebar menu item; uninstalling the app removes it. You declare them under ui.pages in telenow.app.json:

"ui": {
  "entry": "ui/index.tsx",
  "pages": [
    { "id": "patients",     "title": "Patients",     "icon": "users",    "menu": true },
    { "id": "appointments", "title": "Appointments", "icon": "calendar", "menu": true },
    { "id": "reports",      "title": "Reports",      "icon": "chart",    "menu": true }
  ]
}

menu defaults to false. A non-menu page is not a sidebar item — it is reachable in-app only: you route to it internally (e.g. a "details" sub-view your own code switches to) or you surface it through a slot extension (see UI extension slots). group is the submenu label that groups several menu pages under one heading.

Page icons — the whitelist

pages[].icon (and extensions[].icon) must be one of a fixed 27-name whitelist. The dashboard resolves the string to a bundled icon; any unknown name silently falls back to the default box icon (so a typo never errors — it just shows the wrong icon). The valid names are:

bell  book  box  briefcase  calendar  chart  clipboard  clock  card  file
heart  home  inbox  grid  list  mail  map  message  package  phone
settings  cart  star  stethoscope  ticket  user  users

There is no boxes or sticky-note icon — those fall back to box. Use box and file. The canonical list also lives in the Manifest reference.

Your single React bundle renders all pages. When the user clicks a menu item, the host re-renders your iframe with a new context. Read it with useTelenowContext() and route internally on page:

import { useTelenowContext } from 'telenow/react';

export default function App() {
  const { page } = useTelenowContext(); // 'patients' | 'appointments' | 'reports' | …
  if (page === 'patients')     return <Patients />;
  if (page === 'appointments') return <Appointments />;
  return <Reports />;
}

useTelenowContext() returns { appId, pageId, page, title, user? }. page is an alias of pageId — the active manifest page id your app should render. See Manifest reference for every pages[]/extensions[] field.

Every shape on this page is shown in full in the Response reference — real JSON, taken from the code that builds it.

The bridge catalogue

Everything below is available as a hook from telenow/react and as a raw namespace on telenow.* from telenow/browser. Each capability needs the matching scope declared in your manifest (see Scopes & permissions); a missing scope makes the call reject server-side. Many communication/activity ops also enforce an org-role gate — see Role gating below.

useUser() — who's looking, for RBAC gating

import { useUser } from 'telenow/react';

function CallButton({ phone, onCall }: { phone: string; onCall: () => void }) {
  const { user, can } = useUser();
  // The canonical RBAC-for-UI one-liner (mirrors the host's own gate):
  const canDial = can('manage_agents') || user?.role === 'owner' || user?.role === 'admin';
  if (!canDial) return null; // viewers don't see the button
  return <button onClick={onCall}>Call {phone}</button>;
}

user is { id, role, permissions[], name?, email? }. name and email only appear when your app declared the user:profile scope. can() is for UI gating only — it decides what you render. Real permission enforcement always happens server-side, so never treat a hidden button as a security boundary.

can(p) checks user.permissions.includes(p). The host only ever populates permissions from this fixed RBAC list (RBAC_PERMS):

view  manage_agents  manage_numbers  manage_members  manage_billing
manage_workplace  monitor_calls  manage_api_keys  manage_recordings
manage_publish  manage_org

Any other string (e.g. can('admin') or can('owner')) is not a valid permission name and always returns false — gate on role for those instead. user.role is one of owner | admin | developer | viewer | member | null.

useSettings() — non-secret per-install config

import { useSettings } from 'telenow/react';

function Header() {
  const settings = useSettings();
  const clinicName = settings.get<string>('clinic_name') ?? 'Your Clinic';
  return <h1>{clinicName}</h1>;
}

useSettings() returns { all(), get(key) } and is synchronous — values are injected when the iframe loads. These are the non-secret values an admin filled in on the install's settings form (see Settings form below). Secret settings are never here — they stay server-side only.

Settings are a static snapshot. The non-secret values are baked into the iframe's HTML (srcDoc) at load time — there is deliberately no bridge op to fetch settings at runtime. So useSettings() will not update reactively when an admin changes a setting: the new value only appears after the iframe re-loads (re-navigate to the page / reload the app). Plan UI that depends on a setting accordingly.

useObjects() / telenow.data — your object store

This is the workhorse. Your app has a schemaless object store, scoped to your app + org. useObjects(type, query?) gives you a live list plus mutators that refresh it:

import { useObjects } from 'telenow/react';

function Patients() {
  const { data, loading, error, create, update, remove } = useObjects('patient');
  if (loading) return <p>Loading…</p>;
  return (
    <ul>
      {data.map((p) => <li key={p.id}>{p.data.name} — {p.data.phone}</li>)}
      <li><button onClick={() => create({ name: 'Sarah Chen', phone: '+14155550142', status: 'active' })}>Add</button></li>
    </ul>
  );
}

Each row is an AppRecord: { id, appId, objectType, data, createdAt, updatedAt? } — your fields live under .data.

useObjects is equality-only. Its signature is useObjects<T>(objectType, query?: Record<string, string>) — the query is plain string equality and it passes no opts (no orderBy, limit, view, expand, or search). The moment you need operators ($gt, $in, $contains…), ordering, limits, a saved view, relation expansion, or semantic search, drop to getTelenow().data.list(type, query, opts) and manage the result state yourself (useState + useEffect). See Data & objects for the full query/opts model.

For anything beyond a plain live list, use the raw telenow.data namespace, which exposes list, count, create, update, remove, and subscribe:

import { getTelenow } from 'telenow/browser';
const { data } = getTelenow();

// Filters: equality by default, or operator objects.
// $eq $ne $gt $gte $lt $lte $in (array) $contains (substring)
const thisWeek = await data.list('appointment',
  { start: { $gte: '2026-07-01', $lt: '2026-07-08' } },
  { orderBy: { field: 'start' }, limit: 50 },          // QueryOpts
);

const { count } = await data.count('appointment', { status: 'scheduled' });

QueryOpts (the third argument to list) supports:

OptionTypeEffect
orderBy{ field, desc?, numeric? }sort by a field (numeric:true compares as numbers)
limitnumbercap the result count
viewstringapply a manifest-declared saved view (its stored filter + sort)
expandstring[]embed relations — each result gets {field}__expanded
searchstringnatural-language ranking (needs the object's semantic: true)

Field shapes (relations, computed, views, semantic) and how filters map to the REST query string are documented in Data & objects.

Realtime. Instead of polling, subscribe to live changes. data.subscribe needs no extra scope (it's the same trust as data.list — your own data). It fires onChange on every create/update/delete and resolves to an unsubscribe function — always call it on unmount (the host tears its socket down on unmount, but you should clean up your own handler):

import { useEffect, useState } from 'react';
import { getTelenow } from 'telenow/browser';

function LiveCount() {
  const [n, setN] = useState(0);
  useEffect(() => {
    let off = () => {};
    getTelenow().data.subscribe('appointment', (change) => {
      // change = { event: 'created' | 'updated' | 'deleted', objectType, id, data | null }
      if (change.event === 'created') setN((c) => c + 1);
    }).then((unsub) => { off = unsub; });
    return () => off();
  }, []);
  return <span>{n} new today</span>;
}

useAgents() — list agents, build from templates

import { useAgents } from 'telenow/react';
import { getTelenow } from 'telenow/browser';

function AgentPicker() {
  const { agents, loading } = useAgents(); // [{ id, name }], needs agents:read + canViewActivity
  if (loading) return <p>Loading agents…</p>;

  const build = () => getTelenow().agents.createFromTemplate('front-desk'); // owner/admin
  return (
    <>
      <button onClick={build}>Build the Front Desk agent</button>
      {agents.map((a) => <div key={a.id}>{a.name}</div>)}
    </>
  );
}

createFromTemplate(templateId) builds a real voice agent from one of your manifest's agents[] templates (auto-bound to your app's tools); createTeamFromTemplate(teamId) builds a whole multi-agent team. Their return shapes:

// createFromTemplate(templateId)
{ agentId: string, kind: 'single' | 'flow' }

// createTeamFromTemplate(teamId)
{
  teamId: string,
  entryAgentId: string | null,
  agents: { ref: string, agentId: string | null, kind: 'single' | 'flow' }[]
}

Both navigate the dashboard away as a side effect. On success the host immediately routes to the new agent's builder — /agents/:id/flow for a flow agent, /agents/:id/edit for a single agent (for a team it routes to the entry agent). Your page unmounts, so don't expect to keep rendering after the call resolves.

Both require an owner/admin role. Beyond the agents:read scope they require the manage_agents role gate (canCommunicate, below). A viewer with agents:read can list() agents but cannot build from a template.

See Bundled agents & teams.

createFromTemplate gives you an agentId. That is not enough to reach the agent from outside the dashboard — the share page and the widget resolve an agent by its slug, not its id, and a freshly created agent isn't public yet. So an app can build a perfectly good agent that nothing it hands the user can actually call. These two methods close that gap:

import { getTelenow } from 'telenow/browser';
const telenow = getTelenow();

// 1. Build the agent (owner/admin; navigates away unless you pass open:false).
const { agentId } = await telenow.agents.createFromTemplate('front-desk', { open: false });

// 2. Put it on the open internet — returns the link immediately.
const link = await telenow.agents.setPublic(agentId);
// { isPublic: true, slug: 'a1b2c3d4e5f6',
//   publicUrl: 'https://telenow.ai/p/a1b2c3d4e5f6',
//   embedSnippet: '<script src="…/widget.js" data-slug="a1b2c3d4e5f6"></script>' }

// 3. Later, just read it back — this changes nothing.
const current = await telenow.agents.publicLink(agentId);
ScopeRole gate
agents.publicLink (read)agents:readcanViewActivity
agents.setPublic (write)agents:writecanManageSettings — owner/admin

Exposing an agent to the internet is a governance decision, so setPublic holds the owner/admin bar even though publicLink doesn't. Gate the button in your UI on useUser() rather than letting a member click into a permission error.

Notes that matter when you build on this:

  • The slug is stable. setPublic(id, false) takes the agent private but keeps the slug, so re-enabling revives links you already shared. Branding, the access code, lead capture and the embed allow-list all survive the toggle — only isPublic changes.
  • slug is what links.mint({ action: 'agent_call' }) expects in context.slug. That's the usual reason to call publicLink at all: you're minting a per-recipient link pointed at your own agent. See telenow.links.
  • Same shape as the app-key REST plane. GET/PUT /api/app-agents/:id/public return this identical object — deliberately, so a backend and a UI never describe the same link two different ways. Full REST reference: Public link & embeddable widget.
  • In local dev preview these return a mock mock-agent-<id> slug so a mint → /l/<token> round-trip still looks real. Don't hand a dev-mode publicUrl to anyone.

useCall() / useCallHistory() — place calls, read history

import { useCall, useCallHistory } from 'telenow/react';

function CallPanel({ agentId, phone }: { agentId: string; phone: string }) {
  const { initiate } = useCall();                  // calls:initiate + canCommunicate
  const { calls, reload } = useCallHistory();      // calls:read + canViewActivity
  return (
    <>
      <button onClick={() => initiate(agentId, phone)}>Call {phone}</button>
      <button onClick={reload}>Refresh history</button>
      <p>{calls.length} calls</p>
    </>
  );
}

⚠️ useCall().initiate silently drops context variables. The hook is wired as a 2-argument call — initiate: (agentId, phone) => calls.initiate(agentId, phone) — so any third variables argument you pass is never forwarded. If you need to fill the agent's {placeholder} context variables, you must use the raw bridge, which takes the third argument:

import { getTelenow } from 'telenow/browser';

// Variable-filled call — use the RAW bridge, not useCall().initiate:
await getTelenow().calls.initiate(agentId, '+16465550198', {
  patient_name: 'Michael Torres',
  appointment_time: '4:30 PM',
});

A plain useCall().initiate(agentId, phone) (no variables) is fine.

Call history shape. useCallHistory(filters?) returns { calls, loading, error, reload }; the raw telenow.calls.history(filters?) resolves to a CallRecord[]. The host projects its internal camelCase rows to a documented snake_case shape:

FieldTypeNotes
idstringcall / session id
agent_idstringthe agent that handled the call
agent_namestringdisplay name
channelenumtelephony | softphone | web_call | whatsapp | web_chat | simulation
directionenuminbound | outbound | web | whatsapp
from_numberstring | nullcaller number
to_numberstring | nullcallee number
statusstringe.g. completed, failed, in-progress, no-answer
duration_secnumbercall length in seconds
start_timestringISO timestamp
end_timestring | nullISO timestamp (null while in progress)

filters is a Record<string, string> — the two you'll commonly use are { number?, sessionId? } (filter by a mobile number on either leg, or look up exact session id(s), comma-separated for bulk).

useWhatsapp() — send WhatsApp messages

import { useWhatsapp } from 'telenow/react';

async function sendReminder(phone: string, name: string) {
  const { channels, send } = useWhatsapp(); // whatsapp:send + canCommunicate
  const chs = await channels();             // [{ id, kind }]
  if (!chs.length) throw new Error('No WhatsApp channel configured');
  await send(chs[0].id, phone, `Hi ${name}, this is your clinic — see you at your appointment!`);
}

useSoftphone() — open the dialer prefilled

import { useSoftphone } from 'telenow/react';

function DialButton({ phone }: { phone: string }) {
  const { dial } = useSoftphone(); // softphone:dial + canCommunicate — opens the dashboard dialer
  return <button onClick={() => dial(phone)}>Dial in softphone</button>;
}

The call runs in the dashboard's own softphone, not your app (WebRTC stays in the trusted dashboard; the user presses Dial).

useSession() — a token for your backend

import { useSession } from 'telenow/react';

async function callMyBackend() {
  const { token } = useSession();          // session:token (no role gate)
  const { token: jwt } = await token();    // short-lived JWT, aud: app:<id>
  // send `jwt` to YOUR backend; verify it there with verifyAppToken(...)
}

Use this when your app has its own external backend and needs to trust which user/org/app is calling it. Verification is covered in External backends.

useHttp() — call a third-party API through the proxy

import { useHttp } from 'telenow/react';

async function lookupZip(zip: string) {
  const { fetch } = useHttp(); // needs http:api.zippopotam.us in scopes (no role gate)
  const res = await fetch({ url: `https://api.zippopotam.us/us/${zip}` });
  return JSON.parse(res.body); // res = { status, headers, body (text, ≤1 MB) }
}

The browser never touches the third party directly (no CORS, no leaked keys). The proxy is HTTPS-only, SSRF-guarded, follows no redirects, caps the body at 1 MB, is rate-limited per org and circuit-broken per host. The supported methods include GET, POST, PUT, PATCH, DELETE, and HEAD. The target host must be in your http:<host> scopes. Add connection: "<provider>" to inject a stored, auto-refreshed credential server-side (needs connection:<provider>) so a secret never enters your code.

telenow.stream.subscribe — live call frames

Watch an in-progress call's events (lifecycle + partial transcript). The host holds the WebSocket; your iframe never opens a socket. It needs the calls:read scope + canViewActivity role, plus a live, in-progress sessionId — the host mints a single-use ticket and opens the socket on your behalf.

import { useEffect } from 'react';
import { getTelenow } from 'telenow/browser';

function LiveTranscript({ sessionId }: { sessionId: string }) {
  useEffect(() => {
    let off = () => {};
    getTelenow().stream.subscribe(sessionId, (frame) => {
      // frame = { topic, sessionId, data }
      // topics: call.turn, call.transcript_partial, call.barge_in,
      //         call.silence, call.dtmf, call.node_entered
      console.log(frame.topic, frame.data);
    }).then((unsub) => { off = unsub; }); // resolves to an unsubscribe fn
    return () => off();                   // ALWAYS unsubscribe — the host tears all sockets down on unmount
  }, [sessionId]);
  return <div id="live" />;
}

telenow.files — per-app blob storage

Private, org+app-scoped blob storage — strictly your app's files in the installing org. put/delete need files:write; get/list need files:read. There is no role gate.

import { getTelenow } from 'telenow/browser';
const { files } = getTelenow();

await files.put('exports/q3.csv', csvString);     // -> { path, size }
const list = await files.list('exports/');        // -> TelenowFileMeta[]
const bytes = await files.get('exports/q3.csv');  // -> ArrayBuffer (RAW bytes)
const text = new TextDecoder().decode(bytes);     // decode bytes → string yourself
await files.delete('exports/q3.csv');             // -> { deleted: boolean }

Return shapes (exact):

MethodReturns
list(prefix?)TelenowFileMeta[] = { path, contentType?, sizeBytes, updatedAt }[]
put(path, body){ path: string, size: number }
get(path)ArrayBuffer — the raw bytes, NOT a JSON envelope. Use new TextDecoder().decode(...) to read a string.
delete(path){ deleted: boolean }

Notes:

  • Uploads are always sent as Content-Type: application/octet-stream — the platform stores the blob's contentType as octet-stream regardless of what your body actually is. If you need to remember a logical content type, encode it into the path (e.g. exports/q3.csv) or your own metadata object.
  • path is a logical key/ segments are preserved (so you can organise into exports/, imports/2026/, …). list(prefix?) filters by that key prefix.
  • Limits (25 MB/file, 1 GB total, 10,000 files per app) are covered in Limits & quotas.

telenow.ai — the platform AI gateway

Run an LLM without shipping a key or standing up a backend. Needs ai:llm.

const { ai } = getTelenow();
const res = await ai.llm({
  tier: 'smart',                 // coarse tier, not a model id — orgs differ
  temperature: 0.4,
  maxTokens: 900,
  messages: [{ role: 'user', content: 'Summarise this resume in 40 words:\n' + text }],
});
res.text;                        // the completion
  • This spends the installing org's money. Every call bills their wallet at the org's own prices. Say so in your listing; an admin will ask.
  • Ask for a tier, not a model. tier maps to whatever that org has configured, including BYOK. A hardcoded model id breaks on orgs that don't have it.
  • ai.llmStream(request, onToken) is the same call with per-token callbacks.
  • ai.tts is REST-only (POST /api/app-ai/tts, scope ai:tts) — there is no ai.tts() on the bridge.
  • There is no STT endpoint for apps. ai:stt validates but grants nothing; for live call audio use calls:transcribe:live.

Pair it with files.extractText() to go from an uploaded document to a summary without bundling a PDF parser:

const { text, truncated } = await files.extractText('resumes/navin.pdf');

truncated is true when the document was longer than the gateway's input limit — the text is cut to what ai.llm can accept, since that is what it is for.

Mint a URL a candidate, customer or hiring manager can open with no account. links:write to mint, links:read to list or revoke.

const link = await telenow.links.mint({
  action: 'agent_call',
  targetType: 'candidate',
  targetId: row.id,
  singleUse: true,
  code: '481027',                    // optional; send it separately from the link
  ttlSecs: 7 * 24 * 3600,
  context: { slug: agentSlug, variables: { candidate_name: row.data.name } },
});
link.url;   // https://<dashboard>/l/<token> — the ONLY time the token is returned

The whole point is that context is captured server-side at mint time: the recipient cannot alter it. Passing the same data as query parameters would be spoofable, and one recipient could attach their session to somebody else's record.

Three gotchas that cost real debugging time:

  • Store link.url on your own row. links.list() never returns tokens. Re-minting to "get it back" issues a second link and kills the first.
  • Mint before revoking when replacing a link. Minting can fail; a recipient holding nothing is worse than one holding a stale link.
  • context is served to an unauthenticated page. No secrets, no internal ids.

Full semantics — the three action types and what each renders — are in Scopes.

Role gating: scopes aren't the whole story

Beyond the manifest scope check, the host enforces an org-role gate on the communication/activity ops, mirroring the dashboard's own gating (the backend is still the hard authority on every relayed call). Two derived gates:

const canCommunicate = can('manage_agents') || role === 'owner' || role === 'admin';
const canViewActivity = can('view') || canCommunicate;
  • canCommunicate gates: agents.createFromTemplate, agents.createTeamFromTemplate, calls.initiate, whatsapp.channels, whatsapp.send, softphone.dial.
  • canViewActivity gates: agents.list, calls.history, stream.subscribe.
  • No role gate at all: session.token, files.*, http.fetch (telenow.http), data.*, data.subscribe.

So a viewer who has the right scope can still be refused — the call rejects with "your role does not permit this action". Render-gate these features with the same one-liners so the user doesn't hit a dead button.

Quick reference

Hook / namespaceScopeRole gateWhat it does
useTelenowContext() / telenow.contextactive page, app id, user
useUser() / telenow.user(user:profile for name/email)signed-in user + can() RBAC gate
useSettings() / telenow.settingsnon-secret per-install config (static snapshot)
useObjects() / telenow.dataobjects:<type>object store CRUD + count + subscribe
useAgents().list / telenow.agents.listagents:readcanViewActivitylist agents
agents.createFromTemplate/createTeamFromTemplateagents:readcanCommunicatebuild a real agent/team (navigates away)
telenow.agents.getConfigagents:config:readcanViewActivityread a bound agent's full setup — prompt, model, voice, behaviour, flow
telenow.agents.updateConfigagents:config:write:<group> per group touchedcanCommunicatewrite settings back (groups)
useCall() / telenow.calls.initiatecalls:initiatecanCommunicateplace an outbound call
useCallHistory() / telenow.calls.historycalls:readcanViewActivityread call history
useWhatsapp() / telenow.whatsappwhatsapp:sendcanCommunicatelist channels, send messages
useSoftphone() / telenow.softphonesoftphone:dialcanCommunicateopen the dialer prefilled
useSession() / telenow.sessionsession:tokenmint a backend identity JWT
useHttp() / telenow.httphttp:<host> (+ connection:<provider>)proxied third-party HTTP
telenow.calls.get(sessionId)calls:readcanViewActivityone call in full: summary + post-call analysis + transcript
telenow.stream.subscribecalls:readcanViewActivitylive call event frames
telenow.data.subscriberealtime object-change frames
telenow.filesfiles:read / files:writeper-app blob storage
telenow.files.extractTextfiles:readplain text out of a stored .pdf / .docx / .txt / .md
telenow.ai.llm / ai.llmStreamai:llmplatform LLM, billed to the installing org's wallet
telenow.ai.modelsany ai:*coarse tiers + the org's model catalog, for setup UIs
telenow.links.mintlinks:writecanCommunicatemint a tokenized public link (details)
telenow.links.list / links.revokelinks:readlist / kill minted links (never returns tokens)
telenow.members.listmembers:readorg member roster — id, name, email, role
telenow.calls.opencalls:readcanViewActivityopen a call's own page — recording, transcript, timeline
agents.publicLinkagents:readcanViewActivityread an agent's public slug + share URL + embed snippet
agents.setPublicagents:writecanManageSettingsput an agent on / take it off the open internet (owner/admin)
telenow.campaigns.addTargetscampaigns:writecanCommunicatepush mapped rows into an existing campaign (≤1000 per call)
telenow.campaigns.getcampaigns:writecanViewActivityread one campaign
telenow.campaigns.listcampaigns:writecanViewActivitythe org's campaigns (id, name, status) — for a picker
telenow.connector.connections— (server-gated)canViewActivityconnections serving a capability, each with its account
telenow.connector.invokeconnection:<provider>canCommunicaterun a connector action with the org's credential
telenow.ui.openModal / closeModalre-host your own page as a modal over the host
telenow.ui.refreshtell the host its data changed so its tables re-read
telenow.ui.toasta message in the host's own toast

telenow.connector — reach a connected app, as a named account

Call an integration the org connected (Google Sheets, a CRM) by capability. The platform resolves which connection serves it and injects the credential server-side, so your app never holds an OAuth token and never needs the vendor's API.

// Which accounts can serve this? An org may hold several Google connections.
const { connections } = await telenow.connector.connections('sheets.list_rows');
// → [{ id, provider: 'google', label: 'Google', status: 'active', account: '[email protected]' }]

const { result } = await telenow.connector.invoke(
  'sheets.list_rows',
  { offset: 0, limit: 200 },
  { binding: { spreadsheet_id: '1AbC…', sheet_name: 'Leads' },
    connectionId: connections[0].id },
);

Prefer this over http() for anything a connector already covers. Besides the token, it survives provider variants: sheets.* is served by both the unified google connector and the older google_sheets one, but only the latter is reachable through the HTTP proxy — so a proxy-based app works for some orgs and silently fails for others.

Show the account, and pin it. A spreadsheet shared with one Google account is invisible to the others, so "it can't find my sheet" is unanswerable unless your UI names the identity. Store the chosen connectionId alongside whatever you're syncing and pass it every time — otherwise resolution picks (active, most recently updated), and a later run can silently read as a different account.

binding carries the action's bind settings — which spreadsheet, which tab. Write them flat ({ spreadsheet_id, sheet_name }); the nested { settings: {…} } form an agent tool stores is also accepted.

Needs the connection:<provider> scope — for every provider that could serve you. Consent is provider-specific while resolution is alias-tolerant, so an app naming only connection:google is refused outright on an org that connected google_sheets.

telenow.ui — behave like part of the dashboard

Your panel is a guest in someone else's screen. These let it use the host's chrome instead of drawing its own inside a box:

// A slot panel is too cramped for real work — escalate to a modal.
await telenow.ui.openModal('import', { campaignId }, { size: 'lg' });

// After you change host data, say so — otherwise the user watches a stale table.
await telenow.campaigns.addTargets(campaignId, rows);
await telenow.ui.refresh('campaign-targets');
await telenow.ui.toast(`Added ${rows.length} targets`);
await telenow.ui.closeModal();

refresh takes one of campaigns · campaign · campaign-targets · calls · agents — an app can't force arbitrary refetches across the dashboard. addTargets already refreshes the campaign it wrote to, so the explicit call above is only needed when you changed something else.

Batch large imports. addTargets accepts at most 1000 targets per call — the batch crosses postMessage as one frame and then one HTTP body. Importing a 20k-row sheet means paging; the cap fails loudly on the first oversized call rather than stalling the tab.

UI extension slots

Beyond your own sidebar pages, you can render one of your pages into a built-in dashboard surface — for example, an "Agent insights" panel right on the agents page. You declare these under ui.extensions[], each pointing a slot at one of your page_ids.

The server supports seven known slots — do not invent others:

SlotWhere it renders
call_detail_panela panel on a call's detail page
dashboard_widgeta widget on the org dashboard
agent_builder_panela panel in the agent builder/editor
agents_overview_panela panel on the agents list page
call_list_panela panel on the call history page
softphone_call_panela strip above the live softphone controls
campaign_targets_panela panel beside a campaign's target importer (context: campaignId, agentId)

SDK 0.5.0 types all seven slots, ui.contributions, and the connector / campaigns / ui bridge namespaces — no casts needed. On an older SDK you may need as any on the newer slots; the manifest validates and installs either way.

page_id, not pageId: in the manifest JSON the extension key is snake_case page_id. (The server now also accepts the SDK's camelCase pageId, but prefer page_id.)

The clinic-crm app surfaces its Reports page as a panel on the agents overview:

"ui": {
  "entry": "ui/index.tsx",
  "pages": [
    { "id": "reports", "title": "Reports", "icon": "chart", "menu": true }
  ],
  "extensions": [
    { "slot": "agents_overview_panel", "page_id": "reports", "title": "Agent insights" }
  ]
}

When the platform mounts your page into a slot, your app receives that page id as context.page — so the same if (page === 'reports') branch you already wrote renders in both the sidebar and the panel. A slot-embedded page may also receive extra read-only context (e.g. a callId when mounted into call_detail_panel) merged into telenow.context. This is how a non-menu page becomes reachable: you don't list it in the sidebar, you surface it through a slot.

Contributions — UI the platform draws for you

A slot embeds your page in an iframe. A contribution is the opposite: you declare what you want and the platform renders it with its own components. That reaches the places an iframe structurally cannot — a cell inside the host's table, an item in its row menu, its empty state — and because there is no foreign document there is nothing to style-match, no theme to sync, and no extra iframe to pay for.

Use a contribution for the entry point, and a page (usually as a modal) for the work itself.

"ui": {
  "entry": "ui/index.tsx",
  "pages": [{ "id": "import", "title": "Import from Sheets" }],
  "contributions": [
    { "kind": "list_action", "surface": "campaign_targets", "id": "add-from-sheet",
      "label": "Add from Google Sheet", "pageId": "import", "modal": true },
    { "kind": "column", "surface": "campaign_targets", "id": "source",
      "label": "Source", "align": "left" }
  ]
}

Kinds

kindRenders asNeedsStatus
list_actiona native button in the host's list toolbarlabel, pageId✅ rendered
columnan extra column in the host's tablelabel (the header)⏳ not yet
row_actionan item in the host's per-row menulabel, pageId⏳ not yet
bulk_actionan item in the host's bulk-action menulabel, pageId⏳ not yet

Only list_action is drawn today. The other three validate and install cleanly and then appear nowhere — publish preflight warns (CONTRIBUTION_UNRENDERED) so you find out at your keyboard rather than from a customer. They need a batched host→app value fetch, and will land together.

Surfaces: campaign_targets · campaigns_list · calls_list · agents_list

Every field is validated at publish: unknown kind or surface, a duplicate id, a missing label, or a pageId that doesn't exist all fail the upload. A contribution that resolved to nothing would render as nothing — silently — which is the hardest app bug to diagnose from the outside, so the platform refuses to ship one.

id is a stable key, not a label — the host uses it as a React key and sends it back when the user activates the contribution. Rename the label freely; changing the id makes it a different contribution.

Settings form

Your manifest's settings[] becomes an admin-configured form on the install. Non-secret values reach your UI via useSettings(); secret values are encrypted at rest and injected server-side only — they never reach the iframe.

"settings": [
  { "key": "clinic_name", "label": "Clinic name", "type": "text", "required": true },
  { "key": "reminders",   "label": "Send reminders", "type": "boolean", "default": true },
  { "key": "api_key",     "label": "External API key", "type": "secret" }
]
const settings = useSettings();
settings.get<string>('clinic_name');   // ✅ visible to the UI
settings.get<boolean>('reminders');    // ✅ visible to the UI
settings.get('api_key');               // ⛔ undefined — secrets never reach the browser

A secret setting is for tools/handlers/your backend to use server-side. Remember settings are a static snapshot (above) — useSettings() won't react to a change until the page re-loads. See External backends for how secret settings and stored connections are consumed, and the full SettingDef shape in the Manifest reference.

Design system: look native in light and dark

The host injects a set of --tn-* CSS custom properties and .tn-* component classes into every app iframe, for both light and dark themes. Use them and your app looks like part of the dashboard — and adapts automatically.

There are no spacing tokens — use raw pixel values for padding/margins. These are the exact injected tokens (TN_DESIGN_CSS):

TokenLight defaultDark defaultUse for
--tn-bg#ffffff#0b1220page background
--tn-fg#0f172a#e2e8f0primary text
--tn-muted#64748b#94a3b8secondary text
--tn-muted-bg#f8fafc#16223asubtle fills, hover
--tn-border#e2e8f0#334155input/control borders
--tn-card#ffffff#101a2ecard background
--tn-card-border#e8edf5#1e293bcard / table borders
--tn-primary#4f46e5#6366f1primary action
--tn-primary-fg#ffffff#fffffftext on primary
--tn-primary-softrgba(79,70,229,.10)rgba(99,102,241,.18)selected rows, tint fills
--tn-link#4f46e5#a5b4fclink text
--tn-danger#e11d48#fb7185destructive text / border
--tn-danger-solid#e11d48#be123cdestructive fill (white text)
--tn-success#059669#34d399success text / dot
--tn-warning#d97706#fbbf24warning text / dot
--tn-ringrgba(79,70,229,.25)rgba(99,102,241,.45)focus ring
--tn-radius8px8pxborder radius
--tn-shadow0 1px 2px rgba(15,23,42,.06), …0 1px 2px rgba(0,0,0,.5), …card shadow
--tn-fontsystem-ui, …(same)font stack

The dark values apply under :root[data-tn-theme="dark"] — you don't set them; the host flips the attribute. They mirror the dashboard's own dark shell, so a token-built page sits in the page instead of looking pasted onto it.

Two tokens are deliberately split by usage rather than by colour:

  • --tn-danger vs --tn-danger-solid — on a dark background, destructive text must be light and a destructive fill must be dark enough to carry white label text. One value cannot do both. Use --tn-danger for text/borders, --tn-danger-solid for a filled button (.tn-btn-danger already does).
  • --tn-link vs --tn-primary--tn-primary has to stay dark enough for white button text, which leaves it too dim for body links in dark mode. Colour link text with --tn-link.

The host also sets color-scheme on the iframe root, so scrollbars, date/select popups, and any unstyled native <input> your app renders follow the theme without you doing anything.

A native-looking card:

function Card({ children }: { children: React.ReactNode }) {
  return (
    <div
      className="tn-card"          // or style it yourself with the tokens below
      style={{
        background: 'var(--tn-card)',
        color: 'var(--tn-fg)',
        border: '1px solid var(--tn-card-border)',
        borderRadius: 'var(--tn-radius)',
        boxShadow: 'var(--tn-shadow)',
        padding: 16,               // raw pxthere are no spacing tokens
      }}
    >
      {children}
    </div>
  );
}

Ready-made component classes are injected too: .tn-card, .tn-btn, .tn-btn-primary, .tn-btn-danger, .tn-input, .tn-textarea, .tn-select, .tn-badge, .tn-muted, .tn-table (with th/td, plus a row hover). Use them for instant native styling — they carry focus rings, :disabled and placeholder states for free.

Live theme sync. The host sets <html data-tn-theme="light|dark"> and updates it when the user flips the dashboard theme — no reload. If you need the current theme in JS, read it off window.telenow.theme, which is an object (not a string):

// NOTE: telenow.theme is NOT in the published TelenowBridge SDK type yet —
// read it directly off window.telenow.theme.
const theme = (window as any).telenow?.theme;

theme.mode();          // -> 'light' | 'dark'  (current mode)
const off = theme.onChange((mode) => {
  // mode = 'light' | 'dark' — fires whenever the user flips the dashboard theme
});
off();                 // onChange returns an UNSUBSCRIBE fn

In React, subscribe in useEffect and unsubscribe on unmount:

import { useEffect, useState } from 'react';

function useTheme() {
  const t = (window as any).telenow?.theme;
  const [mode, setMode] = useState<'light' | 'dark'>(t?.mode?.() ?? 'light');
  useEffect(() => t?.onChange?.(setMode), []); // returns the unsubscribe fn
  return mode;
}

Prefer the --tn-* tokens over hard-coded hex (the clinic example uses literal colors for brevity, but tokens are the recommended path) so light/dark just work.

Graceful degradation across versions

A user's dashboard host and your uploaded bundle can drift out of sync (an older host, a newer bundle). The hooks are built so a capability gap never blank-screens your app:

  • Read hooks like useAgents() and useCallHistory() return empty results on a host that predates the capability or a missing scope, instead of throwing.
  • Action methods (e.g. calls.initiate) return a clear rejected promise ("…is unavailable — update the dashboard to use it.") rather than a cryptic TypeError.
  • useSettings() falls back to an empty reader on a host that predates settings.

Any runtime error thrown inside your bundle is also caught by the host: it shows an error banner in the dashboard (with the stack) and renders the message inside your iframe — so you see a stack trace instead of a blank page during development.

So always handle the error/rejection path (show a banner, disable a button) and your app stays usable everywhere.

Local dev preview

You don't need the dashboard to build your UI. telenow dev serves your bundle with a full mock bridge (createMockBridge from telenow/browser) — real CRUD against in-memory/localStorage state, believable stubs for agents/calls/WhatsApp:

npx telenow dev --port 5174

Seed it and pick the page in your own harness:

import { createMockBridge } from 'telenow/browser';
window.telenow = createMockBridge({
  context: { page: 'patients' },
  seed: { patient: [{ name: 'Emma Wilson', phone: '+442079460958', status: 'active' }] },
  settings: { clinic_name: 'Demo Clinic' },
});

Running your local bundle against REAL data

To run your local bundle inside the actual dashboard, use the Dev preview button. It is shown only to owner / admin / developer roles, at the top of any of your app's pages.

How it works (exactly):

  1. Click Dev preview. A prompt appears, defaulting to http://localhost:5174 — enter the URL your telenow dev server is serving.
  2. The host loads your local bundle from ${devUrl}/index.js (and ${devUrl}/index.css only if your app declares ui.styles) into the real iframe.
  3. Hot reload rides an SSE channel at ${devUrl}/__livereload — every message remounts the iframe so your latest build appears instantly.
  4. The chosen URL is stored per-app in localStorage under the key telenow.devPreview.<appId>, so it persists for you in this browser only. Clear it with Exit preview.

⚠️ It runs against REAL org data. All bridge calls still flow through the host to the live backend — create/update/remove, calls.initiate, whatsapp.send, etc. are real mutations against the installing org. Use a test org while iterating.

Full setup is in the Quickstart.

Next