Build a custom chat experience

Guide: Build a custom chat experience

This recipe puts an agent's brain — its system prompt, knowledge bases (RAG) and tools — behind your own chat UI, over plain REST. You send text in, you get the agent's reply back. No WebSocket, no audio, and no RAG pipeline of your own to build: the agent already knows your knowledge and runs your tools.

If you'd rather not build any UI at all, Telenow ships a ready-made chat widget — enable Text chat in the agent's Publish panel and embed it. This guide is for teams who want a bespoke chat surface (a help-centre bot, an in-app assistant, a Slack relay) wired to their own front-end.

Everything here runs server-side: the Chat API authenticates with an X-API-Key header, and that key must never reach a browser. Your front-end talks to your backend; your backend talks to Telenow.

At a glance. First message: POST /api/v1/chat { agentId, identifier, input }{ sessionId, reply }. Follow-ups: send the returned sessionId. On 410, start a fresh session. That's the whole protocol.

Architecture

Your UI ──user message──▶ Your backend ──POST /api/v1/chat (X-API-Key)──▶ Telenow
Your UI ◀──agent reply──  Your backend ◀──── { sessionId, reply, turn } ──

The API key lives only on your backend. Hold one sessionId per ongoing conversation (in your DB or session store) and send it back on every follow-up.

Prerequisites

Before you write any code, make sure you have these three things from the dashboard:

  1. An agent — build and configure one in Building agents. Attach any knowledge bases and tools you want available in chat. The agent must be active; an inactive agent returns 400.
  2. Its agent id — open the agent and copy the id from Agent detail, or list agents via the API (GET /api/v1/agents). You can also grab it straight from Agent detail → Publish tab → Chat API, which shows ready-to-run samples pre-filled with this agent's id.
  3. An API key — see the next section.

1. Get an API key

The Chat API accepts API keys only (no user JWT). One key is scoped to one organization and carries a role — see Authentication for the full model.

  1. In the sidebar, go to Developers → API keys.
  2. Click New key (top right). You need the owner, admin, or developer role to mint one.
  3. Give it a Name (e.g. Chat backend) and click Create.
  4. The full secret is shown once in a Save your API key dialog — it starts with vai_live_. Copy it now and store it in your backend's secrets (an env var like TELENOW_API_KEY); Telenow keeps only a hash and will never show it again.

Keep it server-side. Never ship vai_live_… to a browser or mobile app. The Chat API has no browser-safe variant — if you need an anonymous, in-page chat surface, use the public chat widget instead, which never exposes a key.

2. Send the first message

Omit sessionId on the first request; the response returns one. The identifier is your unique id for the end user (1–128 characters) — pick whatever you already use (a logged-in user id, an account number, a session cookie). It binds the conversation to that user so a leaked sessionId can't be reused by anyone else.

curl -X POST https://api.telenow.ai/api/v1/chat \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "agentId": "<AGENT_ID>", "identifier": "user-42", "input": "Hello!" }'
{ "sessionId": "8f3c…", "reply": "Hi! How can I help?", "turn": 1, "identifier": "user-42" }

Save that sessionId keyed to the end user — you'll send it on every follow-up. The reply is the agent's full answer, including the results of any tools it called along the way.

Request fields

FieldTypeNotes
agentIdUUIDRequired. Must belong to your org (the key's org) and be active. Cross-org or unknown ids return 404
identifierstringRequired. Your unique end-user id, 1–128 characters (trimmed). Binds the session
inputstringRequired. The user's message (trimmed; empty → 400)
sessionIdUUIDOmit on the first message; send the returned id on every follow-up
variablesobjectFirst message only — a { "name": "value" } map for the agent's context variables. Any required variable that's missing → 400 listing the names

Context variables

If your agent's prompt or greeting uses {placeholder} variables, pass them in the variables object on the first message — they're stored for the whole session:

{ "agentId": "<AGENT_ID>", "identifier": "user-42", "input": "Hi",
  "variables": { "customer_name": "Jordan", "plan": "Pro" } }

Required variables must be present on that first call or the request is rejected with 400 Missing required variable(s): …. You don't resend them on follow-ups.

3. Send follow-ups

Send the returned sessionId together with the same identifier and agentId. The agent keeps the entire conversation context — you don't resend prior messages.

curl -X POST https://api.telenow.ai/api/v1/chat \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "agentId": "<AGENT_ID>", "identifier": "user-42", "sessionId": "8f3c…", "input": "Tell me more" }'
{ "sessionId": "8f3c…", "reply": "Sure — …", "turn": 2, "identifier": "user-42" }

turn is the running count of user messages this session has seen (1-based). Handy for client-side display or for capping a conversation's length.

One turn at a time. A session generates one reply at a time. If you fire a second message while the previous reply is still being produced, you get 409 { "error": "turn in progress" }. Serialize your sends per sessionId — wait for each reply (or the 409) before sending the next.

4. Handle 410 SESSION_EXPIRED

A chat session is a live runtime. It goes away when you end it, when it sits idle for 30 minutes (or hits the agent's configured max duration), or if the server restarts. The next message you send against a dead session returns:

HTTP/1.1 410 Gone
{ "error": "session expired", "code": "SESSION_EXPIRED" }

The recovery is simple: resend the user's message without sessionId. That starts a fresh session; use the new sessionId it returns from then on. (Note the new session starts with no prior context — re-send any variables your agent needs.) Bake this into your send loop so a long-idle chat transparently reconnects:

send(message, sessionId) →
  if 410: sessionId = null; retry send(message, null)
  if 409: wait a moment, then retry

5. Fetch the transcript

Pull the full user/assistant transcript any time — to render history when a user re-opens the chat, or to archive it:

GET /api/v1/chat/:sessionId/messages
curl https://api.telenow.ai/api/v1/chat/8f3c…/messages \
  -H "X-API-Key: vai_live_…"
{
  "sessionId": "8f3c…",
  "messages": [
    { "role": "user",      "content": "Hello!",            "createdAt": "2026-06-13T10:00:01Z" },
    { "role": "assistant", "content": "Hi! How can I help?", "createdAt": "2026-06-13T10:00:03Z" }
  ]
}

System messages are omitted. The session is scoped to your org by the key — asking for a session that belongs to another org returns 404.

6. End the conversation

When the user closes the chat, end the session explicitly. This runs the same teardown as a widget close — duration, billing settlement and post-call analysis all fire immediately rather than waiting for the idle timeout:

POST /api/v1/chat/:sessionId/end
curl -X POST https://api.telenow.ai/api/v1/chat/8f3c…/end \
  -H "X-API-Key: vai_live_…"
{ "sessionId": "8f3c…", "ended": true }

It's idempotent — ending an already-ended session still returns 200. Only chat-API sessions can be ended here; pointing it at a voice session returns 404. Sessions you never end explicitly close automatically after 30 minutes idle, but calling end promptly is good hygiene.

Errors

Errors return { "error": "…" } with the matching status code. The flat shape (no {success,data} envelope) is the same for every /api/v1 endpoint.

StatusWhenWhat to do
400Missing/invalid field, inactive agent, or a missing required variable (names listed in the message)Fix the request
401Missing or invalid API keyCheck the X-API-Key header — use the full vai_live_… secret
403identifier doesn't match the session's bound end userResume with the identifier the session was created with
404Unknown agent or session, or it belongs to another orgCheck the ids and that the key is for the right org
409{ "error": "turn in progress" } — a reply is still being generatedWait for the in-flight reply, then retry
410{ "error": "session expired", "code": "SESSION_EXPIRED" }Restart: resend the message without sessionId, then continue with the new id

With the backend SDK (shortcut)

If you'd rather not hand-roll the loop above, the Node & Python backend SDKs wrap the Chat API. The low-level methods map 1:1 to the endpoints (tn.chat.send / tn.chat, tn.chat.messages / tn.chat_messages, tn.chat.end / tn.chat_end), and a send-loop helper holds the sessionId, restarts on 410, and waits out 409 for you — keep one per end user:

import { Telenow, chatLoop } from '@telenow/server';
const tn = new Telenow({ apiKey: process.env.TELENOW_API_KEY });

const convo = chatLoop(tn, { agentId: AGENT_ID, identifier: 'user-42', variables: { customer_name: 'Jordan' } });
const a = await convo.send('Hello!');                 // turn 1
const b = await convo.send('What are your hours?');   // turn 2 (transparently restarts on 410)
const { messages } = await convo.messages();          // transcript so far
await convo.end();                                    // settle billing + analysis now
from telenow import Telenow
tn = Telenow(api_key=os.environ["TELENOW_API_KEY"])

convo = tn.chat_conversation(AGENT_ID, "user-42", variables={"customer_name": "Jordan"})
a = convo.send("Hello!")                 # turn 1
b = convo.send("What are your hours?")   # turn 2 (transparently restarts on 410)
msgs = convo.messages()["messages"]      # transcript so far
convo.end()                              # settle billing + analysis now

Prefer the raw protocol (other languages, or no dependency)? The plain-fetch version below does exactly the same thing.

Full Node example (no SDK)

A self-contained server-side helper that creates a session, sends follow-ups, and transparently recovers from 409 / 410 — plain fetch, no SDK.

// chat.js — server-side only. Requires Node 18+ (built-in fetch).
const BASE = 'https://api.telenow.ai';
const API_KEY = process.env.TELENOW_API_KEY;   // vai_live_… — never in the browser
const AGENT_ID = process.env.TELENOW_AGENT_ID;

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Sends one message. `state` is { sessionId } you keep per end user.
// Returns { reply, turn }; mutates state.sessionId on (re)creation.
async function chat(state, identifier, input, variables) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const body = {
      agentId: AGENT_ID,
      identifier,
      input,
      ...(state.sessionId ? { sessionId: state.sessionId } : {}),
      // variables are only honored on the first message of a session
      ...(!state.sessionId && variables ? { variables } : {}),
    };

    const res = await fetch(`${BASE}/api/v1/chat`, {
      method: 'POST',
      headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });

    if (res.status === 410) {        // session expired → start fresh, keep the message
      state.sessionId = null;
      continue;
    }
    if (res.status === 409) {        // a turn is still generating → back off and retry
      await sleep(800);
      continue;
    }
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(`chat ${res.status}: ${err.error || res.statusText}`);
    }

    const data = await res.json();   // { sessionId, reply, turn, identifier }
    state.sessionId = data.sessionId;
    return { reply: data.reply, turn: data.turn };
  }
  throw new Error('chat: gave up after repeated 409/410');
}

// --- demo ---
const state = {};                                  // persist this per end user
const a = await chat(state, 'user-42', 'Hello!', { customer_name: 'Jordan' });
console.log('assistant:', a.reply);                // turn 1
const b = await chat(state, 'user-42', 'What are your hours?');
console.log('assistant:', b.reply);                // turn 2

// fetch history later
const hist = await fetch(`${BASE}/api/v1/chat/${state.sessionId}/messages`, {
  headers: { 'X-API-Key': API_KEY },
}).then((r) => r.json());
console.log(hist.messages.length, 'messages');

// end when the user closes the chat
await fetch(`${BASE}/api/v1/chat/${state.sessionId}/end`, {
  method: 'POST', headers: { 'X-API-Key': API_KEY },
});

Wire your front-end to a tiny route on your own server (e.g. POST /chat) that calls chat(...) and returns just { reply } to the browser. The key and sessionId never leave your backend.

Conversations show up in Call history

Every chat session lands in Call history as a call of type chat, with the full transcript, duration, billing and post-call analysis — exactly like a voice call, just text. You can filter for them, open one to read the transcript, and they count toward usage and billing the same way.

They also fire the same webhook events as voice calls: call.started when the session is created, tool.invoked for each tool the agent runs, and call.ended + call.analyzed at teardown. That means you can ingest chat outcomes into your CRM alongside phone calls with no extra wiring — see Guide: Receive & verify webhooks.

Tips

  • Replies are synchronous. The HTTP response returns only when the agent's full reply — including any tool calls — is ready. Use a generous client timeout (60 s+), especially for agents with slow HTTP tools.
  • Persist sessionId, not the transcript. The agent keeps context server-side; you only need to remember the sessionId per end user. Fetch the transcript on demand if you want to render history.
  • Keep identifier stable per user. Resuming a session with a different identifier returns 403. If you genuinely switch users, start a new session.
  • Voice-only tools don't run in chat. The agent's HTTP tools, knowledge bases and context variables all work; native voice actions like transfer and hangup are unavailable (there's no call to transfer).
  • End sessions you're done with. It settles billing and triggers analysis right away instead of waiting out the 30-minute idle timeout.

Troubleshooting

SymptomLikely causeFix
401 on every requestWrong header or truncated keySend the full vai_live_… secret in X-API-Key; don't use the last-four shown in the dashboard
404 Agent not foundAgent id belongs to another org, or doesn't existConfirm the id and that the key is for the same org (GET /api/v1/me / GET /api/v1/agents)
400 Agent is not activeAgent toggled offActivate the agent in Agent detail
400 Missing required variable(s)A required {placeholder} wasn't sent on the first messageInclude it in variables on the first call (not on follow-ups)
409 turn in progressA previous reply is still generatingSerialize sends per sessionId; wait for each reply before the next
410 SESSION_EXPIREDSession ended, idled out, or server restartedResend without sessionId to start fresh (re-send any required variables)
Chat doesn't appear in Call historyLooking at the wrong filterFilter by type chat; the session also appears once it has at least started