Agent tools

Agent tools

An app tool is a function your app gives the voice agent so it can do things during a live call — look a caller up, book an appointment, cancel one, write a note. While the agent is talking to a customer, the language model decides when a tool is needed, calls it with arguments it extracted from the conversation, and uses whatever the tool returns to continue speaking.

You declare tools in your manifest's tools[] array. Most tools need no code at all — the four object.* handler kinds run on Telenow's runtime against your app's data. Tools that need your own logic call an external backend over HTTP, or run pure-compute JavaScript in a hardened sandbox.

This page covers how tools are named and wired to agents, how to write a good tool, every handler kind with copy-paste examples from the clinic-crm ("Doctor CRM") example app, the exact JSON the agent receives back, and the reserved arguments the platform injects on every call.

How a tool reaches the agent

Namespacing

Inside the manifest you give each tool a short name like find_patient. On the wire — the name the LLM actually sees — it becomes:

{app_id}_{name}

So clinic-crm's find_patient is exposed as clinic-crm_find_patient (the combined name is sanitized to the provider-allowed character set — letters, digits, _ and - are kept, anything else becomes _). Namespacing is deliberate: two installed apps can each ship a book_appointment tool without colliding. You write the short name; the platform handles the prefix.

Aliases can override the wire name. The {app_id}_{name} form is the default. An agent binding may carry a per-tool alias that replaces it (the alias is sanitized to the same provider charset). So don't assume the wire name is permanent — if a binding sets { "book_appointment": "make_booking" }, the LLM sees make_booking instead. Your manifest name is what you reference everywhere else (flow nodes, the data UI); the alias only changes the LLM-facing wire name.

Auto-bind

You never wire tools to an agent by hand. When the org installs your app and then builds one of your agents (or builds any agent and binds your app to it), the app's tools are bound to that agent automatically. At call time the agent's tool list is assembled and your app's own tools are merged in from that binding. Ship a new tool in a manifest update and bound agents pick it up — no rebuild of the agent needed.

Security note: only the app's own tools are merged from the binding. Tool definitions embedded inside a shipped agent template are stripped, so a template can't smuggle in an arbitrary HTTP or cross-app tool. See Scopes & security.

Writing a tool

A tool has these parts that matter to the model:

FieldPurpose
nameShort, snake_case. Becomes {app_id}_{name} on the wire (unless a binding alias overrides it). Required.
descriptionTells the model when to call the tool. This matters a lot — write it well.
parametersA JSON Schema for the arguments. The model fills these from the conversation.
handlerHow the tool executes (see Handler kinds). Required.
timeoutSecsVoice-latency budget. Clamped to 1–30 seconds; default 15s when omitted.
handoffA spoken filler phrase played while the tool runs.

The description is your prompt

The model reads the description to decide whether and how to call the tool, so be specific about when to use it and any rules for the arguments. Look at clinic-crm's find_patient:

{
  "name": "find_patient",
  "description": "Look up an existing patient to recognise the caller. Pass EITHER phone (preferred) OR name — supply only ONE; passing both requires an exact match on both.",
  "parameters": {
    "type": "object",
    "properties": {
      "phone": { "type": "string", "description": "patient phone number (preferred lookup key)" },
      "name":  { "type": "string", "description": "patient full name — use only if phone is unknown" }
    }
  },
  "handler": { "kind": "object.query", "object": "patient" }
}

The description spells out the calling convention, and each property has its own description. Per-property descriptions guide the model just as much as the top-level one.

timeoutSecs and handoff — this is a live phone call

A tool that takes five seconds is five seconds of silence on the line. timeoutSecs sets the budget, and the platform clamps it to 1–30 seconds (with a 15-second default when you omit it). Anything past 30s is a dead conversation, so that's the hard ceiling — but on a live voice call you usually want it much tighter. If your tool can't answer fast, the agent stops waiting. Use handoff to fill the gap with something natural:

{
  "name": "book_appointment",
  "description": "Book a clinic appointment for the caller, with the reason / problem for the visit.",
  "timeoutSecs": 4,
  "handoff": "Let me get that booked for you, one moment.",
  "handler": { "kind": "object.create", "object": "appointment" }
}

timeoutSecs is the network/handler budget for http tools. For the sandbox kind it caps the wrapping call, but the sandbox's own JS execution has its own independent 100 ms–5000 ms window (see Sandbox contract).

Every shape on this page is shown in full in the Response reference — real JSON, taken from the code that builds it.

Handler kinds

The handler.kind decides where and how the tool runs. These are the exact valid values — no others are dispatched at call time:

handler.kindFamilyRuns where
object.createdeclarativeTelenow runtime — no code, no hosting
object.querydeclarativeTelenow runtime
object.updatedeclarativeTelenow runtime
object.deletedeclarativeTelenow runtime
httpexternalyour own backend
sandboxsandboxedTelenow's hardened JS runtime (pure compute)

js is NOT a valid handler kind — use sandbox. This is a trap: telenow validate does not whitelist handler kinds, so a manifest with "kind": "js" passes validation and uploads cleanly — then fails at the moment the agent calls it, mid-call, with:

unsupported app handler 'js' — supported: object.create / object.query / object.update (declarative), http (external), sandbox (gated)

The SDK's TypeScript HandlerKind union also only exposes sandbox (not js), so if you type your manifest you'll get a compile error rather than a runtime surprise. Always write sandbox.

A single app can mix kinds — declarative objects plus one http tool plus a sandbox tool is perfectly fine. The dispatcher routes purely on handler.kind; the manifest-level runtime field is advisory and does not gate dispatch. An http tool works whenever a base_url is set; a sandbox tool depends only on the platform flag (below). Don't assume a tool won't run just because runtime isn't "external" / "sandboxed" — set runtime for clarity, but the handler kind is what decides.

What the agent gets back (return shapes)

Whatever the handler returns is fed straight back to the LLM as the tool result. The declarative handlers return a fixed JSON envelope — the agent literally receives this:

HandlerSuccessFailure
object.create{ "ok": true, "id": "...", "record": { ...row } }{ "ok": false, "error": "app data store is full — cannot create more records" } (at the row cap — this does not throw)
object.query{ "results": [ { "id": "...", "data": { ...row }, "created_at": "..." } ] }(empty results array if nothing matches)
object.update{ "ok": true, "id": "...", "record": { ...row } }{ "ok": false, "error": "no matching record found" }
object.delete{ "ok": true, "deleted": true, "id": "...", "record": { ...row } }{ "ok": false, "deleted": false, "error": "no matching record found" }

Because the agent can read ok/error, write your tool descriptions so the model knows what a failure means ("if no record is found, tell the caller you couldn't find their booking").

The trusted caller identity

Every tool call also carries a trusted caller envelope the platform resolves server-side (the LLM can't spoof it). It's { number, identifier, channel, session_id } — the verified caller number, an optional caller identifier, the channel, and the live session id. This is separate from the LLM-filled parameters:

  • http tools receive it as the top-level caller key in the request body (see the ToolCallRequest example below) — read caller.number / caller.session_id there, not from arguments.
  • object.create auto-stamps the trusted caller_number onto the new row (system-set, never overridable by an argument). Declare a caller_number field on the object if you want to index or filter by it later. (The other caller fields are not stamped onto the row — pass them explicitly as arguments if you need them stored.)
  • object.query / object.update / object.delete don't receive the caller fields automatically, but they can ask for them with handler.map — see below. sandbox never receives them.

So the caller envelope is trusted context for the platform and your http backend, not a set of keys silently merged into a declarative tool's arguments — a declarative tool has to name what it wants.

handler.map — bind an argument to the caller instead of the model

Don't make the model produce the caller's own phone number. It has to transcribe spoken digits, object.query is an exact string match, and one dropped digit returns {"results": []} with status: success — identical to "this caller has no record", which makes it a genuinely hard bug to see.

"handler": { "kind": "object.query", "object": "appointment",
             "map": { "phone": "caller_number" } }

Now phone comes from the verified caller identity. A model-supplied value still wins — that's deliberate, so "don't use this number, my booking is under 555-0143" keeps working; the model passes the spoken number and the map stands aside. Only an absent, null or blank argument is filled.

For the model to omit it, take the field out of required[] and tell it so in the description ("OMIT to use the number the caller is calling from"). Left required, the model always sends something and the map never fires.

Sources: caller_number · caller_identifier · caller_channel · session_id. An unknown source, a target that isn't a declared field, or a map on a non-object.* handler is rejected at upload — each would otherwise fail silently mid-call.

caller_number exists on telephony only — inbound gives the caller ID, outbound gives the dialed number. Web calls have no number (only the developer-supplied identifier from init-web-call).

Pin a key the model must never author

handler.map has two uses that need opposite manifests. The phone-number case above is the soft one: the caller's own number is a sensible default, but "my booking is under 555-0143" has to keep working, so you declare the parameter, drop it from required[], and let the model override.

A record id is not like that. candidate_id, user_id, an order number, any relation field — these are correlation keys your backend needs exactly right, and the model has no way to know them. Ask for one and it will produce something plausible: a real trace shows one tool called four times with three different invented ids (kundan-kumar-sw-eng, kundan-kumar-telenow, Kundan Kumar). None of them matched a record.

Because a model-supplied value always wins, leaving such a parameter declared means a guess beats the truth. Dropping it from required[] is not enough — the model still sees it and still fills it. So don't declare it at all. A map target only has to be a declared field of the object; it never has to be a declared parameter, and match accepts a map target for exactly this reason:

{
  "name": "save_interview_outcome",
  "description": "Record the outcome of this interview.",
  "parameters": {
    "type": "object",
    "properties": {
      "outcome": { "type": "string", "enum": ["pass", "fail", "hold"] },
      "notes":   { "type": "string" }
    },
    "required": ["outcome"]
  },
  "handler": {
    "kind": "object.update", "object": "interview",
    "match": "candidate_id",
    "map": { "candidate_id": "caller_identifier", "call_session_id": "session_id" },
    "set": { "status": "completed" }
  }
}

candidate_id appears nowhere in the schema. The model cannot supply it, cannot override it, and cannot be blamed for it — it is filled from the trusted envelope before the handler runs. The model is left authoring only what it actually learned in the conversation.

One rule covers every tool you will write: the model authors content, the platform authors identity. Anything the model would have to recall rather than hear belongs in map, not in parameters. Split your arguments that way and the precision question answers itself — a wrong outcome is a conversation problem you can fix with a better description, but a wrong candidate_id is a corrupt row.

Mapping session_id alongside it (as call_session_id above — declare it as a field) stamps every written row with the call it came from, so your backend can reconcile a record against a call, a recording and a transcript without trusting anything the model said.

Where caller_identifier comes from — you set it when you start the call, and it is never derived from the conversation:

Starting the callHow you set it
POST /api/sessions/initiate-callthe identifier field
Campaignan identifier column on the uploaded contact list (extra columns become per-target variables)
POST /api/sessions/init-web-call — from your server, with your API keythe identifier field
Chat APIthe identifier field (required)

Full setup in Caller identity. Mint the id into the call and the loop closes: your app knows who it dialed, and the tool writes back against that same id.

This whole pattern works with the "Send caller identity to tools" toggle OFF. caller_identifier into match is a lookup position, and session_id isn't caller PII so consent never applies to it. You only need the toggle if you map caller_identifier or caller_number into a stored position — see the table below.

⚠️ A pin that can't resolve is loud, and loud invites retries. If the call carries no identity, object.update errors with missing value for match field — and an erroring tool gets called again. In the trace above it fired on "Thank you" and "Bye-bye" too. Don't expose a pinned write on an agent reachable by a call shape where the id cannot exist. In particular the anonymous share link (/p/…) sends no identifier — anyone could set one, so it wouldn't be trustworthy. Pin against calls your own server starts.

⚠️ object.update matches the newest row for the field, so a pinned id must be unique per record. And http handlers cannot use map at all (rejected at upload) — an external backend reads caller.identifier from the POST body instead, which does require the toggle, because that is egress.

A mapped value that is only ever a lookup key works whether or not the agent enabled "Send caller identity to tools". It becomes a WHERE predicate against your app's own store — never persisted, never returned to your backend, never shown to the model:

PositionToggle needed?
object.query — any mapped field (all args are filters)no
object.update / object.delete — the match fieldno
object.update — any field that isn't match (lands in the patch)yes
object.create — any field (every arg is written)yes

The split is the difference between matching on the caller's number and keeping it. Storing it puts caller PII in your app's store, where your backend reads it back through the Data API — that's the exposure the toggle governs, and it still applies.

Requiring the toggle for lookups would have been backwards: it's an all-or-nothing switch that also POSTs the full caller envelope to your external backend on every http tool call. An org would have had to grant third-party egress just to match a caller against its own records.

If a call carries no caller identity at all — a web call, or a withheld caller ID — the map fills nothing and the query refuses rather than running unfiltered. The agent is told to ask the caller.

Defaults fill omitted arguments — but not the same defaults everywhere

There are two kinds of default, and they apply to different handlers. Getting these confused is the single most confusing failure on this surface, so they are kept strictly apart:

KindWhere it comes fromApplies to
Field defaultobjects[].fields[].default in your manifest — e.g. status defaults to scheduledobject.create only
Author argument defaultThe "Arguments" editor on a deterministic flow tool nodeEvery handler kind — it is that node author's intent for that step

Caller-supplied values (from the LLM, or a flow's variables) always win over both.

A field default is a value for a NEW ROW, never a filter on a read or a value in a patch. It has to work this way: object.query turns every argument into an equality predicate, so folding status: "scheduled" in would mean a cancelled appointment could never be found by any query tool — and folding it into object.update would silently reset status on every patch that didn't mention it. If you're on a build from before mid-2026, that is exactly what happened: a lookup tool returning {"results": []} for a record plainly visible in the dashboard is that bug, and it gets worse the more lifecycle your object has, because the row drifts further from its declared defaults.

An author argument default is a filter on a query node — that's the point of pinning it on the node. Only field defaults are create-only.

The trusted caller envelope (above) is separate from both — it isn't merged into a declarative tool's arguments. The one exception is object.create, which stamps the trusted caller_number onto the row after defaults are applied (it can't be overridden by an argument).

Declarative: the object.* kinds

These four run in-core against your app's object store with no code. The handler names the object type to act on; the tool's parameters become the data.

object.create — insert a new row. Arguments are the fields; declared field defaults are folded in for anything the caller omitted, and the trusted caller_number is auto-stamped on. Returns { ok:true, id, record }. Register a patient:

{
  "name": "register_patient",
  "description": "Create a new patient record.",
  "parameters": {
    "type": "object",
    "properties": {
      "name":  { "type": "string" },
      "phone": { "type": "string" },
      "condition": { "type": "string", "description": "known/chronic condition, optional" },
      "notes": { "type": "string" }
    },
    "required": ["name", "phone"]
  },
  "handler": { "kind": "object.create", "object": "patient" }
}

object.query — read rows, filtered by the arguments. A few specifics matter:

  • It is hard-capped at 50 rows — there is no manifest override and no pagination. Always steer the model to pass a filter (a phone number, a status) so the 50 rows are the right ones.
  • Filtering is equality-only on the object's DECLARED fields. Operator filters ($gt, $in, $contains, …) are bridge-only — they work through telenow.data.list in the dashboard UI, not from a tool.
  • Arguments that aren't declared fields of the object are dropped before querying (so a date the agent reasons over doesn't silently match zero rows). ⚠️ The flip side: if the field you meant to filter by isn't declared in objects[].fields[], it's dropped too — the query then runs on whatever filters remain, and the agent treats the first row it gets as the match. A lookup that confidently returns the wrong record almost always means an undeclared filter field. Declare every field a tool filters on.
  • An empty filter is refused, not treated as "everything". If every argument is dropped, blank, or was never supplied, the tool returns an error telling the agent to ask the caller for the missing detail. It does not fall through to the newest 50 rows, which would hand the agent a stranger's record to read out loud. (Builds before mid-2026 did fall through — if you saw a lookup confidently read back the wrong person's booking, that was this.)
  • Computed fields are resolved on the returned rows, so the agent reads the same derived values your dashboard shows. Relations are not expanded — the agent gets the raw id, because expanding every relation would push whole related rows into the model's context on each lookup.
  • The return envelope is { "results": [ { "id", "data", "created_at" } ] }. Note data is nested here — unlike the dashboard bridge, which returns flattened rows. Read fields as results[i].data.<field>.

find_patient and list_appointments both use it:

{
  "name": "list_appointments",
  "description": "List appointments, optionally filtered by the patient's phone number. (Date filtering is available on the dashboard.)",
  "parameters": {
    "type": "object",
    "properties": {
      "phone": { "type": "string", "description": "filter to one patient's appointments" }
    }
  },
  "handler": { "kind": "object.query", "object": "appointment" }
}

object.update — find the newest record matching a field, then update it. You name the lookup field with match. Rules:

  • The match value must be a non-empty string supplied in the arguments. If it's missing or empty, the tool errors with missing value for match field <field>``.
  • ⚠️ match must name an ARGUMENT, not just a field of the object. The value is read from arguments[match], so "match": "candidate" with a parameter called candidate_id fails on every call — the model fills what its schema declares and the lookup reads a different key. Both halves look correct in isolation (candidate is a declared field), which is why this used to upload clean and fail 100% at runtime. Manifest validation now rejects it: give the tool a parameter of that exact name, or bind it with handler.map.
  • For a relation field the stored value is another record's id — something a model cannot guess. Bind it from context rather than asking the model, or it will invent a plausible-looking slug and quietly match nothing. Leave the parameter out of the schema entirely and fill it with map: see Pin a key the model must never author.
  • The match field is removed from the patch — you cannot update the same field you match on.
  • set constants (declared on the handler) are applied last and always overwrite any argument value.
  • Returns { ok:true, id, record } on a match, or { ok:false, error:"no matching record found" } when nothing matches (nothing is created).

clinic-crm's cancel_appointment finds the latest appointment for a phone number and flips its status:

{
  "name": "cancel_appointment",
  "description": "Cancel the caller's most recently booked appointment (matched by phone number).",
  "parameters": {
    "type": "object",
    "properties": {
      "phone": { "type": "string", "description": "the caller's phone number" }
    },
    "required": ["phone"]
  },
  "handler": {
    "kind": "object.update",
    "object": "appointment",
    "match": "phone",
    "set": { "status": "cancelled" }
  }
}

The match field (phone) must be a declared field of the object — telenow validate checks this. update_patient uses match without set so the model supplies the new values (status, condition, notes) itself.

object.delete — find the newest record matching match and remove the row entirely. Same match rules as update (non-empty string required, same missing value for match field error). Returns { ok:true, deleted:true, id, record }, or { ok:false, deleted:false, error:"no matching record found" }. delete_appointment deletes rather than cancels:

{
  "name": "delete_appointment",
  "description": "Permanently delete the caller's most recent appointment record (matched by phone). Prefer cancel_appointment for a normal cancellation; this removes the row entirely.",
  "parameters": {
    "type": "object",
    "properties": {
      "phone": { "type": "string", "description": "the caller's phone number" }
    },
    "required": ["phone"]
  },
  "handler": { "kind": "object.delete", "object": "appointment", "match": "phone" }
}

Notice the description steers the model toward cancel_appointment for normal use — a nice example of using descriptions to disambiguate two similar tools.

External: the http kind

When a tool needs your own logic, declare a base_url on the manifest and give the tool an http handler. The platform POSTs to base_url + path:

{
  "name": "check_insurance",
  "description": "Check whether the caller's insurance is accepted, given their member id.",
  "parameters": {
    "type": "object",
    "properties": { "member_id": { "type": "string" } },
    "required": ["member_id"]
  },
  "handler": { "kind": "http", "path": "/tools/check-insurance", "method": "POST" }
}

The request body is a ToolCallRequest:

{
  "tool": "clinic-crm_check_insurance",
  "arguments": { "member_id": "INS-4821" },
  "caller": { "number": "+14155550142", "channel": "phone", "session_id": "…" }
}

http constraints (read these — they cause silent failures)

The combined base_url + path is run through the platform's outbound-URL guard before the call goes out, and the response is bounded:

  • HTTPS only. A http:// URL is rejected (app tool url rejected: …). The host must not resolve to loopback / link-local / private / cloud-metadata addresses (SSRF guard) — same rejection.
  • The install must have a signing secret (every http call is signed with it). Apps get one at install; mint/rotate it in your installed app's settings.
  • Response ≤ 1 MB. A larger body aborts the tool with app tool response exceeded 1 MB cap.
  • Any non-2xx aborts the tool. The error surfaced is app tool failed (<status>): <body> with the first 300 chars of the body. Return 2xx with a small JSON object on success.
  • For local development the proxy can't reach http://localhost — use a public HTTPS tunnel (e.g. ngrok / Cloudflare Tunnel) so the URL is a reachable https:// host.

See Limits & quotas for the full proxy/limits table.

Two rules for the backend:

  1. Verify the signature. Every http tool call carries X-Telenow-Signature: sha256=<hex> (HMAC-SHA256 over the raw body). Verify it against the raw bytes before parsing JSON.
  2. Your return value goes back into the conversation. Whatever JSON your handler returns is fed straight to the agent to keep talking — keep it small and meaningful.
import { verifySignature, type ToolCallRequest } from 'telenow';

app.post('/tools/check-insurance', (req, res) => {
  if (!verifySignature(SIGNING_SECRET, req.rawBody, req.header('x-telenow-signature')))
    return res.status(401).json({ error: 'bad signature' });
  const { arguments: args } = req.body as ToolCallRequest;
  const accepted = lookup(args.member_id);
  res.json({ accepted, message: accepted ? 'Yes, we accept that plan.' : 'Sorry, that plan is out of network.' });
});

Full details — signing, the app-key Data API, session tokens — are in External backends. The npm package is telenow (npm install telenow).

Sandboxed: the sandbox kind

For pure-compute logic that needs no backend, put a JS function body in handler.code. It runs in Telenow's hardened runtime (rquickjs): it gets the tool arguments and returns a result object, with no host, data, or network access — only computation.

{
  "name": "estimate_wait",
  "description": "Estimate the wait time in minutes from the number of patients ahead.",
  "parameters": {
    "type": "object",
    "properties": { "ahead": { "type": "number" } },
    "required": ["ahead"]
  },
  "handler": {
    "kind": "sandbox",
    "code": "return { minutes: Math.max(5, dv.ahead * 12) };"
  }
}

Sandbox tools are feature-flagged OFF by default. They run only when the platform operator sets the server env FEATURE_APP_SANDBOX to true (or 1) — they need platform/marketplace vetting before they're enabled. Until then, calling one mid-call aborts with:

sandbox app tools are disabled (set FEATURE_APP_SANDBOX=true)

Don't ship a sandbox tool as your only path to a critical feature on a stock install. If you need to read data or call an API, use a declarative object.* tool or an http tool instead.

code vs codeFile

At the tool level the sandbox body lives in handler.code only — an inline JS function-body string. The SDK's ToolHandler type exposes only code. (codeFile, a CLI convenience that bundles a .js file at build, is documented on the Manifest reference — but it's compiled down to code in the uploaded manifest; the server/tool runtime only ever sees code.)

Sandbox contract

The snippet is a function body, not an expression — it must return an object. Returning a scalar, undefined, or nothing fails with snippet must return an object.

  • Inputs: the tool arguments are exposed as the frozen global dv (alias vars). Read dv.ahead, dv.member_id, etc. You can't mutate it.
  • Pure compute, sandboxed: eval, Function, Promise, setTimeout/setInterval, fetch, XMLHttpRequest, require, import, process, WebAssembly, and similar host hooks are deleted. No async, no timers, no network, no filesystem. console.* is a no-op (logging won't error, but goes nowhere). import-as-statement is a syntax error (the body runs as a plain script).
  • Result shape: on success the runtime returns your object verbatim — exactly what you return, with no wrapper added (so add your own ok: true field if you want the agent to branch on it). On any error it returns { ok: false, status, error }, where status is one of error | timeout | oom | invalid | busy. Branch on this in your tool's description so the agent handles a failed computation gracefully.

Sandbox limits (enforced; not configurable in the manifest):

LimitValue
Source size≤ 16 KB (larger source is rejected before compiling)
Output size≤ 256 KB (serialized return)
Input size≤ 256 KB (the injected dv)
Heap16 MB hard cap
Native stack256 KB hard cap
Execution time100 ms – 5000 ms — independent of timeoutSecs
Global concurrencydefault 32 simultaneous sandboxes across the process → busy status when exceeded

Custom argument UIs with x-ui

Tools aren't only called by the agent — they also show up in the dashboard (for example, when a person fills the form manually). Any property in your parameters schema can carry an x-ui hint to control the widget rendered for that argument: { widget, label?, placeholder? }, where widget is "textarea", "date", and so on.

clinic-crm's book_appointment renders a multi-line box for the symptoms and a date picker for the slot:

{
  "name": "book_appointment",
  "description": "Book a clinic appointment for the caller, with the reason / problem for the visit.",
  "parameters": {
    "type": "object",
    "properties": {
      "patient_name": { "type": "string" },
      "phone": { "type": "string" },
      "problem": {
        "type": "string",
        "description": "reason / symptoms for the visit",
        "x-ui": { "widget": "textarea", "placeholder": "e.g. fever and cough for 3 days" }
      },
      "start": {
        "type": "string",
        "description": "appointment date-time, ISO 8601",
        "x-ui": { "widget": "date", "label": "Appointment date" }
      }
    },
    "required": ["patient_name", "phone", "start"]
  },
  "handler": { "kind": "object.create", "object": "appointment" }
}

x-ui only affects the input UI — the model still sees the plain JSON Schema.

A tool as a deterministic flow node

By default the model decides when to call a tool. Sometimes you want a step to run every time, in a fixed order — for example, look the caller up in your records before the agent's first word, so it can greet them by name. You can do that by placing your app tool in a kind: "tool" node — whose inner tool config is kind: "app" — inside a flow agent.

clinic-crm's bundled Smart Front Desk agent does exactly this: a tool flow node runs find_patient deterministically, then hands control to a conversation node that greets the caller:

{
  "id": "lookup",
  "name": "Look up caller",
  "kind": "tool",
  "config": {
    "name": "find_patient",
    "kind": "app",
    "config": {
      "app_id": "clinic-crm",
      "object": "patient",
      "handler": { "kind": "object.query" }
    }
  }
}

The node runs the lookup, the next node uses the result, and there's no guessing on the model's part. A deterministic app tool node is only allowed to run your own app's tools (the config.app_id must match), so a shipped template can't reach into another app's data. The node's "Arguments" editor can also set author defaults that fill any argument the flow omits (caller values still win). Building the bundled agent auto-binds the rest of the app's tools as usual. See Bundled agents & teams for the full graph format.

The clinic-crm tool set

For reference, here are the real tools clinic-crm ships and the handler each uses:

ToolHandlerWhat it does
find_patientobject.queryRecognise the caller by phone or name
register_patientobject.createCreate a new patient
book_appointmentobject.createBook a slot (uses x-ui for problem + date)
list_appointmentsobject.queryList a patient's appointments
log_visitobject.createRecord diagnosis, prescription, follow-up
cancel_appointmentobject.update + match + setMark the newest appointment cancelled
delete_appointmentobject.delete + matchRemove the newest appointment row
update_patientobject.update + matchUpdate status / condition / notes

All eight are declarative — the whole CRM works with zero backend code.

Next