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):

  • orgId is always present — the row is keyed by org.
  • updatedAt is always serialized (never null) — every write bumps it via now().
  • createdBy is serialized as null, 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."
  • createdAt is 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, never record.phone. The envelope keys (id, orgId, appId, objectType, createdAt, updatedAt, createdBy) are platform-owned; your values live exclusively under data.


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

FieldNotes
typeThe object key, e.g. "appointment". Required, unique in your app, key-safe ([A-Za-z0-9._-]).
labelDisplay name shown in the dashboard.
fields[]Field definitions (below).
views[]Saved named queries (see Views).
semantictrue to enable semantic search on this object.

Field definitions

FieldNotes
keyThe field name. Required.
typeAdvisory type for UI + indexing: text string number boolean date datetime select enum email phone url json.
indextrue to index this field for fast filtering and upsert matching.
valuesAllowed values for a select/enum field.
defaultDefault 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: true on 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 default is a CREATE-time value, never a query filter. It seeds a field the writer omitted; it does not narrow a read. An object.query tool over this object does not silently add status = 'scheduled', so a cancelled appointment is still findable. (Through mid-2026 the runtime folded declared defaults into every handler, which made object.query filter on them and object.update reset 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:

LimitValue / 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 keySilently 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 refThe 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:

FieldTypeNotes
fieldstringThe data field to sort by.
descbool?Descending when true (default ascending).
numericbool?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 orderBy applies 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.


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_chunks table / 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, bool fields, and string-arrays (joined with ", ").
  • Skipped: relation fields, computed fields, and any key not declared in fields[].
  • 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 string fields 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.delete tool.
  • 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 on search= 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.

SurfaceWhere it runsAuthUse it for
(a) UI bridgeYour React dashboard UI (iframe)The signed-in user, relayed by the hostDashboards, forms, reports
(b) App-key RESTYour own external backendAuthorization: Bearer <app key>Server-side integrations, syncs
(c) Agent toolsThe voice agent, during a callDeclarative object.* handlersReading/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 writerWritesFires object.<type>.created?
Inbound webhook (inboundHooks[])insert, or upsert on the hook's keyYes
Public link form (a form link you issue to someone outside the org)insert, or update on matchFieldYes
Event rule (call.analyzed → upsert)upsert on the rule's keyNo — it is an event handler; re-emitting would loop
Durable workflow (create-object / update-object steps)insert / update by matchNo — 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 / DataClient operator filters ($gt etc.) 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 & pathPurpose
GET /api/app-data/:objectTypelist records (query-string filters — equality only)
POST /api/app-data/:objectTypecreate one record (JSON body = the data)
PATCH /api/app-data/:objectType/:idmerge-update a record by id
DELETE /api/app-data/:objectType/:iddelete 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:

RouteResponse 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 (from telenow) unwraps these for youdb.list() returns the AppRecord[] directly, db.create()/update() return the AppRecord. Raw HTTP callers must read data.objects on a list (and data on 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 REST GET query string supports equality only, and every value is coerced to a STRING — so ?status=active&limit=50 works, but a number or boolean in the query string becomes text equality (?vip=true matches the string "true", ?count=3 matches the string "3"). There is no POST /query route on /api/app-data — only GET (list), POST (create), PATCH, and DELETE.

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 computed field 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 a default-free set on 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.queryThe 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 listThe 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 / DataClient only. The raw REST GET query string is equality-only and stringifies every value (see REST vs bridge).

OperatorMeaning
$eqequals (same as a plain value)
$nenot equal
$gt / $gtegreater than / greater-or-equal
$lt / $lteless than / less-or-equal
$invalue is in the given array
$containssubstring 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:

OperatorExact behaviour
$gt $gte $lt $lteCompares 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.
$containsCase-insensitive ILIKE with the LIKE metacharacters %, _, and \ escaped — so { $contains: '50%' } matches a literal 50%, not "anything ending in 50".
$neUses SQL IS DISTINCT FROM, so it also matches rows where the field is null / absent (an absent field is "distinct from" any value).
$inThe operand must be an array. A non-array operand matches nothing (fail-closed AND FALSE) — it does not match everything.
Unknown $operatorSilently 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 numeric off for dates and timestamps.
  • numeric: true casts the field to float8 (a float). Values that aren't a plain number become NULL, and NULLs 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 (the id direction follows the sort direction).
  • With no orderBy, rows come back newest-first by createdAt (then id).

Limit & paging:

AspectBehaviour
Default limit100 when unset.
ClampHard-clamped to 1..=500. An over-large limit is clamped, not rejected — asking for 5000 quietly gives you 500.
Offset / page paramNone exists. There is no offset/page.
CursorA 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:

  • PATCH is 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 to null (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/:objectType for a type your manifest doesn't list under objects[] is rejected 400 "unknown object type '…'" — the same rule the dashboard bridge has always applied. This catches the plural-typo class (POST /candidates against a manifest that declares candidate), 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/DELETE by id are not gated this way, so rows written before this rule can still be cleaned up.
  • :id is parsed as a UUID. On PATCH/DELETE, a non-UUID :id is a 400 path rejection (not a clean 404). A well-formed-but-unknown id is 404 "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 returns 413 "app data store is full (max 100000 records per app)". See Limits & quotas.

Automation coupling

  • Creating a record fires the object.<type>.created automation event — so workflows and rule/webhook event handlers pick it up. Every caller-initiated create emits it: REST POST, a dashboard-bridge create, an agent's object.create tool, an inbound-webhook write, and a public-link form submission. The two automation writers that deliberately don't are event rules and workflow create-object steps — they are themselves reacting to an event, so re-emitting would loop. See Automation.
  • PATCH and DELETE do NOT fire object.<type>.updated/.deleted automation 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.
  • created and updated frames carry the full row data, unredacted (it's your own app data, the same you can already read via the Data API). deleted frames carry data: 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 event rule upserts, 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.telenow bridge, hooks, slots, and the design system.
  • External backends — the app-key REST API, the DataClient wrapper, 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[], and views[] field in one place.