Worked example: clinic-crm

Worked example: clinic-crm

This page walks through clinic-crm ("Doctor CRM") — the running example used across these docs — from end to end. It is a real, complete app shipped in the SDK at sdk/examples/doctor-crm/telenow.app.json, and it exercises almost every capability of the Telenow App Platform in one manifest: data objects, agent tools, a dashboard UI, bundled agents and a team, a knowledge base, workflows, and a platform-hosted inbound hook.

It is a declarative app — there is no backend to host. Everything you see here runs on Telenow's own runtime; you write only telenow.app.json plus a React UI. After the walkthrough, a short recipes section shows common patterns lifted straight from the manifest.

Want to run it? Copy sdk/examples/doctor-crm, then npm install and npx telenow dev — see the Quickstart for the full loop.

⚠️ Before you copy-paste: fix the imports

The in-repo example imports a package named @telenow/app — for example ui/index.tsx does import { mount } from '@telenow/app/react', ui/App.tsx does import { ... } from '@telenow/app/react', and package.json depends on "@telenow/app": "file:../../app-sdk". That is a local, file-aliased development dependency that only works inside this SDK monorepo. @telenow/app does not exist on npm.

For your own app, the package is telenow (unscoped):

npm install telenow
// index.tsx — YOUR app
import { mount } from 'telenow/react';
import App from './App';
mount(App);
// App.tsx — YOUR app
import { useObjects, useUser, useCall, useWhatsapp, useSoftphone, useCallHistory, useAgents, useTelenowContext } from 'telenow/react';

If you copy the example and keep its @telenow/app imports, your build will failnpm install cannot resolve it and esbuild cannot bundle it. The doc snippets on this page (and throughout these docs) deliberately import from 'telenow' / 'telenow/react', which is the canonical, correct form. The shipped example source should itself be updated to import telenow; until then, swap every @telenow/app for telenow after you copy it.

What you see in the example repoWhat YOUR app must use
import { mount } from '@telenow/app/react'import { mount } from 'telenow/react'
import { ... } from '@telenow/app/react'import { ... } from 'telenow/react'
"@telenow/app": "file:../../app-sdk" (package.json)"telenow": "<latest>" after npm install telenow

Never write @telenow/app in a real app. The canonical subpaths are telenow (server helpers + manifest types), telenow/react (UI hooks), and telenow/browser (raw bridge + dev mock). See Quickstart.

⚠️ The bundled README is stale

The canonical, correct facts about this app (matching the manifest telenow.app.json, version 2.1.2) are:

  • 8 tools (find_patient, register_patient, book_appointment, list_appointments, log_visit, cancel_appointment, delete_appointment, update_patient).
  • 5 sidebar pages: Patients, Appointments, Visits, Activity, Reports.

The bundled sdk/examples/doctor-crm/README.md is out of date — it says "seven" tools, version 2.0.0, lists only four pages, and omits the Activity page. Trust this doc page and the manifest, not the README. (If you open the README, mentally apply: eight tools, five pages including Activity, version 2.1.2.)

What the app does

Doctor CRM gives a clinic a complete patient workflow that the dashboard and the voice agent share:

  • Staff manage patients, appointments, and visit logs from dashboard pages.
  • A voice agent answers the phone, recognises the caller, books or cancels appointments, and logs visit outcomes — using the same records the dashboard shows.
  • New leads (e.g. from a Meta lead-ad) flow in automatically and trigger follow-up.

Because the dashboard UI and the agent's tools read and write one object store, an appointment the agent books mid-call appears on the dashboard live, and a row a staffer edits is what the agent reads on the next call.

The manifest at a glance

The header declares the app's identity, its declarative runtime, and the scopes the org consents to at install:

{
  "id": "clinic-crm",
  "version": "2.1.2",
  "name": "Doctor CRM",
  "runtime": "declarative",
  "category": "crm",
  "blurb": "A full clinic CRM: patients, appointments, and visit logs — managed from the dashboard and by the voice agent during calls.",
  "scopes": [
    "objects:patient", "objects:appointment", "objects:visit",
    "agents:read", "calls:initiate", "calls:read",
    "whatsapp:send", "softphone:dial", "user:profile",
    "campaigns:write", "campaigns:read"
  ]
}

Note the scope list is specific — three named objects:<type> grants (there is no objects:*), and only the capabilities the app actually uses. That is the least-privilege guidance from Scopes & permissions in practice.

Below the header, the manifest declares each capability. We take them one at a time.

1. Objects — the shared data store

Four objects: patient, appointment, visit, and lead. They show off every field power at once — an indexed enum with a default, a computed field, a relation, saved views, and opt-in semantic search.

"objects": [
  {
    "type": "patient",
    "label": "Patient",
    "fields": [
      { "key": "name",   "type": "string", "index": true },
      { "key": "phone",  "type": "phone",  "index": true },
      { "key": "status", "type": "enum", "values": ["active", "inactive"],
        "default": "active", "index": true },
      { "key": "condition", "type": "string" },
      { "key": "notes",     "type": "string" },
      { "key": "display",   "type": "string",
        "computed": { "template": "{{name}} ({{phone}})" } }
    ],
    "views": [
      { "name": "active", "label": "Active patients",
        "filter": { "status": "active" }, "orderBy": { "field": "name" } }
    ],
    "semantic": true
  }
]
  • Only fields the app filters or matches on are index: true (name, phone, status) — free-text notes/condition are not, because object-store indexes are shared platform-wide.
  • display is computed and read-only: {{name}} ({{phone}}) is pure string interpolation, evaluated at read time.
  • patient and visit set semantic: true so a natural-language search ranks rows by meaning.

The appointment object links back to a patient with a relation field, which a read can expand:

{ "key": "patient_id", "type": "string", "index": true,
  "relation": { "object": "patient" } }

Full field reference: Data & objects.

2. Tools — what the agent can do on a call

Doctor CRM ships eight tools, all declarative object.* handlers — so the whole CRM works with zero backend code. The handler kind decides what each does:

ToolHandlerWhat it does
find_patientobject.queryRecognise the caller by phone or name
register_patientobject.createCreate a new patient
book_appointmentobject.createBook a slot (uses x-ui for problem + date)
list_appointmentsobject.queryList a patient's appointments
log_visitobject.createRecord diagnosis, prescription, follow-up
cancel_appointmentobject.update + match + setMark the newest appointment cancelled
delete_appointmentobject.delete + matchRemove the newest appointment row
update_patientobject.update + matchUpdate status / condition / notes

A good tool starts with a precise description (it is the model's prompt for when to call), and x-ui hints render nice inputs when a human fills the form:

{
  "name": "book_appointment",
  "description": "Book a clinic appointment for the caller, with the reason / problem for the visit.",
  "parameters": {
    "type": "object",
    "properties": {
      "patient_name": { "type": "string" },
      "phone": { "type": "string" },
      "problem": { "type": "string", "description": "reason / symptoms for the visit",
        "x-ui": { "widget": "textarea", "placeholder": "e.g. fever and cough for 3 days" } },
      "start": { "type": "string", "description": "appointment date-time, ISO 8601",
        "x-ui": { "widget": "date", "label": "Appointment date" } }
    },
    "required": ["patient_name", "phone", "start"]
  },
  "handler": { "kind": "object.create", "object": "appointment" }
}

cancel_appointment shows the match + set update pattern — find the newest appointment for a phone number, flip its status:

{ "name": "cancel_appointment", "description": "Cancel the caller's most recently booked appointment (matched by phone number).",
  "parameters": { "type": "object", "properties": { "phone": { "type": "string" } }, "required": ["phone"] },
  "handler": { "kind": "object.update", "object": "appointment", "match": "phone", "set": { "status": "cancelled" } } }

When the org builds one of the app's agents, these tools auto-bind to it — you never wire them by hand. Full details: Agent tools.

3. UI — five dashboard pages and a panel

The app contributes a single React bundle that renders five sidebar pages plus one extension that surfaces the Reports page inside the agents-overview panel:

"ui": {
  "entry": "ui/index.tsx",
  "pages": [
    { "id": "patients",     "title": "Patients",     "icon": "users",     "menu": true },
    { "id": "appointments", "title": "Appointments", "icon": "calendar",  "menu": true },
    { "id": "visits",       "title": "Visits",        "icon": "clipboard", "menu": true },
    { "id": "activity",     "title": "Activity",      "icon": "phone",     "menu": true },
    { "id": "reports",      "title": "Reports",       "icon": "chart",     "menu": true }
  ],
  "extensions": [
    { "slot": "agents_overview_panel", "page_id": "reports", "title": "Agent insights" }
  ]
}

The UI runs in a sandboxed iframe with no API keys; it reaches data through the bridge (window.telenow) and its React hooks. The patient list is just useObjects:

import { useObjects } from 'telenow/react';

function Patients() {
  const { data, loading } = useObjects('patient');   // live, app-scoped list
  if (loading) return <p>Loading…</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.data.display}</li>)}</ul>;
}

useObjects(type, query?) covers equality filters. To apply the saved active view (or expand/search/sorting), call the lower-level bridge directly — telenow.data.list('patient', undefined, { view: 'active' }). The full bridge, hooks, slots, and design system are in Dashboard UI.

The Activity page — call history

The Activity page (one of the five) is a call-history browser. It uses the useCallHistory hook, which needs the calls:read scope:

import { useCallHistory } from 'telenow/react';

function Activity() {
  // filters are optional; both keys accept comma-separated lists for bulk lookup
  const { calls, loading, error, reload } = useCallHistory({ number: '+14155550142' });
  if (loading) return <p>Loading…</p>;
  return (
    <table>
      <tbody>
        {calls.map((c) => (
          <tr key={c.id}>
            <td>{c.start_time}</td>
            <td>{c.channel}</td>      {/* method: phone / web / whatsapp … */}
            <td>{c.direction}</td>
            <td>{c.from_number} → {c.to_number}</td>
            <td>{c.agent_name}</td>
            <td>{c.status}</td>
            <td>{c.duration_sec != null ? `${c.duration_sec}s` : '—'}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

useCallHistory(filters?) accepts an optional { number?, sessionId? } filter (each value may be a single string or a comma-separated list) and returns { calls, loading, error, reload }. Each row in calls carries:

FieldMeaning
idCall / session row id (use as React key)
start_timeWhen the call started (ISO string)
channelThe method — e.g. phone, web, whatsapp
directioninbound / outbound
from_numberCaller number
to_numberCalled number
agent_nameThe agent that handled the call
statusCall outcome / status
duration_secDuration in seconds (may be null)

The canonical useCallHistory / telenow.calls.history() contract lives in Dashboard UI — this page just shows it in use.

4. Bundled agents — the one-click headline

Doctor CRM ships three agent templates in agents[] — one single agent and two flow agents — plus one team in agentTeams[]. An owner/admin clicks Create agent and gets a real, working voice agent auto-bound to the eight tools above.

  • front-desk — a single-prompt receptionist (systemPrompt + gpt-4o-mini + rachel voice + an opener).
  • smart-front-desk — a flow agent that runs an app-tool flow node (find_patient) deterministically before greeting, so it can use the caller's name.
  • intake-flow — a three-step conversation flow: greet → capture → book.

The deterministic-lookup node is the interesting one — a tool node whose inner config is kind: "app", pointing at the app's own tool:

{
  "id": "lookup", "name": "Look up caller", "kind": "tool",
  "config": {
    "name": "find_patient", "kind": "app",
    "config": { "app_id": "clinic-crm", "object": "patient", "handler": { "kind": "object.query" } }
  }
}

The team — and the real handoff mechanism

The team, front-desk-team, wires a triage agent that hands off to a booking specialist. The members list alone (entry + two members[] with prompts) is only the shell; the actual routing lives inside the triage member's metadata.flow envelope. Every flow agent — single or team member — carries the same envelope shape:

"metadata": {
  "flow": {
    "schema": 1,
    "startNodeId": "greet",
    "routerModel": "gpt-4o-mini",
    "nodes": [ /* … */ ],
    "edges": [ /* … */ ]
  }
}
Envelope keyMeaning
schemaAlways "v1".
startNodeIdThe node the call begins on.
routerModelLLM that decides edge transitions (e.g. gpt-4o-mini).
nodes[]The flow nodes (see kinds below).
edges[]Directed transitions: { id, source, target }.

A conversation node talks to the caller:

{ "id": "greet", "name": "Greeting",
  "prompt": "Greet the caller and ask how you can help.",
  "expectsInput": true, "kind": "conversation" }

An agent (handoff) node transfers the live call to a sibling member. Its config.agents[] references the sibling by its team-local ref — here "booking" — which the platform rewrites to the real agent id when the team is built:

{ "id": "toBooking", "name": "To Booking", "kind": "agent",
  "config": {
    "agents": [
      { "agentId": "booking", "label": "booking", "mode": "transfer" }
    ]
  } }

…and an edge wires the greeting into the handoff:

"edges": [ { "id": "e1", "source": "greet", "target": "toBooking" } ]

So the full triage member is:

{
  "ref": "triage", "name": "Triage",
  "systemPrompt": "…hand off to the booking specialist when they want an appointment.",
  "metadata": {
    "flow": {
      "schema": 1, "startNodeId": "greet", "routerModel": "gpt-4o-mini",
      "nodes": [
        { "id": "greet", "name": "Greeting", "prompt": "Greet the caller and ask how you can help.",
          "expectsInput": true, "kind": "conversation" },
        { "id": "toBooking", "name": "To Booking", "kind": "agent",
          "config": { "agents": [ { "agentId": "booking", "label": "booking", "mode": "transfer" } ] } }
      ],
      "edges": [ { "id": "e1", "source": "greet", "target": "toBooking" } ]
    }
  }
}

The booking member (ref: "booking") is a plain single agent with a systemPrompt and an opener; it is the target the handoff agentId is rewritten to. The full set of flow node kinds (conversation, tool, agent, transfer, code, subagent) and the complete agent-template spec are documented in Bundled agents & knowledge — read that page before authoring your own flow/team.

Full walkthrough: Bundled agents & knowledge.

5. Knowledge base — answers from real content

A single bundled KB, clinic-info, gives the agents real facts to answer from (hours, services, cancellation policy). On install it is embedded and auto-attaches to every agent the app builds — no wiring, no runtime API:

"knowledgeBases": [
  { "id": "clinic-info", "name": "Clinic Info",
    "documents": [
      { "title": "Clinic hours and services",
        "body": "Sunrise Family Clinic is open Monday to Saturday from 9 AM to 7 PM… We accept cash, cards, and major insurance. Appointment cancellations require at least 4 hours notice…" }
    ] }
]

KBs are manifest-only — to change the knowledge, edit the documents and publish a new version. See Bundled agents & knowledge.

6. Automation — workflows and an inbound hook

The app declares three workflows and one inbound hook, all driven by events with no backend.

appointment-followup — when an appointment row is created, wait, then create a follow-up visit task:

{ "id": "appointment-followup", "trigger": { "event": "object.appointment.created" },
  "steps": [
    { "kind": "delay", "seconds": 1 },
    { "kind": "create-object", "object": "visit",
      "data": { "patient_name": "{{trigger.data.patient_name}}", "phone": "{{trigger.data.phone}}",
                "notes": "Auto follow-up task created by workflow" } }
  ] }

lead-http-notify — POST each new lead to an external CRM with an SSRF-guarded http step. lead-callback writes a callback task for each new lead (swap its step for outbound-call to ring the lead directly).

The inbound hook lead-intake lets Meta lead-ads POST straight into the lead object — the platform verifies the x-hub-signature-256 HMAC against the install's signing secret, maps three fields, upserts by phone, and fires object.lead.created (which the workflows above react to):

"inboundHooks": [
  { "id": "lead-intake", "object": "lead", "key": "phone",
    "map": { "name": "full_name", "phone": "phone_number", "source": "ad_id" },
    "verify": { "header": "x-hub-signature-256", "prefix": "sha256=", "algo": "sha256", "encoding": "hex" } }
]

This composes into a complete no-backend lead loop: third-party POST → inbound hook (verify + map) → lead row → object.lead.created → workflow → follow-up. Full details: Events, webhooks & workflows.

Recipes

Short, copy-paste patterns drawn from the app.

Recipe: recognise the caller before greeting

Put find_patient in a deterministic tool flow node ahead of the greeting node (the smart-front-desk template). The lookup always runs first, so the greeting node can use the result — no guessing by the model. See Agent tools → flow node.

Recipe: call / WhatsApp / softphone a contact from a UI button

The Activity and patient pages let a staffer reach a contact three ways — a voice agent call, a WhatsApp message, or the softphone dialer — each relayed by the dashboard under the signed-in user. The three bridge calls (lifted from App.tsx) are:

import { useAgents, useCall, useWhatsapp, useSoftphone, useUser } from 'telenow/react';

function ContactActions({ phone, name }: { phone: string; name: string }) {
  const { user, can } = useUser();
  const { agents } = useAgents();
  const { initiate } = useCall();         // calls:initiate
  const { channels, send } = useWhatsapp(); // whatsapp:send
  const { dial } = useSoftphone();        // softphone:dial

  // RBAC guard: only owners/admins (or anyone who can manage agents) may reach out.
  const canCommunicate = can('manage_agents') || user?.role === 'owner' || user?.role === 'admin';
  if (!canCommunicate) return null;

  const activeAgent = agents[0]?.id ?? '';

  // 1) Place a voice-agent call — initiate(agentId, phone). Two args; no variables here.
  const call = () => {
    if (!activeAgent) throw new Error('No agent available to place the call');
    return initiate(activeAgent, phone);
  };

  // 2) Send a WhatsApp message — pick a channel, then send(channelId, to, message).
  const whatsapp = async () => {
    const chs = await channels();
    if (!chs.length) throw new Error('No WhatsApp channel is configured');
    await send(chs[0].id, phone, `Hi ${name || 'there'}, this is your clinic — how can we help?`);
  };

  // 3) Open the dashboard dialer prefilled — dial(phone).
  const softphone = () => dial(phone);

  return (
    <div>
      <button onClick={call}>Call</button>
      <button onClick={whatsapp}>WhatsApp</button>
      <button onClick={softphone}>Softphone</button>
    </div>
  );
}

Notes:

  • initiate(agentId, phone) is the two-argument form. (A third variables? argument fills {placeholder} context vars; the example doesn't pass it.) Needs calls:initiate.
  • channels() returns the configured WhatsApp channels — read chs[0].id and pass it to send(channelId, to, message). Needs whatsapp:send.
  • dial(phone) opens the dashboard's softphone prefilled. Needs softphone:dial.
  • can() is UI gating only — real enforcement is server-side. Always pair an outbound button with the role guard so read-only members don't see it. Full bridge contract: Dashboard UI.

Recipe: upsert a lead from a post-call analysis

Skip workflows entirely for a one-shot write — an event rule handler upserts a row when a call is analysed:

"events": [
  { "on": "call.analyzed",
    "handler": { "kind": "rule", "do": "upsert", "object": "lead", "key": "phone",
      "when": { "path": "sentiment", "equals": "positive" },
      "map": { "phone": "caller_number", "notes": "summary" } } }
]

A rule handler resolves when.path and each map source against a flat event context — the call's analysis fields (summary, sentiment, disposition, custom.<yourField>) plus caller_number and session_id at the top level. Do not prefix them with data. (that prefix is only for a webhook handler, which receives the { event, appId, data } envelope).

Recipe: reflect a mid-call booking on the dashboard live

Subscribe to the object store from your UI; the agent's object.create flows through the same store, so the panel updates with no refresh:

const off = await telenow.data.subscribe('appointment', (change) => {
  // change.event = 'created' | 'updated' | 'deleted'
  if (change.event === 'created') refreshCalendar();
});

Recipe: launch appointment-reminder calls from a backend

If you do run a server, an app key + the app-key REST API launches a paced campaign over your own data (each matched row becomes that call's {placeholder} variables). This is a teaser — the full body has more required fields:

curl -X POST https://api.telenow.ai/api/app-campaigns \
  -H "authorization: Bearer $TELENOW_APP_KEY" -H "content-type: application/json" \
  -d '{ "name": "Appointment reminders",
        "agentId": "8f3c…-real-app-bound-uuid",
        "targetQuery": { "object": "appointment", "phoneField": "phone", "filter": { "status": "scheduled" } } }'

Required / default fields you must get right:

  • agentId is required and must be a real, app-bound agent uuid (one of the agents this app created) — not a template id.
  • Targets: provide EITHER targets: [{ phone, variables? }] (explicit list) OR targetQuery: { object, phoneField, filter }. phoneField defaults to "phone" — set it if your phone column has another name.
  • Defaults: window? ({ start, end, timezone } for paced dialling), retryBackoffSecs defaults to 300, retryOnNoAnswer defaults to true, autostart defaults to true.
  • The response reports a status of "running" (when autostart) or "draft"; then poll GET /api/app-campaigns/:id for status, and POST …/:id/pause or POST …/:id/cancel to control it.

The complete Campaigns body, status/pause/cancel responses, and result write-back to your app objects are documented in the Campaigns section of External backends & the app-key API.

Where to go from the example

Doctor CRM is the fastest way to learn the platform: read its telenow.app.json, run npx telenow dev, change a tool or a page, and watch it live. Every section above maps to one capability page in this doc set.

One more time: the example repo imports @telenow/app (a local SDK alias). In your own app, npm install telenow and import from 'telenow' / 'telenow/react'.

Next