Build your first app

Build your first app

This is a hands-on tutorial. In about 10 minutes you will scaffold a small Notes app, run it locally with live reload, package it into an uploadable zip, and install it into your Telenow dashboard — where it appears as a real page in the sidebar.

You don't need a backend. The starter app is declarative: its data, UI, and rules run on Telenow's runtime, so there is nothing for you to host. (For the runtime tiers — declarative, external, sandboxed — see the Platform overview.)

Prerequisites

  • Node.js 18 or newer. Check with node --version.
  • A Telenow dashboard account where you can upload apps (Apps → Your apps).

The Telenow SDK is the npm package telenow (unscoped, zero runtime dependencies). You'll mostly run it through npx, so there's nothing to install globally.

The package is telenow. Imports are telenow, telenow/react, and telenow/browser. There is no @telenow/app package — ignore any older reference you may see.

Step 1 — Scaffold the project

Run:

npx telenow app init notes-app

This creates a notes-app/ folder with a complete, valid starter app:

notes-app/
├── telenow.app.json      # the manifest — your app's whole definition
├── telenow.dev.json      # mock seed data for `telenow dev` (local only)
├── package.json          # depends on `telenow`, react, react-dom
├── README.md             # shown on your marketplace listing
├── CHANGELOG.md          # per-version "what's new" notes
├── ui/
│   ├── index.tsx         # React entry — calls mount(App)
│   └── App.tsx           # your dashboard page
└── screenshots/          # drop listing images here

The scaffolded manifest

telenow.app.json is the heart of your app — it declares everything Telenow needs to run it. The starter manifest defines one data object (note) and one dashboard page:

{
  "$schema": "./node_modules/telenow/telenow.app.schema.json",
  "id": "notes-app",
  "version": "1.0.0",
  "name": "Notes App",
  "runtime": "declarative",
  "category": "other",
  "blurb": "A starter Telenow app.",
  "icon": "box",
  "scopes": ["objects:note"],
  "objects": [
    { "type": "note", "label": "Note", "fields": [
      { "key": "title", "type": "text" },
      { "key": "body", "type": "text" }
    ] }
  ],
  "ui": {
    "entry": "ui/index.tsx",
    "pages": [{ "id": "notes", "title": "Notes", "icon": "file", "menu": true }]
  }
}

What each part does:

FieldMeaning
id / versionUnique app id and semantic version. Both must be key-safe ([A-Za-z0-9._-]).
runtime: "declarative"No app code runs server-side — Telenow handles the data and rules.
scopes: ["objects:note"]The one permission this app needs: read/write its own note objects. The org consents to scopes at install.
iconA sidebar icon, by name. Must be one of the whitelisted icon names.
objects[]Declares the note data type with two fields, title and body. The store is schemaless, so type is an advisory hint used for UI and indexing.
ui.entryThe source path to your React entry. telenow build compiles it to ui/index.js.
ui.pages[]Each page with menu: true becomes a sidebar item in the dashboard.

icon must be a whitelisted name. Only a fixed set of icon names is bundled (e.g. box, file, users, calendar, phone). An unknown name (such as boxes or sticky-note, which are not in the list) silently falls back to a default box icon — your page still renders, but not with the icon you expected. See the full list in the Manifest reference and Dashboard UI.

The "$schema" line points your editor (VS Code and friends) at the bundled JSON schema, so you get autocomplete and inline validation while you type.

The scaffolded UI

ui/index.tsx mounts your root component into the host-provided container:

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

mount(App);

ui/App.tsx is a working notes page — a small form to add notes and a list with delete buttons, all backed by the note object store:

import { useState } from 'react';
import { useObjects, useTelenowContext } from 'telenow/react';

interface Note { title: string; body?: string }

export default function App() {
  useTelenowContext();
  const { data, loading, create, remove } = useObjects<Note>('note');
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  if (loading) return <p style={{ padding: 16 }}>Loading…</p>;
  return (
    <div style={{ padding: 16, fontFamily: 'system-ui', maxWidth: 640 }}>
      <h1 style={{ fontSize: 20 }}>Notes</h1>
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          if (!title.trim()) return;
          await create({ title, body });
          setTitle('');
          setBody('');
        }}
        style={{ display: 'flex', gap: 8, margin: '12px 0' }}
      >
        <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
        <input value={body} onChange={(e) => setBody(e.target.value)} placeholder="Body" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {data.map((n) => (
          <li key={n.id}>
            <strong>{n.data.title}</strong> {n.data.body}{' '}
            <button onClick={() => remove(n.id)}>delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Notice the shape of a row: each item is { id, data, createdAt? }, so you read fields off n.data.title, not n.title.

Step 2 — Install dependencies

cd notes-app
npm install

This pulls in telenow, react, and react-dom.

Step 3 — Run it locally with telenow dev

npx telenow dev

This starts a local server (default http://localhost:5174; pass --port N to change it) and watches your files — every save rebuilds and hot-reloads the page. There are two ways to use it:

ModeHowWhat you get
StandaloneOpen http://localhost:5174 in your browserA mock window.telenow bridge backed by in-memory / localStorage data. No dashboard, no login — perfect for building UI offline. Notes you create persist in the browser.
Dev previewIn the dashboard, open your app → Dev preview and paste http://localhost:5174The real dashboard loads your local bundle into its sandboxed iframe, running against real data, agents, and scopes under your login.

Use standalone for fast UI iteration, then switch to Dev preview to test against real data before you ship. In both modes, saving a file reloads instantly.

telenow.dev.json — seeding the standalone mock

In standalone mode there is no real backend, so your object store starts empty. To get realistic data to build against, the scaffold ships a telenow.dev.json file that npx telenow dev loads into the mock bridge (createMockBridge, from telenow/browser). It lets you pre-seed rows, fake the available agents, and impersonate a user/role locally:

{
  // Pre-seeded rows per object type. Each array element becomes a row in
  // that object's store (the mock assigns ids). Keyed by the object `type`.
  "seed": {
    "note": [
      { "title": "Welcome", "body": "This is a seeded note." },
      { "title": "Todo", "body": "Try editing App.tsx — it hot-reloads." }
    ]
  },
  // Agents that `telenow.agents.list()` returns locally.
  "agents": [{ "id": "agent-front-desk", "name": "Front Desk" }],
  // The signed-in user the mock reports via telenow.user / useUser().
  "user": {
    "id": "dev-1",
    "role": "owner",
    "permissions": ["view", "manage_agents"],
    "name": "Dr. Dev",
    "email": "[email protected]"
  }
}

Field reference for telenow.dev.json:

KeyShapeWhat it does
seed{ <objectType>: [ row, … ] }Pre-populates each object store with rows. The object type keys match your manifest objects[].type.
agents[{ id, name }]The agents telenow.agents.list() / useAgents() return in standalone mode.
user{ id, role, permissions[], name?, email? }The mock signed-in user. The permissions array (e.g. ["view", "manage_agents"]) drives can(permission) from useUser() during local dev, so you can impersonate a role or a read-only viewer and verify your UI gating. name/email mirror what the user:profile scope would surface.

The clinic-crm example ships a fuller telenow.dev.json (patients, appointments, visits). See it at sdk/examples/doctor-crm/telenow.dev.json and the Worked example.

This file is for local development only — it is not part of the uploadable zip and is never sent to Telenow.

Step 4 — Edit the UI with useObjects

useObjects(type) is the main hook for your app's data. It returns { data, loading, error, reload, create, update, remove }, all scoped to your app automatically — no API keys, enforced server-side. (See Dashboard UI for the full bridge and every hook.)

The starter already does a create-and-list. Try a small change in ui/App.tsx — show a note count, newest first:

import { useObjects } from 'telenow/react';

interface Note { title: string; body?: string }

export default function App() {
  // The object store already lists newest-first (created_at DESC), so `data`
  // is in the order you want — no client-side reordering needed.
  const { data, loading, create, remove } = useObjects<Note>('note');

  if (loading) return <p style={{ padding: 16 }}>Loading…</p>;

  return (
    <div style={{ padding: 16 }}>
      <h1>Notes ({data.length})</h1>
      <button onClick={() => create({ title: 'Quick note', body: '' })}>
        + Add a quick note
      </button>
      <ul>
        {data.map((n) => (
          <li key={n.id}>
            <strong>{n.data.title}</strong>
            <button onClick={() => remove(n.id)} style={{ marginLeft: 8 }}>delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Save the file — the page hot-reloads with your change.

Ordering: the object store already lists newest first (created_at DESC). Don't reach for [...data].reverse() to get newest-first — reversing a newest-first list actually gives you oldest first. Read data in the order it arrives. To sort by a different field, call the bridge directly with telenow.data.list('note', undefined, { orderBy: { field: 'title', desc: true } }) — the orderBy/limit/expand options live on data.list, not on the useObjects hook.

useObjects(type, query?) takes a query for equality filters; richer operators like $gte, $in, $contains and the orderBy/limit/expand options are available through telenow.data.list. Those all live in Data & objects.

Step 5 — Validate the manifest

Before building, check that your manifest is correct:

npx telenow validate

This runs the exact same checks the server runs at upload — catching mistakes at the keyboard instead of at upload. It flags bad id/version, non-semver versions, duplicate object types, tools that reference an undeclared object, http tools missing base_url, duplicate page ids, and unrecognised scopes (a typo guard).

Step 6 — Build the uploadable package

npx telenow build

This validates again, bundles your React UI with esbuild (minified, with an inline sourcemap and preserved component names so DevTools and the in-dashboard error overlay show real .tsx file:line), and writes:

notes-app-1.0.0.telenow.zip

The zip is named <id>-<version>.telenow.zip and contains exactly the package the platform's validator accepts:

Inside the zipFrom
telenow.app.jsonYour manifest, with ui.entry rewritten to ui/index.js (and $schema stripped)
ui/index.js (+ ui/index.css if you have styles)The compiled React bundle
README.mdBundled into the manifest's readme for your listing
CHANGELOG.mdPer-version "what's new" notes
screenshots/*Any images you added under screenshots/

Package size limits. The build/upload has hard caps — e.g. the zip must be ≤ 32 MB (≤ 40 MB uncompressed) and ≤ 400 files, the JS bundle ≤ 8 MB, CSS ≤ 2 MB, the manifest ≤ 512 KB, README ≤ 64 KB, a sandbox tool codeFile ≤ 64 KB, and up to 5 screenshots at ≤ 4 MB each. Exceed them and the upload is rejected (a 413 for an over-cap zip, a per-file 422). See Limits & quotas for the full table.

Step 7 — Upload and install

  1. In the dashboard, go to Apps → Your apps → Upload app zip.
  2. Choose notes-app-1.0.0.telenow.zip.
  3. Review the requested scopes (objects:note here) and confirm the install.

Once installed, look at the left sidebar — your Notes page is there, using the icon and title from the manifest. Open it, add a note, refresh: the data is stored in your org, scoped to this app.

Make it visible to your team

There's a catch the first time you install your own uploaded app: it lands in the dev stage, visible only to you (the uploader) and org admins. A regular teammate will not see the sidebar page yet. (A marketplace install — an app someone else published — starts in prod, visible to everyone, so this only bites your own private uploads.) The installed-ui endpoint that feeds the sidebar returns pages to non-admin members strictly according to the install's stage:

StageWho sees the sidebar page
devThe uploader and org owners/admins only (for testing before promotion)
stagingThe uploader/admins plus a named audienceMembers list (a beta group)
prodEveryone in the org

To roll it out, promote the install via its Visibility control (PATCH /:appId/visibility) to prod (everyone) or staging (a named audienceMembers list of user ids). Owners/admins (and the app's developer) can change this. The same staged-rollout flow is described under Packaging & distribution.

PATCH /api/orgs/:orgId/apps/notes-app/visibility
Content-Type: application/json

{ "stage": "prod" }
PATCH /api/orgs/:orgId/apps/notes-app/visibility
Content-Type: application/json

{ "stage": "staging", "audienceMembers": ["<user-uuid-1>", "<user-uuid-2>"] }

Uninstalling — what happens to your data

Uninstall takes an optional ?deleteData=<bool> query (default false):

DELETE /api/orgs/:orgId/apps/notes-app
DELETE /api/orgs/:orgId/apps/notes-app?deleteData=true
deleteData=false (default)deleteData=true
Sidebar pageRemovedRemoved
Stored records (object rows)Kept — a later re-install restores themDeleted
App blobs (app_blobs file storage)KeptDeleted
Bundled knowledge basesKeptSoft-deleted (no longer searchable)
app_schedules (scheduled jobs)Always deletedAlways deleted
Signing secretPreserved across re-install (ON CONFLICT keeps the existing one)n/a

So the default uninstall is non-destructive: it just pulls the page and stops the schedules; reinstalling brings your data and the same signing secret back. Pass deleteData=true for a clean wipe.

On install (and re-install/update), the platform also reconciles state idempotently: it scrubs any stale or secrecy-changed settings the newly pinned version no longer declares, reconciles the install's app_schedules to the manifest's schedules, and instantiates the app's bundled knowledge bases (embedding their docs) — safe to repeat on a re-install. (The same lifecycle is summarized on the Platform overview.)

That's the full loop: scaffold → dev → validate → build → upload → promote. To ship updates, bump version in the manifest, add a CHANGELOG.md entry, rebuild, and upload the new zip.

Next

  • Manifest reference — every field of telenow.app.json, including the full icon whitelist.
  • Data & objects — queries, relations, computed fields, views, and semantic search.
  • Dashboard UI — the window.telenow bridge, all the React hooks, slots, and the design system.
  • Limits & quotas — package size caps and runtime quotas.
  • Packaging & distribution — private upload, staged rollout, marketplace, and versioning.
  • Worked example — the full clinic-crm ("Doctor CRM") app, end to end.