Short, self-contained recipes for the embeddable chat widget. Each one does a single job and can be pasted straight into a page or your backend. The widget is the <telequick-chat> web component: it mints a conversation-scoped token, opens a real-time session (QUIC first, WebSocket fallback), and drives the same visual flow / step engine as TeleQuick Voice — announcements, waits with queue position, forms, and skill queues — plus AI text agents and human handoff.
Chat is widget- and JS-first today. There is no polyglot SDK for chat: you embed the web component and configure it, or speak the envelope protocol directly from a custom client over raw MoQT. Server-side token minting uses your normal control-plane credentials.

Add chat to a webpage

Drop the widget on any page in two moves: mint a conversation-scoped token on your server (never ship your API key to the browser), then render the element with that token.
1

Mint a scoped token server-side

Exchange your server credential for a short-lived token bound to one conversation namespace (chat/<org>/<conv>). Do this in a backend route and hand the result to the page.
// POST /api/chat-token  →  { token, conversation, expiresAt }
export async function mintChatToken(orgId: string) {
  const res = await fetch("https://engine.telequick.dev/v1/chat/tokens", {
    method: "POST",
    headers: {
      "authorization": `Bearer ${process.env.TELEQUICK_CREDENTIALS}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      org: orgId,
      flow: "support",       // the flow/step program to run
      ttlSec: 900,           // token lifetime
    }),
  });
  if (!res.ok) throw new Error(`mint failed: ${res.status}`);
  return res.json(); // { token, conversation, expiresAt }
}
import os, requests

def mint_chat_token(org_id: str) -> dict:
    res = requests.post(
        "https://engine.telequick.dev/v1/chat/tokens",
        headers={"authorization": f"Bearer {os.environ['TELEQUICK_CREDENTIALS']}"},
        json={"org": org_id, "flow": "support", "ttlSec": 900},
        timeout=10,
    )
    res.raise_for_status()
    return res.json()  # { token, conversation, expiresAt }
curl -sX POST https://engine.telequick.dev/v1/chat/tokens \
  -H "authorization: Bearer $TELEQUICK_CREDENTIALS" \
  -H "content-type: application/json" \
  -d '{"org":"org_abc","flow":"support","ttlSec":900}'
org
string
required
Your organization / workspace id. Scopes the conversation namespace.
flow
string
Id of the flow (built in the console with the same step engine as voice) the conversation runs. Omit to use your default flow.
ttlSec
number
default:"900"
Token lifetime in seconds. The widget re-mints on expiry via your endpoint.
2

Render the widget with the token

Load the widget loader once, then place the element and pass the minted token. Everything below is the complete, self-contained embed.
index.html
<!-- 1. load the widget once (defines the custom element) -->
<script
  src="https://relay.telequick.dev/embed/chat.js"
  async
></script>

<!-- 2. drop the element; fetch the token from YOUR backend -->
<telequick-chat
  id="support-chat"
  org="org_abc"
  title="Support"
  subtitle="We usually reply in a minute"
  position="bottom-right"
></telequick-chat>

<script>
  // hand the element a fresh conversation-scoped token
  const el = document.getElementById("support-chat");
  el.tokenProvider = async () => {
    const r = await fetch("/api/chat-token", { method: "POST" });
    const { token } = await r.json();   // from mintChatToken() above
    return token;
  };
</script>
Pass a tokenProvider function rather than a static token attribute so the widget can silently re-mint when the token expires or the visitor starts a new conversation. A static token="…" attribute also works for quick tests.
That is a working chat launcher. The flow named in the token (support) runs on connect — greet, collect, queue — exactly as you built it in the console.

Present a form

Forms are flow-driven: a step in your flow presents a form and blocks until the visitor submits a valid form_result. The widget renders the fields, does client-side validation, and returns the structured result to the engine — no front-end code required. Add a form step to the flow the token points at:
flow: support (excerpt)
{
  "steps": [
    { "type": "announce", "text": "Hi! Before we start — a couple details." },
    {
      "type": "form",
      "id": "intake",
      "title": "How can we help?",
      "fields": [
        { "name": "email",  "label": "Work email", "type": "email", "required": true },
        { "name": "topic",  "label": "Topic", "type": "select",
          "options": ["Billing", "Technical", "Sales"], "required": true },
        { "name": "detail", "label": "Details", "type": "textarea" }
      ]
    },
    { "type": "queue", "skill": "support" }
  ]
}
The engine emits a form envelope; the widget renders it and, on submit, publishes a form_result envelope that the flow validates before advancing. The captured values are available to downstream steps and to the agent who picks up.
Building forms with a custom (non-widget) client? You receive the form envelope on the server track and publish a matching form_result on the client track yourself — see the envelope protocol.

Route to a skill / queue

A queue step places the conversation into a skill queue and announces position while the visitor waits. The same skill can be staffed by human agents, an AI text agent, or both — routing picks whoever is eligible and available.
{ "type": "queue", "skill": "billing", "announcePosition": true }
{
  "type": "switch",
  "on": "intake.topic",
  "cases": {
    "Billing":   [ { "type": "queue", "skill": "billing" } ],
    "Technical": [ { "type": "queue", "skill": "tier1" } ],
    "Sales":     [ { "type": "queue", "skill": "sales" } ]
  }
}
Skills, staffing, and concurrency (how many chats one agent can hold at once) are configured in the console. A queued conversation that no one accepts follows your RONA / re-offer policy just like a queued voice call.

Attach an AI text agent

Point a queue step — or the whole flow — at an AI text agent to answer automatically. It is the same agent runtime as voice, in text mode: the same tools, knowledge, and DAG, driven by text turns instead of audio.
{
  "steps": [
    { "type": "announce", "text": "Hi! I'm an assistant — ask me anything." },
    { "type": "agent", "agent": "support-bot" }
  ]
}
<telequick-chat
  org="org_abc"
  agent="support-bot"
  title="Ask the docs bot"
></telequick-chat>
The AI agent streams replies token-by-token as message envelopes and can raise a typing indicator between turns. When it hits a tool or policy that requires a person, it escalates — see the next recipe.

Hand off to a human

Escalation is a flow transition: the AI agent (or a switch on its outcome) routes the live conversation into a human skill queue. The conversation keeps its id, so the visitor’s history, the captured form, and the AI transcript are all in front of the human when they accept.
flow: AI first, human on escalate
{
  "steps": [
    { "type": "agent", "agent": "support-bot", "onEscalate": "to-human" }
  ],
  "labels": {
    "to-human": [
      { "type": "announce", "text": "Connecting you with a specialist…" },
      { "type": "queue", "skill": "support-humans" }
    ]
  }
}
1

The AI agent decides a human is needed

Any tool, guardrail, or explicit “talk to a person” intent transitions the flow to the to-human label.
2

The conversation queues to a human skill

It is offered on the agent console. A human agent can hold several concurrent chats, so pickup is fast; unanswered offers re-offer per your RONA policy.
3

The human takes over in place

The agent’s messages are relayed onto the same server track the widget already subscribes to — the visitor’s chat window never reconnects or loses scrollback.
The full agent-side experience (accepting offers, the multi-chat workstation, what the human sees on pickup) lives in the console, not the widget. Media escalation from a text chat to a voice/video call is on the roadmap; today handoff is text-to-text.

Send a file attachment

Attachments ride out of band: you upload the raw bytes over HTTPS, get back an attachment meta, then publish a normal message envelope that references it. The bytes never travel through the real-time session — only the small meta does.
On the widget this is zero-code: the paperclip button, drag-and-drop, and a multi-file picker (<input accept>) upload and attach for you. The recipe below is for custom clients speaking the envelope protocol directly.
1

Upload the bytes, get an attachment meta

POST the raw file bytes to the upload endpoint with the session token (the same conversation-scoped token the widget/client already holds). Name the file in the x-cc-filename header and set Content-Type to its MIME type. The endpoint enforces an allowed-MIME allowlist and a max size and rejects anything else.
// Returns the attachment meta to reference from a message envelope.
async function uploadAttachment(token: string, file: File) {
  const res = await fetch("https://engine.telequick.dev/v1/chat/upload", {
    method: "POST",
    headers: {
      "authorization": `Bearer ${token}`, // conversation-scoped session token
      "x-cc-filename": file.name,
      "content-type": file.type,           // must be an allowed MIME type
    },
    body: file,                            // raw bytes — NOT multipart
  });
  if (!res.ok) throw new Error(`upload rejected: ${res.status}`); // MIME/size
  return res.json(); // { id, name, size, mime, url }
}
import requests

def upload_attachment(token: str, path: str, mime: str) -> dict:
    with open(path, "rb") as f:
        res = requests.post(
            "https://engine.telequick.dev/v1/chat/upload",
            headers={
                "authorization": f"Bearer {token}",
                "x-cc-filename": path.rsplit("/", 1)[-1],
                "content-type": mime,
            },
            data=f,               # raw bytes
            timeout=30,
        )
    res.raise_for_status()        # 4xx if MIME/size not allowed
    return res.json()             # { id, name, size, mime, url }
# $CHAT_TOKEN is the conversation-scoped session token
curl -sX POST https://engine.telequick.dev/v1/chat/upload \
  -H "authorization: Bearer $CHAT_TOKEN" \
  -H "x-cc-filename: quote.pdf" \
  -H "content-type: application/pdf" \
  --data-binary @quote.pdf
# → {"id":"att_9f…","name":"quote.pdf","size":48213,
#    "mime":"application/pdf","url":"https://engine.telequick.dev/v1/chat/attachment/…"}
x-cc-filename
header
required
Original file name. Preserved in the meta and used to build the download URL.
content-type
header
required
The file’s MIME type. Must be on the allowed list or the upload is rejected.
id
string
Attachment id, unique within the conversation.
name
string
File name as uploaded.
size
number
Byte size the engine recorded.
mime
string
Stored MIME type.
url
string
Download URL for the bytes, served at GET /v1/chat/attachment/<conv>/<id>/<name>. Object storage sits behind it.
2

Publish a message envelope that carries the meta

Send a normal message envelope with data.attachments set to an array of the metas you got back. text is optional — an attachment-only message can leave it empty. Attach several files by uploading each, then listing all their metas.
// `publish` is your client-track publisher — see the envelope protocol page.
async function sendFile(token: string, file: File, text = "") {
  const meta = await uploadAttachment(token, file); // step 1
  await publish({
    kind: "message",
    data: {
      text,                 // "" for an attachment-only message
      attachments: [meta],  // one or more { id, name, size, mime, url }
    },
  });
}
{
  "kind": "message",
  "data": {
    "text": "Here's the quote you asked for.",
    "attachments": [
      { "id": "att_9f…", "name": "quote.pdf", "size": 48213,
        "mime": "application/pdf", "url": "https://engine.telequick.dev/v1/chat/attachment/…" }
    ]
  }
}
The engine emits a chat.file.uploaded event { role, count } and records "[file: <names>]" in the transcript, so an attached AI text agent has context about what was shared even though it can’t read the bytes.

React to a message

Every message envelope carries a mid — an author-owned message id, unique within the conversation, that the engine stamps and relays. Reactions (and quoted replies via reply_to) address a message by its mid. To react, publish a reaction envelope with data { op, emoji, target_mid }.
On the widget this is zero-code: the hover quick-reaction strip and the emoji picker publish these envelopes for you, and reaction pills show live counts. The recipe below is for custom clients.
// Add or remove an emoji reaction on a message, addressed by its mid.
async function react(mid: string, emoji: string, op: "add" | "remove" = "add") {
  await publish({
    kind: "reaction",
    data: { op, emoji, target_mid: mid },
  });
}

// e.g. react to the last agent message you received
react(lastMessage.data.mid, "👍");          // add
react(lastMessage.data.mid, "👍", "remove"); // undo
{ "kind": "reaction", "data": { "op": "add", "emoji": "👍", "target_mid": "m_7f3a…" } }
op
'add' | 'remove'
required
Add the reaction, or remove one you previously added.
emoji
string
required
The emoji to attach, e.g. "👍", "🎉", "❤️".
target_mid
string
required
The mid of the message you’re reacting to.
The engine relays the reaction to the other side and emits a chat.reaction event { role }. Reaction envelopes are their own kind — the full set is message | typing | form | form_result | reaction | event, documented in the envelope protocol.

Custom theme

Match the widget to your brand with attributes for the common knobs and CSS ::part() selectors for deep styling. The widget is a real custom element, so its shadow parts are stylable from your page CSS.
<telequick-chat
  org="org_abc"
  mode="dark"
  accent="#6d28d9"
  radius="16"
  position="bottom-left"
  launcher-icon="chat"
></telequick-chat>
telequick-chat::part(launcher) {
  background: linear-gradient(135deg, #6d28d9, #4f46e5);
  box-shadow: 0 8px 24px rgba(79, 70, 229, .35);
}
telequick-chat::part(header) { font-weight: 600; }
telequick-chat::part(bubble-agent)   { background: #f5f3ff; }
telequick-chat::part(bubble-visitor) { background: #4f46e5; color: #fff; }
mode
'light' | 'dark' | 'auto'
default:"auto"
Color scheme. auto follows the visitor’s prefers-color-scheme.
accent
string (CSS color)
Primary accent used for the launcher, buttons, and visitor bubbles.
radius
number
Corner radius in pixels for the panel and bubbles.
position
'bottom-right' | 'bottom-left'
default:"bottom-right"
Docked corner for the floating launcher.

Mount programmatically

Skip the declarative markup and create the widget from JavaScript — for SPAs, consent-gated loading, or opening the panel on your own button. The element exposes open(), close(), and a config object.
// create and configure the element after the loader has run
const chat = document.createElement("telequick-chat");

chat.config = {
  org: "org_abc",
  flow: "support",
  title: "Support",
  mode: "auto",
  accent: "#6d28d9",
  tokenProvider: async () => {
    const r = await fetch("/api/chat-token", { method: "POST" });
    return (await r.json()).token;
  },
};

document.body.appendChild(chat);

// open the panel from your own CTA instead of the floating launcher
document.querySelector("#help-button")
  ?.addEventListener("click", () => chat.open());

// react to lifecycle in your app (analytics, unread badges, …)
chat.addEventListener("conversation", (e) => {
  console.log("conversation id:", e.detail.conversation);
});
chat.addEventListener("message", (e) => {
  console.log(e.detail.role, e.detail.text); // 'visitor' | 'agent' | 'ai'
});
Guarding load behind consent? Inject the https://relay.telequick.dev/embed/chat.js script tag only after the visitor opts in, then run the snippet above once it fires load. Nothing connects until the element mounts and a token is provided.
For a fully custom UI (no widget at all), drop to the wire: subscribe the server track and publish client envelopes yourself over raw MoQT. The message, typing, form, and event envelope shapes are documented in Widget & API.

Widget & envelope protocol

Every widget attribute and the JSON envelope wire for custom clients.

Chat overview

How conversations, tracks, flows, and agents fit together.

End-to-end recipes

Full builds: a support widget with AI-to-human handoff and lead capture.

Deployment models

Where the engine runs and how to make chat highly available.