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 aretelenow,telenow/react, andtelenow/browser. There is no@telenow/apppackage — 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:
| Field | Meaning |
|---|---|
id / version | Unique 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. |
icon | A 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.entry | The 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. |
iconmust be a whitelisted name. Only a fixed set of icon names is bundled (e.g.box,file,users,calendar,phone). An unknown name (such asboxesorsticky-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:
| Mode | How | What you get |
|---|---|---|
| Standalone | Open http://localhost:5174 in your browser | A 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 preview | In the dashboard, open your app → Dev preview and paste http://localhost:5174 | The 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:
| Key | Shape | What 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 atsdk/examples/doctor-crm/telenow.dev.jsonand 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. Readdatain the order it arrives. To sort by a different field, call the bridge directly withtelenow.data.list('note', undefined, { orderBy: { field: 'title', desc: true } })— theorderBy/limit/expandoptions live ondata.list, not on theuseObjectshook.
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 zip | From |
|---|---|
telenow.app.json | Your 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.md | Bundled into the manifest's readme for your listing |
CHANGELOG.md | Per-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 (a413for an over-cap zip, a per-file422). See Limits & quotas for the full table.
Step 7 — Upload and install
- In the dashboard, go to Apps → Your apps → Upload app zip.
- Choose
notes-app-1.0.0.telenow.zip. - Review the requested scopes (
objects:notehere) 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:
| Stage | Who sees the sidebar page |
|---|---|
dev | The uploader and org owners/admins only (for testing before promotion) |
staging | The uploader/admins plus a named audienceMembers list (a beta group) |
prod | Everyone 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 page | Removed | Removed |
| Stored records (object rows) | Kept — a later re-install restores them | Deleted |
App blobs (app_blobs file storage) | Kept | Deleted |
| Bundled knowledge bases | Kept | Soft-deleted (no longer searchable) |
app_schedules (scheduled jobs) | Always deleted | Always deleted |
| Signing secret | Preserved 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.telenowbridge, 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.