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
boxesorsticky-noteicon — those fall back tobox. Useboxandfile. 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. SouseSettings()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.
useObjectsis equality-only. Its signature isuseObjects<T>(objectType, query?: Record<string, string>)— thequeryis plain string equality and it passes noopts(noorderBy,limit,view,expand, orsearch). The moment you need operators ($gt,$in,$contains…), ordering, limits, a saved view, relation expansion, or semantic search, drop togetTelenow().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:
| Option | Type | Effect |
|---|---|---|
orderBy | { field, desc?, numeric? } | sort by a field (numeric:true compares as numbers) |
limit | number | cap the result count |
view | string | apply a manifest-declared saved view (its stored filter + sort) |
expand | string[] | embed relations — each result gets {field}__expanded |
search | string | natural-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/flowfor a flow agent,/agents/:id/editfor 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:readscope they require themanage_agentsrole gate (canCommunicate, below). A viewer withagents:readcanlist()agents but cannot build from a template.
agents.publicLink() / agents.setPublic() — reach the agent you just built
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);
| Scope | Role gate | |
|---|---|---|
agents.publicLink (read) | agents:read | canViewActivity |
agents.setPublic (write) | agents:write | canManageSettings — 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 — onlyisPublicchanges. slugis whatlinks.mint({ action: 'agent_call' })expects incontext.slug. That's the usual reason to callpublicLinkat all: you're minting a per-recipient link pointed at your own agent. Seetelenow.links.- Same shape as the app-key REST plane.
GET/PUT /api/app-agents/:id/publicreturn 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-modepublicUrlto 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().initiatesilently drops context variables. The hook is wired as a 2-argument call —initiate: (agentId, phone) => calls.initiate(agentId, phone)— so any thirdvariablesargument 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:
| Field | Type | Notes |
|---|---|---|
id | string | call / session id |
agent_id | string | the agent that handled the call |
agent_name | string | display name |
channel | enum | telephony | softphone | web_call | whatsapp | web_chat | simulation |
direction | enum | inbound | outbound | web | whatsapp |
from_number | string | null | caller number |
to_number | string | null | callee number |
status | string | e.g. completed, failed, in-progress, no-answer |
duration_sec | number | call length in seconds |
start_time | string | ISO timestamp |
end_time | string | null | ISO 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):
| Method | Returns |
|---|---|
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'scontentTypeasoctet-streamregardless of what yourbodyactually is. If you need to remember a logical content type, encode it into thepath(e.g.exports/q3.csv) or your own metadata object. pathis a logical key —/segments are preserved (so you can organise intoexports/,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.
tiermaps 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.ttsis REST-only (POST /api/app-ai/tts, scopeai:tts) — there is noai.tts()on the bridge.- There is no STT endpoint for apps.
ai:sttvalidates but grants nothing; for live call audio usecalls: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.
telenow.links — links for people outside the org
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.urlon 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.
contextis 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;
canCommunicategates:agents.createFromTemplate,agents.createTeamFromTemplate,calls.initiate,whatsapp.channels,whatsapp.send,softphone.dial.canViewActivitygates: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 / namespace | Scope | Role gate | What it does |
|---|---|---|---|
useTelenowContext() / telenow.context | — | — | active page, app id, user |
useUser() / telenow.user | (user:profile for name/email) | — | signed-in user + can() RBAC gate |
useSettings() / telenow.settings | — | — | non-secret per-install config (static snapshot) |
useObjects() / telenow.data | objects:<type> | — | object store CRUD + count + subscribe |
useAgents().list / telenow.agents.list | agents:read | canViewActivity | list agents |
agents.createFromTemplate/createTeamFromTemplate | agents:read | canCommunicate | build a real agent/team (navigates away) |
telenow.agents.getConfig | agents:config:read | canViewActivity | read a bound agent's full setup — prompt, model, voice, behaviour, flow |
telenow.agents.updateConfig | agents:config:write:<group> per group touched | canCommunicate | write settings back (groups) |
useCall() / telenow.calls.initiate | calls:initiate | canCommunicate | place an outbound call |
useCallHistory() / telenow.calls.history | calls:read | canViewActivity | read call history |
useWhatsapp() / telenow.whatsapp | whatsapp:send | canCommunicate | list channels, send messages |
useSoftphone() / telenow.softphone | softphone:dial | canCommunicate | open the dialer prefilled |
useSession() / telenow.session | session:token | — | mint a backend identity JWT |
useHttp() / telenow.http | http:<host> (+ connection:<provider>) | — | proxied third-party HTTP |
telenow.calls.get(sessionId) | calls:read | canViewActivity | one call in full: summary + post-call analysis + transcript |
telenow.stream.subscribe | calls:read | canViewActivity | live call event frames |
telenow.data.subscribe | — | — | realtime object-change frames |
telenow.files | files:read / files:write | — | per-app blob storage |
telenow.files.extractText | files:read | — | plain text out of a stored .pdf / .docx / .txt / .md |
telenow.ai.llm / ai.llmStream | ai:llm | — | platform LLM, billed to the installing org's wallet |
telenow.ai.models | any ai:* | — | coarse tiers + the org's model catalog, for setup UIs |
telenow.links.mint | links:write | canCommunicate | mint a tokenized public link (details) |
telenow.links.list / links.revoke | links:read | — | list / kill minted links (never returns tokens) |
telenow.members.list | members:read | — | org member roster — id, name, email, role |
telenow.calls.open | calls:read | canViewActivity | open a call's own page — recording, transcript, timeline |
agents.publicLink | agents:read | canViewActivity | read an agent's public slug + share URL + embed snippet |
agents.setPublic | agents:write | canManageSettings | put an agent on / take it off the open internet (owner/admin) |
telenow.campaigns.addTargets | campaigns:write | canCommunicate | push mapped rows into an existing campaign (≤1000 per call) |
telenow.campaigns.get | campaigns:write | canViewActivity | read one campaign |
telenow.campaigns.list | campaigns:write | canViewActivity | the org's campaigns (id, name, status) — for a picker |
telenow.connector.connections | — (server-gated) | canViewActivity | connections serving a capability, each with its account |
telenow.connector.invoke | connection:<provider> | canCommunicate | run a connector action with the org's credential |
telenow.ui.openModal / closeModal | — | — | re-host your own page as a modal over the host |
telenow.ui.refresh | — | — | tell the host its data changed so its tables re-read |
telenow.ui.toast | — | — | a 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
connectionIdalongside 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.
addTargetsaccepts 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:
| Slot | Where it renders |
|---|---|
call_detail_panel | a panel on a call's detail page |
dashboard_widget | a widget on the org dashboard |
agent_builder_panel | a panel in the agent builder/editor |
agents_overview_panel | a panel on the agents list page |
call_list_panel | a panel on the call history page |
softphone_call_panel | a strip above the live softphone controls |
campaign_targets_panel | a panel beside a campaign's target importer (context: campaignId, agentId) |
SDK 0.5.0 types all seven slots,
ui.contributions, and theconnector/campaigns/uibridge namespaces — no casts needed. On an older SDK you may needas anyon the newer slots; the manifest validates and installs either way.
page_id, notpageId: in the manifest JSON the extension key is snake_casepage_id. (The server now also accepts the SDK's camelCasepageId, but preferpage_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
kind | Renders as | Needs | Status |
|---|---|---|---|
list_action | a native button in the host's list toolbar | label, pageId | ✅ rendered |
column | an extra column in the host's table | label (the header) | ⏳ not yet |
row_action | an item in the host's per-row menu | label, pageId | ⏳ not yet |
bulk_action | an item in the host's bulk-action menu | label, pageId | ⏳ not yet |
⏳ Only
list_actionis 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.
idis 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 thelabelfreely; changing theidmakes 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):
| Token | Light default | Dark default | Use for |
|---|---|---|---|
--tn-bg | #ffffff | #0b1220 | page background |
--tn-fg | #0f172a | #e2e8f0 | primary text |
--tn-muted | #64748b | #94a3b8 | secondary text |
--tn-muted-bg | #f8fafc | #16223a | subtle fills, hover |
--tn-border | #e2e8f0 | #334155 | input/control borders |
--tn-card | #ffffff | #101a2e | card background |
--tn-card-border | #e8edf5 | #1e293b | card / table borders |
--tn-primary | #4f46e5 | #6366f1 | primary action |
--tn-primary-fg | #ffffff | #ffffff | text on primary |
--tn-primary-soft | rgba(79,70,229,.10) | rgba(99,102,241,.18) | selected rows, tint fills |
--tn-link | #4f46e5 | #a5b4fc | link text |
--tn-danger | #e11d48 | #fb7185 | destructive text / border |
--tn-danger-solid | #e11d48 | #be123c | destructive fill (white text) |
--tn-success | #059669 | #34d399 | success text / dot |
--tn-warning | #d97706 | #fbbf24 | warning text / dot |
--tn-ring | rgba(79,70,229,.25) | rgba(99,102,241,.45) | focus ring |
--tn-radius | 8px | 8px | border radius |
--tn-shadow | 0 1px 2px rgba(15,23,42,.06), … | 0 1px 2px rgba(0,0,0,.5), … | card shadow |
--tn-font | system-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-dangervs--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-dangerfor text/borders,--tn-danger-solidfor a filled button (.tn-btn-dangeralready does).--tn-linkvs--tn-primary—--tn-primaryhas 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 px — there 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()anduseCallHistory()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 crypticTypeError. 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):
- Click Dev preview. A prompt appears, defaulting to
http://localhost:5174— enter the URL yourtelenow devserver is serving. - The host loads your local bundle from
${devUrl}/index.js(and${devUrl}/index.cssonly if your app declaresui.styles) into the real iframe. - Hot reload rides an SSE channel at
${devUrl}/__livereload— every message remounts the iframe so your latest build appears instantly. - The chosen URL is stored per-app in
localStorageunder the keytelenow.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
- Data & objects — fields, relations, computed, views, semantic search, queries (operators live here)
- Manifest reference — every
ui.pages/extensions/settingsfield + the icon whitelist - Events, webhooks & workflows — react to calls and data changes
- Scopes & permissions — the security model behind every bridge call
- Limits & quotas — blob sizes, row caps, rate limits