Packaging & distribution
Packaging & distribution
You have built and tested your app — now you need to ship it. This page covers turning your app folder into an uploadable package with npx telenow build, the size and file-count limits the validator enforces, the two ways to get it to users (a private install in just your own org, or a public marketplace listing), the readiness checklist the marketplace requires, how versions are immutable and how the publish guard works, staged rollout (who sees the app), minting app API keys and the signing secret your backend needs, the uninstall data lifecycle, your publisher profile, and the cross-org developer analytics console.
New here? Start with the Quickstart to scaffold and run an app first, then come back to ship it. For exact quotas (rows, blobs, schedules, …), see Limits & quotas.
Build the package — npx telenow build
From inside your app folder (the one with telenow.app.json), run:
npx telenow build
build does three things, in order:
- Validates the manifest — it runs the same checks the server runs at upload (the
telenow validaterules: badid/version, duplicate object types, tools referencing undeclared objects,httptools missingbase_url, duplicate page ids, unknown scopes). If anything is an error, the build stops at the keyboard instead of failing later at upload. - Bundles your React UI with esbuild — your source entry (e.g.
ui/index.tsx) is compiled to a single minifiedui/index.js(plusui/index.cssif you have styles). It carries an inline sourcemap and preserves component names, so the in-dashboard error overlay and your browser DevTools show real.tsxfile:line. React is de-duplicated to your app's single copy so hooks work. - Assembles the ZIP — it writes
<id>-<version>.telenow.zip, e.g.clinic-crm-1.0.0.telenow.zip, where<id>and<version>come from your manifest.
✓ wrote clinic-crm-1.0.0.telenow.zip — upload it in the Telenow dashboard (Apps → Upload custom app).
If you have no
ui(a pure data/tools/automation app),buildskips the bundling step and just packages the manifest and listing files.
What's inside the .telenow.zip
This is exactly the package format the platform's validator (validate_zip) accepts. Nothing else is included.
| Entry | Where it comes from | Notes |
|---|---|---|
telenow.app.json | your manifest, rewritten | $schema is stripped (editor-only); ui.entry is rewritten from your source path (ui/index.tsx) to the built ui/index.js; ui.styles is set to ui/index.css if styles were produced; screenshots is filled in from the screenshots/ folder if you didn't list them by hand. |
ui/index.js | esbuild output | The bundled, minified React UI with an inline sourcemap. Only present if your manifest has a ui.entry. |
ui/index.css | esbuild output | Only present if your UI produced a stylesheet. |
README.md | your README.md | Becomes the marketplace listing copy (bundled into the manifest's readme at install). Required for a marketplace submission (see below). |
CHANGELOG.md | your CHANGELOG.md | Per-version "what's new" notes, shown on the listing and the update prompt. Optional. |
screenshots/* | your screenshots/ folder | Listing images. Picked up automatically if you don't declare screenshots in the manifest. At least one is required for a marketplace submission. |
icon.png / icon.jpg / icon.jpeg / icon.webp | beside the manifest | Optional custom app logo on the listing (see Listing assets). |
handlers/*.js | your sandbox tool codeFiles | Pure-compute JS for js/sandbox tools, bundled at build (see Tools). |
You never edit readme, changelog, or the rewritten paths by hand — the CLI fills them from the files on disk. Author README.md and CHANGELOG.md as normal Markdown next to your manifest and they ride along.
Package size & file-count limits
The validator rejects (it never silently truncates) a package that exceeds any of these. Keep your bundle lean — these also protect installing orgs from a zip-bomb. See Limits & quotas for the full quota picture.
| Limit | Value | What it caps |
|---|---|---|
PACKAGE_UPLOAD_LIMIT | 32 MB | The whole multipart upload body. Over this → 413 package exceeds the 32 MB limit. |
MAX_ENTRIES | 400 files | Total entries in the zip. |
MAX_TOTAL_UNCOMPRESSED | 40 MB | Total uncompressed size (zip-bomb guard). |
MAX_BUNDLE_BYTES | 8 MB | The built UI bundle (ui.entry → ui/index.js). |
MAX_STYLES_BYTES | 2 MB | The built UI stylesheet (ui.styles). |
MAX_SCREENSHOT_BYTES | 4 MB | Each individual screenshot image (and the custom icon.*). |
MAX_SCREENSHOTS | 5 | Number of screenshots. |
MAX_README_BYTES | 64 KB | README.md (and CHANGELOG.md each). |
MAX_CODE_BYTES | 64 KB | Each sandbox tool codeFile body. |
MAX_MANIFEST_BYTES | 512 KB | telenow.app.json itself. |
When upload fails
Anything other than the body-size limit comes back as HTTP 422 with a per-file list:
{
"success": false,
"error": "package validation failed",
"errors": [
{ "file": "nope.js", "message": "tool 'calc': codeFile 'nope.js' not found in the package" }
]
}
The file field tells you exactly where to look:
- A real path (e.g.
"nope.js","ui/index.js") — that entry is missing or too large. "telenow.app.json"— the manifest is missing, isn't valid JSON, or failed a manifest rule."(package)"— a zip-level problem: not a valid zip, too many entries, or over the uncompressed cap.
Entries whose name contains .. or starts with / are rejected as unsafe path (directory traversal) — telenow build never produces those, so you'll only see this if you hand-assemble a zip.
Two ways to distribute
1. Private upload (your org only)
The fastest path — no review, live in seconds. Use this for in-house apps, or to test the real install before you list publicly. A private upload does not require a README or screenshots.
- In the Telenow dashboard go to Apps → Your apps.
- Click Upload app zip.
- Pick your
clinic-crm-1.0.0.telenow.zip. - Review the scopes the app requests and consent to them.
- The app installs into your org only — its pages appear in the sidebar, its agents/KBs/workflows/hooks are wired up (see Install & uninstall below).
Under the hood this is POST /api/apps/:orgId/upload (no ?review), which publishes the version with status published and visibility private. Re-uploading a higher version upgrades the install. A private upload is never visible to any other org.
2. Publish to the marketplace (public listing)
When you want any Telenow org to discover and install your app, you submit it for admin review. There are two ways to submit — both end in the same admin queue:
- Re-upload with review —
POST /api/apps/:orgId/upload?review=truewith your zip. This validates the package and requires a README + screenshot (below), then sets the new version's status toin_review. - Submit an already-uploaded version —
POST /api/apps/:orgId/:appId/submit-review. This re-runs the readiness checklist against your newest uploaded version (no zip needed) and moves it toin_review. - JSON manifest route —
POST /api/apps/:orgId/publishwith{ "manifest": …, "review": true }. Same review queue; use this from CI/SDK integrations that publish manifests rather than zips. (Withoutreview: truethis route self-publishes privately — and is rejected for apps already live in the marketplace.)
Only one version per app can sit in review at a time — withdraw the pending one (POST /api/apps/:orgId/:appId/withdraw-review) if you need to swap it.
On approval the app becomes a public listing in the marketplace, where any org can install it (and consent to its scopes at install time).
Apps are free to install; if your app places calls or sends messages, those run on the installing org's own Telenow usage billing — make sure your listing says so.
Readiness checklist
Before a marketplace submission is accepted it must pass a checklist. Hit GET /api/apps/:orgId/:appId/readiness to see it at any time — it returns { version, ready, checklist } where each item is { key, label, required, ok, hint }:
| Key | Label | Required | Comes from |
|---|---|---|---|
name | App name | ✅ | manifest name (non-empty) |
blurb | Short description | ✅ | manifest blurb (non-empty) |
readme | README / overview | ✅ | README.md bundled into readme (non-empty) |
screenshots | At least one screenshot | ✅ | manifest screenshots[] not empty |
icon | App icon / logo | optional | manifest icon name or a bundled icon.* |
ready is true only when every required item is ok.
If you submit before the listing is ready, you get a 422. The two submission paths report it slightly differently:
// upload?review=true with a blank README and no screenshots
{
"success": false,
"error": "marketplace submission needs a README and screenshots",
"errors": [
{ "file": "README.md", "message": "a README is required to publish to the marketplace" },
{ "file": "screenshots/", "message": "at least one screenshot is required to publish to the marketplace" }
]
}
// submit-review on a not-yet-ready stored version
{
"success": false,
"error": "this app isn't ready for the marketplace yet",
"checklist": [ /* the full {key,label,required,ok,hint} list so the UI shows what to fix */ ]
}
A private upload skips this check entirely — README and screenshots are only mandatory for the marketplace.
Listing assets
These come from your project, mostly automatically:
| Asset | Source | Purpose |
|---|---|---|
| Name | manifest name | Title in the catalog + sidebar. |
| Blurb | manifest blurb | One-line catalog tagline. |
| Category | manifest category | One of crm, productivity, healthcare, ecommerce, finance, support, marketing, telephony, other. |
| Icon | manifest icon or icon.* file | A whitelisted built-in icon name (e.g. box, calendar, stethoscope), or drop an icon.png / icon.jpg / icon.jpeg / icon.webp (≤ 4 MB) beside the manifest for a custom logo. |
| Screenshots | screenshots/ folder | Listing images (max 5). build collects them. |
| Listing copy | README.md | The full description shown on the listing page. |
| What's new | CHANGELOG.md | Per-version release notes, shown on the listing and the in-app update prompt. |
Icon names are a fixed whitelist. An unknown
iconname does not error — it silently renders the default box. The valid built-in names are:bell,book,box,briefcase,calendar,chart,clipboard,clock,card,file,heart,home,inbox,grid,list,map,message,package,phone,settings,cart,star,stethoscope,ticket,user,users. See the full whitelist on Manifest reference and Dashboard UI. For a logo of your own, ship anicon.*file instead.
A clinic-crm-style telenow.app.json header gives you most of the listing for free:
// telenow.app.json
{
"id": "clinic-crm",
"version": "1.0.0",
"name": "Doctor CRM",
"category": "healthcare",
"blurb": "Patient records, appointments & follow-up calls for clinics.",
"icon": "calendar"
// … objects, tools, ui, agents, workflows, inboundHooks …
}
Versioning, immutability & the publish guard
Your version should be a semantic version like 1.2.0 (major.minor.patch). telenow build warns if it isn't semver, and a non-semver version cannot move the marketplace channel forward.
"version": "1.1.0" // bump this every release
Versions are immutable. Before any publish or submission, the platform runs precheck_publish, which rejects these with a clear error:
| Situation | Result |
|---|---|
The version already exists with status published or in_review | 409 Conflict version X already exists — publish under a new version number |
You try to publish an id already owned by another developer (app ids are global, first-come) | 409 Conflict app id 'X' is already taken by another developer |
You self-publish (private, review=false) a new version of an app that is already public in the marketplace | 409 Conflict this app is published in the marketplace — submit updates for review |
| Your developer account is suspended by a platform admin | 403 Forbidden your developer account is suspended |
So: to ship a change, bump the version, rebuild, and upload. You can never overwrite a 1.0.0 that is already live or in review — pick 1.0.1/1.1.0/etc.
- Installers see an update prompt. When a new version is approved (or re-uploaded privately), orgs that already have the app installed are offered the update, and your
CHANGELOG.mdfor that version is shown as the "what's new" notes. - Adding scopes requires re-consent. Scopes are frozen at install. If a new version requests additional scopes, the installing org must re-consent before the update applies — so add scopes deliberately and call them out in your changelog. See Scopes & permissions.
## 1.1.0
- New "lead callback" workflow auto-calls web leads within 5 minutes.
- Appointment list now supports semantic search.
## 1.0.0
- Initial release.
Private and public are independent
It is worth being precise about visibility, because publishing never makes an app public on its own:
publish_app(a private upload, or the dev-portalpublish) writes the version and never changes the app'svisibility. A private app stays private no matter how many versions you upload.submit_for_reviewonly moves your newest version to statusin_review. Crucially,latest_version(the live pointer) is not advanced — so if your app already has a live public version, that version keeps serving to installers during the review.- Only a platform admin approving the app (
approve_app) setsvisibility = 'public'and advanceslatest_versionto the approved version. That is the only path to the public marketplace. (Approving does not lift a platform kill switch — a disabled app stays disabled until an admin re-enables it.) - A rejected version goes to status
rejectedwith areview_note(the reviewer's reason), which is surfaced back in your developer console so you can fix it and re-submit a new version.
In short: an app can be private-and-installed-in-your-org and separately have a version sitting in review — the two states do not interfere.
Staged rollout (visibility)
Within an org, you control who sees the app's sidebar pages with a rollout stage — handy for testing a new install with a few teammates before the whole org gets it. PATCH /api/apps/:orgId/:appId/visibility with:
PATCH /api/apps/:orgId/:appId/visibility
Content-Type: application/json
{ "stage": "staging", "audienceMembers": ["<user-uuid>", "<user-uuid>"] }
stage | Who sees the app | Notes |
|---|---|---|
dev | Just you (the uploader) + org admins/owners | The default "private to me" stage. |
staging | Admins/owners + the named audienceMembers allow-list | The audienceMembers (a Uuid[]) is only kept on staging; switching to any other stage clears it. |
prod | Everyone in the org | Full rollout. |
Any other stage value → 400 stage must be dev, staging, or prod. Admins and owners always see the app regardless of stage; other members see it per the stage/audience rule. The change can be made by an org owner/admin or by the app's own uploader (install.installed_by), so a developer can stage their own app without admin rights. The response echoes { stage, audienceMembers }.
App API keys & the signing secret
An external or backend app cannot be built without an app key — that is the bearer credential your server uses to call the app-key REST API (Data, Files, Agents, Campaigns). Mint and manage them on your installed app in the dashboard (your app → API keys / Signing secret), which call these endpoints:
| Method & path | What it does |
|---|---|
GET /api/apps/:orgId/:appId/keys | List the keys you've minted for this app (metadata only — never the secret). |
POST /api/apps/:orgId/:appId/keys | Create a key → returns { key, secret }. The secret is shown ONCE; only its hash is stored. Optional { "label": "..." }. |
DELETE /api/apps/:orgId/:appId/keys/:keyId | Revoke a key. |
GET /api/apps/:orgId/:appId/signing-secret | Read the install's signing secret → { signingSecret } (owner/admin). |
Key facts:
- You must install the app first. Creating a key before install fails with 400
install the app before creating a key. - A key is bound to one
(org, app)— it can never touch another tenant's or another app's data. - The signing secret is the HMAC key your backend uses to verify the
X-Telenow-Signatureheader on every tool call and event webhook we send you. It's the same secret used to verify app session tokens. Read it with the endpoint above (owner/admin) — see External backends forverifySignature/verifyAppToken.
# Mint a key, then use it as a bearer token against the Data API
curl -s -X POST https://api.telenow.ai/api/apps/$ORG/clinic-crm/keys \
-H "Authorization: Bearer $DASHBOARD_JWT" -H 'Content-Type: application/json' \
-d '{"label":"prod backend"}'
# → { "success": true, "data": { "key": "tk_live_…", "secret": "…shown once…" } }
Install & uninstall lifecycle
Understanding what happens at install/uninstall helps you reason about what a new version changes.
On install, the platform wires up everything your manifest declares:
- UI pages become sidebar items (each
ui.pages[]entry withmenu: true). - Agents & teams from
agents[]/agentTeams[]are available to build with one click, auto-bound to the app's tools. - Knowledge bases from
knowledgeBases[]are created, chunked + embedded, and auto-attached to the app's agents. - Workflows, schedules, events, and inbound hooks are registered and start running on their triggers.
- Tools are namespaced as
{app_id}_{name}and offered to the org's agents.
The org consents to your scopes once, at this point. Everything runs under that consent, scoped to the installing org.
Uninstall & the data lifecycle
Uninstall takes a query flag: DELETE /api/apps/:orgId/:appId?deleteData=<bool> (default false). The confirmation dialog shows the record count from GET /api/apps/:orgId/:appId/object-count → { count }.
In every case, uninstall:
- removes the app's sidebar pages (the dashboard returns to its prior state),
- busts
app_access(the install is no longer live), and - deletes the install's
app_schedules(scheduled jobs stop).
Then the deleteData flag decides what happens to the data:
deleteData | What happens to records / blobs / KBs | Response |
|---|---|---|
false (default) | Kept. Objects, blobs (app_blobs), and bundled KBs all remain; a later re-install restores them, and the signing secret is preserved on the conflicting re-install. | { "dataDeleted": false } |
true | Purged. Blob storage and app_blobs rows are deleted, and the app's bundled KBs are soft-deleted (their chunks physically remain but become unreachable — list/search both exclude deleted KBs). | { "dataDeleted": true } |
Re-installing a previously kept app brings its pages, data, and capabilities back exactly as they were.
Publisher profile
Give your apps a consistent author identity. PATCH /api/apps/:orgId/developer-profile (owner/admin) accepts:
| Field | Rule |
|---|---|
displayName | text, ≤ 80 chars |
tagline | text, ≤ 140 chars |
supportEmail | text |
website | must be an https:// URL |
supportUrl | must be an https:// URL |
logo | must be an https:// URL |
GET /api/apps/:orgId/developer-profile reads it back. A public publisher page at GET /api/apps/:orgId/publisher/:slug lists the publisher's catalog-visible apps alongside the profile. The verified badge and suspended status are controlled by platform admins — they are not editable here.
Developer analytics
Once your app is published, the dashboard gives you a Developer analytics console (under Developers → Analytics) showing how your app performs across every org that installed it. It's backed by GET /api/apps/:orgId/:appId/dev-analytics?days=30 (publisher owner/admin only; days is clamped to 1–365), distinct from GET /:appId/metrics, which is your own org's usage. The response:
{
"days": 30,
"installs": { "total": 0, "active": 0, "errored": 0 },
"activeOrgs": 0,
"totals": { "tool_call": 0, "page_view": 0, "error": 0, "webhook_fail": 0 },
"errorRate": 0.0,
"series": [ { "date": "2026-06-01", "tool_call": 0 } ],
"installsByDay": [ { "date": "2026-06-01", "installs": 0 } ]
}
installs— total /active(enabled) /erroredinstalls across all orgs.activeOrgs— distinct orgs with anyapp_metricsin the window.totals— summed permetric_typeacross every install.errorRate—(totals.error + totals.webhook_fail) / sum(all metric totals).series/installsByDay— daily charts (usage per day, new installs per day).
Everything here is aggregate only. You see counts and rates across the install base — you do not see any individual org's identity, data, or per-org breakdown.
Indexing tip. At publish time the platform best-effort creates expression indexes for your objects'
index: truefields and for every inbound-hook dedupkey(each inbound POST upserts by it). Mark a fieldindex: trueonly when you actually filter or upsert on it — object-store indexes are shared platform-wide. See Data & objects.
Next
- Quickstart — scaffold,
telenow dev, build, and upload your first app. - Limits & quotas — rows, blobs, schedules, and every other quota.
- External backends — app keys, signatures, session tokens, and the REST API.
- Worked example & recipes — the clinic-crm app end-to-end.
- Scopes & permissions — what each scope grants and the re-consent rule.