Call destinations
Call destinations
Write what happened on every call into a spreadsheet or a table you already use — automatically, as each call ends.
Set it up on an agent under Post-call actions → After every call, write it to…, or for the whole workspace under Settings → Organization.
You can configure this on a brand-new agent before saving it — the destination is created together with the agent.
Why not just give the agent a tool?
You can already give an agent a "add a row to my sheet" tool, and it isn't the same thing:
- A tool fires only if the model decides to call it. A log has to be guaranteed.
- A tool cannot record a call nobody answered — there was no conversation for it to act in. That's usually the row you most want.
A destination is neither of those. It runs after the call, from the call record itself, whatever happened.
Where you can write
| Destination | Notes |
|---|---|
| Google Sheet | Values are matched to columns by header name, so inserting a column doesn't shift everything |
| Airtable table | Values land correctly in single-select and date fields, not just text |
| Database | PostgreSQL, MySQL, SQL Server or MongoDB — see Connecting your database |
Only what you've connected under Workplace → Integrations is offered. The four database engines sit behind one Database entry; click it to choose yours.
Picking a table and columns
For a database, you don't type names. The table list is read off the connection, and choosing a table lists its columns with their types:
Table call_log ▼
outcome · text status · text metadata · jsonb
Two things this catches before a single call is written:
- A column that is
NOT NULLwith no default and isn't mapped is flagged — the insert names only the columns you map, so leaving one out fails every write. - A table or column name that no longer exists shows as such rather than failing silently later.
If the role can't read the catalog, you get a text box and a note saying why — you're never stuck.
JSON and JSONB columns
A json/jsonb column can hold the real shape instead of flattened text:
- One field, keeping its shape —
analysis.topicsstays a real array, not"pricing, demo". - Several fields, as one object — tick what goes in, and the editor previews exactly what will be written:
{ "sentiment": "positive",
"action_items": ["Send the pricing PDF", "Book the Friday demo"],
"interested": "yes" }
Keys are the short names, so you query metadata->>'sentiment'. On SQL Server, which has no native JSON type, a bundle lands as JSON text.
CRMs are deliberately not here. Creating a new lead for every inbound call fills your CRM with duplicates. What's actually useful is an activity logged against the contact that already exists — that's a different feature, and it's coming.
What you can write
You map your column ← our field.
| Field | |
|---|---|
call.outcome | answered, no-answer, busy or failed — the carrier's own verdict, not a flattened "failed" |
call.ended_at call.attempt call.to call.from | |
session.call_id | The call's own id. Open it at /calls/<id> for the recording and transcript, or fetch it from the API as GET /v1/calls/<id> — it's the same sessionId your webhooks send. Names the call this row was written from; on a retried target that's the most recent attempt. Blank when the number was never dialled successfully, because there's no call to point at. |
record.disposition | How your agent graded the lead against the rules in its own prompt — QUALIFIED, NURTURE, NOT_INTERESTED. Not the same as call.outcome, which only says whether the phone was picked up. |
record.opted_out record.declined record.actions_done | Whether they asked not to be contacted; what they wouldn't give; which tools actually ran (written when the tool runs, never from what the agent said). |
keypad.digits | Every keypad digit the caller pressed, in order — 1,3. Blank if they pressed nothing. Campaign calls only. |
keypad.choices | What each press meant, in the same order — Confirm,Talk to team. The option's name as your flow defined it when the call happened, so a later edit can't re-label a call already written to your sheet. An unmapped press leaves its slot empty. |
analysis.summary analysis.sentiment analysis.disposition | from post-call analysis |
analysis.action_items analysis.topics | lists, written as comma-separated text |
analysis.custom.<name> | anything your agent extracts — analysis.custom.interested, analysis.custom.budget |
var.<name> | a context variable carried into the call |
'some text' | a literal, in single quotes |
analysis.custom.* is usually the interesting half. Whatever you've told the agent to extract is mappable straight into your own columns.
The picker shows you what each field actually contains, taken from this agent's own recent calls — so you're choosing by value, not by guessing at a name:
Sentiment analysis.sentiment
How the caller sounded overall.
e.g. positive
budget analysis.custom.budget
Stated budget, if the caller mentioned one.
⚠ Empty on all 12 recent calls — this column would stay blank
That last line is the useful one: a field your agent declares and never fills is otherwise only discoverable by mapping it and watching a blank column for a week.
A field with nothing in it is skipped, not blanked. If a call has no summary, that cell is left alone rather than overwritten with an empty string — we won't erase something you already had.
If you map an analysis field
Post-call analysis takes a moment to run, so the destination waits for it before writing. That means the row appears a minute or two after the call, not instantly — the editor tells you when a mapping does this.
If analysis can't complete, we write anyway after 30 minutes with those fields empty, and record why. A missing summary is better than a missing row.
Which calls
| Setting | |
|---|---|
| Which calls | Every call · Except campaign calls · Only campaign calls |
| Write when the call was | any of answered no-answer busy failed |
Use Except campaign calls when a campaign already writes back to its own source and you don't want the call logged twice.
More than one destination
An agent can have several, each with its own mapping — a summary row into your warehouse and a status flag into your CRM, say. Every one of them fires on every matching call, independently. A destination that maps analysis fields waits for analysis; one that doesn't writes as soon as the call ends. They don't wait for each other.
Workspace default vs per-agent
A destination set under Settings → Organization applies to every agent, and new agents inherit it automatically. That's the place to set it once if you want every call in your workspace logged to the same table — rather than repeating it on each agent and having the next one silently miss it.
An agent that defines its own uses only its own — it replaces the default rather than adding to it, so you never get a duplicate row you didn't configure.
The two panels are the same editor, and tell you which one you're looking at:
| Heading | Rows | |
|---|---|---|
| Settings → Organization | Workspace default — after every call… | yours to edit |
| An agent | After every call… | an inherited one carries a workspace default chip and is read-only here — change it in Organization, or add the agent's own to override it |
When something goes wrong
The error appears on the destination itself, with the vendor's own message — "No such column 'Call_Status__c'", "the sheet has no header row". That matters because the failure is otherwise invisible: the agent keeps answering calls perfectly and the sheet simply stops filling.
Failed writes are retried. Nothing is lost by a temporary outage at Google or Airtable.
Each call is written once per destination, even across restarts or multiple servers — so you won't get duplicate rows.
Campaign write-back is the same machinery
If you fill a campaign from a source, its write-back is a destination too — one that updates the row the contact came from rather than appending a new one. It's configured in the campaign's own importer, so it doesn't appear in these lists.
The difference is only the verb:
- Campaign — we read the row, so we know its address and can update it
- Agent — nothing was read (an inbound caller is in no list), so each call appends
API
/api/orgs/{orgId}/destinations
Everything the dashboard's destination editor does is available over the API. Authenticate with a user JWT + X-Org-Id. Reads need membership; writes need owner, admin or developer.
| Method | Path | Purpose |
|---|---|---|
GET | /kinds | What a call can be written to |
GET | /fields | What a call can be written about — the mapping surface |
GET | /tables?connectionId= | Tables in a connected database |
GET | /columns?connectionId=&table= | Columns in one of those tables |
GET | /?agentId= | This org's destinations; agentId narrows to one agent |
POST | / | Create or update one |
DELETE | /{id} | Remove one |
kinds and fields are endpoints rather than constants you hard-code, on purpose: adding a destination kind is a migration on our side, not a release on yours. Read them at runtime and your integration picks up new destinations without a deploy.
GET / deliberately excludes campaign write-backs. Those are configured in the campaign's own importer, and listing them here would offer two places to edit one thing.
Creating a destination
curl -X POST https://api.telenow.ai/api/orgs/$ORG/destinations \
-H "Authorization: Bearer $JWT" -H "X-Org-Id: $ORG" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "…",
"capability": "…",
"name": "Leads to CRM",
"mapping": { "their_field": "our.field.ref" },
"firesOn": ["answered"],
"scope": "non_campaign",
"chainMode": "final_only"
}'
| Field | Required | Default | Notes |
|---|---|---|---|
connectionId | Yes | — | The connected integration or database |
capability | Yes | — | What this destination does, from /kinds |
mapping | Yes | — | { "<their field>": "<our field ref>" }. Field refs come from /fields |
id | No | — | Present = update, absent = create |
agentId | No | org-wide | Omit for a default that applies to every agent |
name | No | — | Display label |
config | No | {} | Kind-specific settings |
firesOn | No | all four | Any of answered, no-answer, busy, failed |
scope | No | all | all · non_campaign · campaign_only |
chainMode | No | every_call | See below |
enabled | No | true |
An unknown value in firesOn, scope or chainMode is a 400 naming the valid set.
chainMode — the one that changes your numbers
A call-back chain is N calls for what the customer experienced as one request. every_call writes each of them: full history, but a report that counts rows counts that lead N times. final_only writes just the call that ended the thread.
The default is every_call so that no existing destination changes behaviour — but if your destination feeds a report that counts leads, final_only is almost certainly what you want.
Mappings that need post-call analysis
A mapping may reference fields the post-call analysis produces — the summary, the disposition, your own extracted customFields. When it does, the write-back waits for analysis to finish before firing, bounded by a 30-minute grace period after which it writes without them, because never writing is worse than writing without a summary.
Two consequences worth knowing: analysis must be enabled on the agent or those fields are always blank, and a call that falls under the agent's minimum-substance threshold is never analysed at all, so it takes the full grace period before writing.