Data & objects
Data & objects
Every Telenow app gets its own object store — a place to keep structured records like patients, appointments, leads, or orders. You declare the shapes you need in your manifest, and the platform gives you a database, a query API, an admin UI binding, agent-callable tools, and live updates. No hosting required for the default (declarative) runtime.
This page covers how the store works, how to declare objects and fields, the richer field powers (relations, computed fields, views, semantic search), the three ways to read and write data, the exact query syntax and operator semantics, the REST response shapes, limits, and realtime subscriptions.
We use the clinic-crm ("Doctor CRM") example app throughout — see Worked example for the full app.
The shared object store
The store holds schemaless JSONB rows. Each row is scoped to a (app, org) pair: your app only ever sees its own object types, and only the data belonging to the org where it is installed. An app key bound to one org can never read another tenant's rows — isolation is enforced server-side on every call.
"Schemaless" means a row's data is just JSON. The fields you declare in the manifest are advisory — they drive UI hints, indexing, and validation niceties, but the store will happily keep extra keys you write that weren't declared. Declare fields so the platform (and you) know what to expect; don't expect them to be a rigid schema.
The AppRecord shape
Every read returns rows wrapped in an AppRecord. The fields you declared live inside data; the envelope is added by the platform:
interface AppRecord<T = Record<string, unknown>> {
id: string; // platform-generated row id (a UUID)
orgId: string; // the installing org's id — ALWAYS present
appId: string; // your app id, e.g. "clinic-crm"
objectType: string; // the object type, e.g. "appointment"
data: T; // YOUR fields live here
createdBy: string | null; // a provenance/writer tag; serialized as null (not absent) when unknown
createdAt: string; // ISO-8601 server creation timestamp (default sort key)
updatedAt: string; // ISO-8601; always serialized (non-null)
}
Notes on the envelope (these come straight from the backend serializer):
orgIdis always present — the row is keyed by org.updatedAtis always serialized (never null) — every write bumps it vianow().createdByis serialized asnull, not omitted, when the writer is unknown. It is a provenance/writer tag, not a user id: records created over the app-key REST API are stamped"api". Don't treat it as "which user made this."createdAtis the server creation timestamp and is the field used for default (newest-first) ordering.
So a stored appointment comes back like this:
{
"id": "5f6a2b9c-1d34-4e77-8a01-2b9c1d344e77",
"orgId": "9a1c3e55-77b2-4d6e-9f01-3e5577b24d6e",
"appId": "clinic-crm",
"objectType": "appointment",
"data": {
"patient_name": "Sarah Chen",
"phone": "+14155550142",
"problem": "fever and cough for 3 days",
"start": "2026-07-04T10:30:00Z",
"status": "scheduled",
"patient_id": "01J9ZK6M0AB12"
},
"createdBy": "api",
"createdAt": "2026-07-01T08:15:00Z",
"updatedAt": "2026-07-01T08:15:00Z"
}
Read your fields off
record.data— e.g.record.data.phone, neverrecord.phone. The envelope keys (id,orgId,appId,objectType,createdAt,updatedAt,createdBy) are platform-owned; your values live exclusively underdata.
Declaring objects and fields
Objects go in the manifest's objects[] array. Here is the clinic's patient object, straight from telenow.app.json:
{
"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
}
Object fields
| Field | Notes |
|---|---|
type | The object key, e.g. "appointment". Required, unique in your app, key-safe ([A-Za-z0-9._-]). |
label | Display name shown in the dashboard. |
fields[] | Field definitions (below). |
views[] | Saved named queries (see Views). |
semantic | true to enable semantic search on this object. |
Field definitions
| Field | Notes |
|---|---|
key | The field name. Required. |
type | Advisory type for UI + indexing: text string number boolean date datetime select enum email phone url json. |
index | true to index this field for fast filtering and upsert matching. |
values | Allowed values for a select/enum field. |
default | Default value applied on create only, when the field is omitted. See the warning below. |
relation | { object, many? } — make this field a relation. |
computed | { template } — make this field a read-only computed value. |
A field is stored, OR a relation, OR computed — not a combination.
About index (read this)
Indexing makes a field fast to filter and lets it be used as an upsert/match key. But object-store field indexes are shared platform-wide by field NAME — declaring index: true on status adds your rows to the one status index every app shares, rather than building a private structure for your app. (The index leads with (org_id, app_id, object_type), so it stays selective for your scope; what's shared is the storage and the write cost.) So the rule is simple:
Only set
index: trueon fields you actually filter, sort, or match on.
Indexes are built at publish time. Publishing an app that declares a new indexed field builds that index over the whole store, which on a large deployment takes a write lock for the duration — so add indexed fields in a deliberate release, not casually.
In the clinic app, name, phone, status, start, and patient_id are indexed because tools and the UI query by them. Free-text fields like notes and condition are not indexed — you never filter by them directly.
Enums and defaults
Use type: "enum" (or "select") with values to constrain a field to a fixed set, and default to seed it on create. The clinic's appointment status:
{
"key": "status", "type": "enum",
"values": ["scheduled", "visited", "not_visited", "cancelled"],
"default": "scheduled",
"index": true
}
A new appointment created without a status lands as "scheduled".
A
defaultis a CREATE-time value, never a query filter. It seeds a field the writer omitted; it does not narrow a read. Anobject.querytool over this object does not silently addstatus = 'scheduled', so a cancelled appointment is still findable. (Through mid-2026 the runtime folded declared defaults into every handler, which madeobject.queryfilter on them andobject.updatereset them — if you're on an older build, a lookup that returns nothing for a record you can see in the dashboard is that bug.)
Relations
A relation field stores the id of a row in another object type (or an array of ids when many: true). The clinic links each appointment back to a patient:
{
"key": "patient_id",
"type": "string",
"index": true,
"relation": { "object": "patient" }
}
Now appointment.patient_id holds a patient row's id. The target object (patient) must be declared in the same manifest — forward references are fine.
Expanding relations
By default a relation read just gives you the id. To embed the referenced row, pass expand and the platform attaches it as {field}__expanded:
const appts = await telenow.data.list('appointment',
{ status: 'scheduled' },
{ expand: ['patient_id'] }
);
// each row now carries the linked patient inline:
appts[0].data.patient_id // "01J9ZK6M0AB12"
appts[0].data.patient_id__expanded // { name, phone, ... } — the patient row's data
For a many: true relation, {field}__expanded is an array of the referenced rows. Expansion is batched and bounded, so listing many appointments with their patients is one round-trip, not N.
Expand limits & edge cases
These are enforced by the enrichment layer — design around them:
| Limit | Value / behaviour |
|---|---|
| Expand fields per read | ≤ 8 (MAX_EXPAND_FIELDS). Extra entries beyond the first 8 are not expanded. |
| Referenced rows fetched per field | ≤ 500 (MAX_EXPAND_IDS). For a to-many array, ids beyond 500 (across all rows in the page) are truncated. |
| Unknown / non-relation expand key | Silently ignored — no error. Only declared relation fields expand. |
| Dangling to-one ref (id points at a deleted/missing row) | Expands to null. |
| Dangling to-many ref | The missing ids are filtered out of the __expanded array. |
Computed fields
A computed field is read-only and built by string interpolation. You give it a template with {{other_field}} placeholders; at read time each placeholder is replaced by that field's scalar value. The clinic uses one to make a nice label for pickers:
{
"key": "display",
"type": "string",
"computed": { "template": "{{name}} ({{phone}})" }
}
A patient { name: "Sarah Chen", phone: "+14155550142" } reads back with data.display === "Sarah Chen (+14155550142)".
Rules and caps (from the renderer):
- The template is pure string interpolation, never evaluated — there is no expression engine, no math, no code. It only substitutes other field values.
- A placeholder that names an unknown key, or a key whose value is non-scalar (object / array /
null), renders empty (the literal braces and surrounding text survive). - A computed field can reference stored fields only — it cannot reference another computed field. All computed fields read from a snapshot of the stored data, so they are order-independent.
- The rendered value is capped at 8192 characters (
MAX_COMPUTED_LEN), truncated char-safely. - Because it's computed at read time, you can't write to it or filter on it.
Views
A view is a saved query — a named filter plus an order — stored on the object. The clinic ships an "active patients" view and a "scheduled appointments" view. Here is the appointment view exactly as it appears in the manifest:
"views": [
{
"name": "scheduled", "label": "Scheduled",
"filter": { "status": "scheduled" },
"orderBy": { "field": "start", "numeric": false }
}
]
orderBy (the ViewOrder shape)
A view's orderBy carries:
| Field | Type | Notes |
|---|---|---|
field | string | The data field to sort by. |
desc | bool? | Descending when true (default ascending). |
numeric | bool? | Sort numerically when true, as text otherwise. |
The clinic sorts scheduled by start with numeric: false because start is an ISO-8601 datetime string — and ISO-8601 datetimes sort correctly as text (lexicographic order matches chronological order). Set numeric: true only for a number stored as text (e.g. a price or age), never for a date. See Sort semantics below for exactly what numeric does.
Applying a view
Apply a view instead of writing the filter yourself. From the dashboard bridge use opts.view; from the REST API use the ?view= query parameter:
// in the UI:
const scheduled = await telenow.data.list('appointment', {}, { view: 'scheduled' });
GET /api/app-data/appointment?view=scheduled
View precedence (important)
When a read both applies a view and passes its own filter/sort, these rules decide who wins:
- Filter: the view's stored predicates override the caller's filter on the same key (view wins). Keys the view doesn't mention are kept from the caller.
- Sort: the view's
orderByapplies only when the caller passed no sort. A caller-supplied sort wins. - Unknown view name: a silent no-op — the read runs as if no view was named (there is no 404). Double-check the spelling of
view.
Views are the clean way to keep common queries consistent across your UI, your backend, and your reports.
Semantic search
Set semantic: true on an object to opt into search by meaning. When enabled, each row is embedded on write (its text is turned into a vector), so you can rank results by how close they are in meaning to a natural-language query rather than by exact field match.
The clinic enables it on patient and visit. Then a search ranks rows by meaning:
// "diabetic patient with high blood sugar" matches even if those exact words
// aren't in the row — ranking is by meaning.
const matches = await telenow.data.list('visit',
{},
{ search: 'diabetic patient with high blood sugar', limit: 10 }
);
GET /api/app-data/visit?search=diabetic%20patient&topK=10
(topK is an alias for limit on the REST endpoint — both set how many ranked rows you get back.)
⚠️ Semantic search degrades SILENTLY
This is the single most common semantic-search bug. If search= is passed but any of the following is true, the read does not error — it falls back to a plain newest-first listing ranked by recency, not meaning:
- the object is not declared
semantic: true, OR - pgvector / the
semantic_chunkstable / embeddings aren't configured on the deployment, OR - the embedding call for the query fails.
So you can get plausible-looking results that are simply the latest rows. Always set semantic: true on the object, and verify your results are actually ranked by relevance (not just newest-first) before relying on it.
What actually gets embedded
The embeddable text for a row (object_search_text) is built from declared SCALAR fields only, as key: value lines joined by newlines:
- Included:
string,number,boolfields, and string-arrays (joined with", "). - Skipped:
relationfields,computedfields, and any key not declared infields[]. - Empty / whitespace-only values are skipped. A row with no embeddable text is not indexed at all (and won't appear in semantic results).
Declare the descriptive free-text fields you want matched (symptoms, notes, descriptions) as plain scalar
stringfields on the object, or they won't be part of the vector.
Re-indexing is asynchronous
- Embedding runs after the write returns (fire-and-forget) — the index is eventually consistent, so a row may not be searchable for a brief moment after creation.
- Re-embedding is skipped when the searchable body is byte-identical to what was last indexed (an update that doesn't change any embeddable field costs nothing).
- Deleting a row removes its chunk from the index — including a delete by an agent's
object.deletetool. - Which writers index: the dashboard bridge, the app-key REST API, the agent's
object.*tools, inbound webhooks, public-link form submissions, and workflow object steps all (re)index on write. The remaining exceptions are event-rule upserts (call.analyzed → upsert) and campaign write-back: rows they create or change are stored and served normally, but their embedding is only refreshed the next time some other surface writes that row. If an object's rows arrive mainly through an event rule, don't lean onsearch=for them.
When to use it
Reach for semantic when users describe what they want in their own words (free-text notes, symptoms, descriptions). For exact lookups — a phone number, a status, a date range — use ordinary equality/operator filters; they're cheaper and exact. Because embedding has a cost, semantic search is opt-in per object so you only pay for it where it helps.
Three ways to read and write data
The same object store is reachable from three surfaces. Pick by where your code runs.
| Surface | Where it runs | Auth | Use it for |
|---|---|---|---|
| (a) UI bridge | Your React dashboard UI (iframe) | The signed-in user, relayed by the host | Dashboards, forms, reports |
| (b) App-key REST | Your own external backend | Authorization: Bearer <app key> | Server-side integrations, syncs |
| (c) Agent tools | The voice agent, during a call | Declarative object.* handlers | Reading/writing data mid-conversation |
Three surfaces you call. The platform also writes to the same store on your behalf from four automation paths — worth knowing about when you're wondering where a row came from, or why a workflow did or didn't fire:
| Automation writer | Writes | Fires object.<type>.created? |
|---|---|---|
Inbound webhook (inboundHooks[]) | insert, or upsert on the hook's key | Yes |
| Public link form (a form link you issue to someone outside the org) | insert, or update on matchField | Yes |
Event rule (call.analyzed → upsert) | upsert on the rule's key | No — it is an event handler; re-emitting would loop |
Durable workflow (create-object / update-object steps) | insert / update by match | No — same loop guard |
Every one of these is subject to the same (org, app) scoping and the same row
cap, and every one emits on the realtime stream.
(a) In the dashboard UI — useObjects + telenow.data.*
Your app's React UI runs in a sandboxed iframe with no API keys. The host injects a window.telenow bridge and performs every call under the signed-in user, scoped to your app. The easiest entry point is the useObjects hook:
import { useObjects } from 'telenow/react';
function Appointments() {
const { data, loading, create, update, remove } = useObjects('appointment');
if (loading) return <p>Loading…</p>;
return (
<ul>
{data.map((a) => (
<li key={a.id}>
{a.data.patient_name} — {a.data.start} ({a.data.status})
</li>
))}
</ul>
);
}
useObjects(type, query?) returns { data, loading, error, reload, create, update, remove }; the mutators refresh the list automatically.
For one-off calls (counts, custom options, subscriptions) use the bridge directly:
import { getTelenow } from 'telenow/browser';
const telenow = getTelenow();
const rows = await telenow.data.list('patient', { status: 'active' });
const total = await telenow.data.count('appointment', { status: 'scheduled' });
const row = await telenow.data.create('lead', { name: 'Michael Torres', phone: '+16465550198' });
await telenow.data.update('patient', row.id, { status: 'inactive' });
await telenow.data.remove('appointment', someId);
The bridge /
DataClientoperator filters ($gtetc.) are the full query language. The raw REST GET query string is equality-only — see REST vs bridge below.
The full bridge, hooks, and design system are covered in Dashboard UI.
(b) From your backend — the app-key REST API
If you run an external backend, talk to the store over HTTPS with an app key (mint one in the dashboard under your installed app → API keys). Base host: https://api.telenow.ai. An app key is bound to one (org, app), so every call is automatically scoped.
GET /api/app-data/appointment?view=scheduled&limit=50
Authorization: Bearer <app key>
POST /api/app-data/lead
Authorization: Bearer <app key>
Content-Type: application/json
{ "name": "Emma Wilson", "phone": "+442079460958", "source": "website" }
The four routes:
| Method & path | Purpose |
|---|---|
GET /api/app-data/:objectType | list records (query-string filters — equality only) |
POST /api/app-data/:objectType | create one record (JSON body = the data) |
PATCH /api/app-data/:objectType/:id | merge-update a record by id |
DELETE /api/app-data/:objectType/:id | delete a record by id |
The GET query string supports limit, sort/dir/numeric, view, expand (comma list), and search+topK; every other key becomes an equality filter. Full reference (signatures, session tokens, the DataClient wrapper): External backends.
Response shapes
The Data API uses a { success, data?, error? } envelope, but the shape of data differs per route — read these carefully if you call the REST API with raw HTTP:
| Route | Response body |
|---|---|
GET (list) | { "success": true, "data": { "objects": [ ...AppRecord ] } } — the array is nested under data.objects, NOT directly data. |
POST (create) | { "success": true, "data": <AppRecord> } — the full created row, not just the fields you sent. |
PATCH (update) | { "success": true, "data": <AppRecord> } — the full merged row. |
DELETE | { "success": true } — no data. |
// GET /api/app-data/appointment →
{
"success": true,
"data": {
"objects": [
{ "id": "…", "orgId": "…", "appId": "clinic-crm", "objectType": "appointment", "data": { … }, "createdBy": "api", "createdAt": "…", "updatedAt": "…" }
]
}
}
The TypeScript
DataClient(fromtelenow) unwraps these for you —db.list()returns theAppRecord[]directly,db.create()/update()return theAppRecord. Raw HTTP callers must readdata.objectson a list (anddataon create/update).
REST vs bridge: the operator split
Operator objects (
$gt/$gte/$lt/$lte/$in/$ne/$contains) are available ONLY via the UI bridge /DataClient. The raw RESTGETquery string supports equality only, and every value is coerced to a STRING — so?status=active&limit=50works, but a number or boolean in the query string becomes text equality (?vip=truematches the string"true",?count=3matches the string"3"). There is noPOST /queryroute on/api/app-data— onlyGET(list),POST(create),PATCH, andDELETE.
If you need ranges, membership, or substring matching from a backend, either narrow with equality + view filters, or read through the in-dashboard bridge where the full operator language is available.
(c) From the voice agent — object.* tools
Declarative tools let the agent read and write the store during a call with no app code. A tool with an object.* handler runs in-core:
{
"name": "book_appointment",
"description": "Book a clinic appointment for the caller, with the reason for the visit.",
"parameters": { "type": "object", "properties": { /* … */ } },
"handler": { "kind": "object.create", "object": "appointment" }
}
The four kinds are object.create, object.query, object.update, and object.delete. The full pattern — match/set for updates, x-ui argument hints, namespacing, and auto-binding to bundled agents — is in Agent tools.
What an agent read sees
object.query goes through the same enrichment layer as the other two surfaces,
so computed fields are resolved on the rows the agent gets
back — the agent reads the same display value your dashboard renders. Two
deliberate differences from a UI/REST read:
- Relations are not expanded. The agent gets the raw id in the relation
field. Auto-expanding every relation would push whole related rows into the
model's context on every lookup. If the agent needs a name rather than an id,
put it on the row (a
computedfield is the cheap way) or give the agent a second tool that looks the related object up. - Views and semantic search aren't reachable from a tool handler — a tool
filters by its declared parameters. Encode the narrowing in the tool's
parameters(or in adefault-freeseton an update) instead.
⚠️ An undeclared filter field behaves the OPPOSITE way here
The two read surfaces disagree, on purpose, and it bites:
You filter on a key that is not in fields[] | Result |
|---|---|
Agent object.query | The key is dropped from the filter. The tool returns the newest rows, unfiltered — the agent will happily treat row #1 as "the" match. |
REST GET / bridge list | The key is kept as an equality on a field that doesn't exist. You get zero rows. |
Both failures are silent. So: declare every field a tool filters on in
objects[].fields[]. If a lookup tool returns a confidently wrong record,
suspect an undeclared filter field; if it returns nothing, suspect a filter value
that genuinely doesn't match.
Query filters
The query argument filters by stored field equality by default. To express comparisons, pass an operator object as the value instead of a plain value:
Reminder: operator objects work through the UI bridge /
DataClientonly. The raw REST GET query string is equality-only and stringifies every value (see REST vs bridge).
| Operator | Meaning |
|---|---|
$eq | equals (same as a plain value) |
$ne | not equal |
$gt / $gte | greater than / greater-or-equal |
$lt / $lte | less than / less-or-equal |
$in | value is in the given array |
$contains | substring match (case-insensitive) |
You can mix plain equality and operator objects in one query. For example, scheduled appointments in the first week of July for one of three patients:
const rows = await telenow.data.list('appointment', {
status: 'scheduled', // equality
start: { $gte: '2026-07-01', $lt: '2026-07-08' }, // range
phone: { $in: ['+14155550142', '+16465550198'] }, // membership
});
Operator semantics & gotchas
These are the exact rules the query builder applies — they decide whether your filter does what you think:
| Operator | Exact behaviour |
|---|---|
$gt $gte $lt $lte | Compares numerically only when the operand is a JSON number (e.g. { $gte: 18 }); otherwise compares as text (e.g. { $gte: '2026-07-01' }). Pass a number for numeric compare, a string for text/date compare. ISO-8601 dates compare correctly as text. |
$contains | Case-insensitive ILIKE with the LIKE metacharacters %, _, and \ escaped — so { $contains: '50%' } matches a literal 50%, not "anything ending in 50". |
$ne | Uses SQL IS DISTINCT FROM, so it also matches rows where the field is null / absent (an absent field is "distinct from" any value). |
$in | The operand must be an array. A non-array operand matches nothing (fail-closed AND FALSE) — it does not match everything. |
Unknown $operator | Silently ignored (lenient). A typo'd operator key simply doesn't constrain the query — double-check operator spelling. |
Sort, limit, count
QueryOpts (the third argument to list) controls ordering and paging:
const latest = await telenow.data.list('visit',
{ phone: '+14155550142' },
{ orderBy: { field: 'follow_up', desc: true, numeric: false }, limit: 20 }
);
const { count } = await telenow.data.count('lead', { status: 'new' });
Sort semantics (exactly what the store does):
- Default order is text. A field sorts lexicographically. ISO-8601 datetimes sort correctly as text — leave
numericoff for dates and timestamps. numeric: truecasts the field tofloat8(a float). Values that aren't a plain number becomeNULL, andNULLs sort LAST (NULLS LAST) — they are sorted to the end, NOT excluded from the results.- Ties break by
id. The full order is(data->>field) ASC/DESC NULLS LAST, id ASC/DESC(theiddirection follows the sort direction). - With no
orderBy, rows come back newest-first bycreatedAt(thenid).
Limit & paging:
| Aspect | Behaviour |
|---|---|
Default limit | 100 when unset. |
| Clamp | Hard-clamped to 1..=500. An over-large limit is clamped, not rejected — asking for 5000 quietly gives you 500. |
| Offset / page param | None exists. There is no offset/page. |
| Cursor | A keyset cursor (before) exists internally only and is not exposed on the REST API. Custom and view-set sorts can't be cursor-paged at all. |
If you need more than 500 rows, filter down (by date range, status, a
view, etc.) rather than paging — there is no general pagination cursor on the public surface. See Limits & quotas.
Writes: semantics & errors
The same rules apply to writes from every surface:
PATCHis a JSONB merge (data || body): it adds or overwrites the keys you send and leaves untouched keys in place. It cannot delete a key — to blank a field, write it explicitly tonull(or"").- Create / update body must be a JSON object. A non-object body (array, string, number) is rejected
400 "body must be a JSON object". - The object type must be declared.
POST /api/app-data/:objectTypefor a type your manifest doesn't list underobjects[]is rejected400 "unknown object type '…'"— the same rule the dashboard bridge has always applied. This catches the plural-typo class (POST /candidatesagainst a manifest that declarescandidate), which used to succeed and create a parallel type that no view, no computed field, and no query tool could ever reach, while still counting against your row cap.PATCH/DELETEby id are not gated this way, so rows written before this rule can still be cleaned up. :idis parsed as a UUID. OnPATCH/DELETE, a non-UUID:idis a400path rejection (not a clean 404). A well-formed-but-unknown id is404 "record not found".- Row cap: there is a hard cap of 100,000 records per
(org, app)across all object types. A create that would exceed it returns413"app data store is full (max 100000 records per app)". See Limits & quotas.
Automation coupling
- Creating a record fires the
object.<type>.createdautomation event — so workflows andrule/webhookevent handlers pick it up. Every caller-initiated create emits it: RESTPOST, a dashboard-bridgecreate, an agent'sobject.createtool, an inbound-webhook write, and a public-link form submission. The two automation writers that deliberately don't are event rules and workflowcreate-objectsteps — they are themselves reacting to an event, so re-emitting would loop. See Automation. PATCHandDELETEdo NOT fireobject.<type>.updated/.deletedautomation events — there are no such automation topics. Updates and deletes still emit on the realtime stream (below); they just don't trigger workflows/rules.
Realtime updates
Instead of polling, subscribe to changes and update your UI live. telenow.data.subscribe(type, onChange) fires on every create, update, or delete of that object type in your app, and resolves to an unsubscribe function — call it when your view unmounts.
const unsubscribe = await telenow.data.subscribe('appointment', (change) => {
// change: { event, objectType, id, data }
console.log(change.event, change.id, change.data);
});
// later, on cleanup:
unsubscribe();
Each frame looks like:
interface ObjectChangeEvent {
event: 'created' | 'updated' | 'deleted';
objectType: string;
id: string;
data: Record<string, unknown> | null; // the full row for created/updated, null for deleted
}
This is how a dashboard panel reflects an appointment the voice agent just booked mid-call without a refresh — the agent's object.create flows through the same store, so your subscribe handler fires immediately.
Delivery guarantees (important)
The realtime hub is best-effort telemetry, not a durable log — design for it accordingly:
- Frames are broadcast through an in-process hub with a per-subscriber buffer of 256 (
HUB_CAP). A slow/lagging subscriber DROPS frames — the hub never back-pressures the database write. A write always succeeds even if no one is listening or a listener is behind. - Because frames can be dropped, re-list on (re)subscribe to reconcile — treat the stream as "something changed, refresh" rather than the source of truth.
createdandupdatedframes carry the full rowdata, unredacted (it's your own app data, the same you can already read via the Data API).deletedframes carrydata: null.- Frames fire from the database write chokepoint, so every write path emits uniformly: dashboard UI writes, app-key REST writes, agent
object.*tool writes, declarative eventruleupserts, inbound-webhook writes, public-link form submissions, and workflow object steps. This is the one guarantee that holds across all writers — automation events and semantic indexing each have exceptions (above), the realtime stream does not.
Next
- Agent tools — let the voice agent read and write this store during a call.
- Dashboard UI — the
window.telenowbridge, hooks, slots, and the design system. - External backends — the app-key REST API, the
DataClientwrapper, signatures, and session tokens. - Automation — events, inbound webhooks, schedules, and durable workflows over your objects.
- Limits & quotas — row caps, limit clamps, rate limits, and every other ceiling.
- Manifest reference — every
objects[],fields[], andviews[]field in one place.