JavaScript API
Use the JavaScript API when the drop-in widgets don’t fit, for example when you build your own call UI or need call events in your app. Everything here comes from sdk/js/src.
| Export | For |
|---|---|
Tring |
Agents. Connects to the gateway, keeps the agent online, and gives you a Call to answer, control and hang up. It can’t place calls. |
TringGuest |
Visitors. Places one call into an IVR flow, using credentials from your backend. |
RtcCall, RtcAgent |
The low-level clients for the gateway’s /rtc protocol that the two classes above are built on. |
ClientMsgType, ServerMsgType |
The protocol’s message type names (see The /rtc protocol). |
Loading the SDK
Section titled “Loading the SDK”Bundler (ESM). Build sdk/js and import from @tring/voice:
import Tring, { TringGuest, RtcCall, RtcAgent } from "@tring/voice";Tring is both the default export and a named export.
Script tag. The API serves the bundles, and each sets one global:
| Bundle | Global |
|---|---|
/sdk/tring.js |
window.Tring (the Tring class only) |
/sdk/agent.js |
window.Tring, and it also starts the agent widget |
/sdk/voice.js |
window.TringGuest, and it also starts the visitor widget if its data-* attributes are set |
RtcCall, RtcAgent and the protocol constants are available only from the ES module.
All tokens come from your backend. See the overview.
Events
Section titled “Events”Every class has the same small event emitter:
on(type, listener): () => void // returns a function that removes the listeneroff(type, listener): voidconst off = tring.on("call:ended", ({ call }) => console.log("ended", call.to));off(); // stop listeningIf a listener throws, the error is logged as [tring] listener error: and the other listeners still run.
The agent client. It connects to the gateway, registers the agent as online, and gives you a Call whenever a call rings this agent.
Tring.init(opts)
Section titled “Tring.init(opts)”static init(opts: InitOpts): Promise<Tring>Creates the client and starts connecting. The promise resolves once the token has been fetched and the WebSocket has been created. It does not wait for the agent to be online, so listen for registered to know when they are. It throws if gatewayUrl is missing, or if you pass neither getToken nor token.
At start-up, init also asks for the microphone, so later calls (including auto-answered ones) don’t need a new permission prompt. If the user denies it, the SDK asks again when they accept a call.
| Option | Type | Default | Meaning |
|---|---|---|---|
gatewayUrl |
string |
(required) | Gateway base URL, for example https://gw.tring.example.com. The SDK changes http(s) to ws(s) and connects to /rtc. |
getToken |
() => Promise<string> |
Returns a fresh agent JWT. It’s called on every connect and reconnect, and again for each accepted call. Preferred, and it wins over token. |
|
token |
string |
A static agent JWT from GET /v1/agents/{id}/token. It expires after 5 minutes, after which reconnects and new calls fail. |
|
autoAnswer |
boolean |
true |
Answer ringing calls automatically. Set it to false to show your own Accept/Decline UI. |
iceServers |
RTCIceServer[] |
[] |
Extra STUN/TURN servers. The gateway also sends its own when the call connects. |
const tring = await Tring.init({ gatewayUrl: "https://gw.tring.example.com", autoAnswer: false, getToken: async () => { // Your endpoint: verifies the agent session, calls GET /v1/agents/{id}/token const r = await fetch("/tring/agent-token", { credentials: "same-origin" }); if (!r.ok) throw new Error(`token endpoint ${r.status}`); return (await r.json()).token; },});tring.disconnect()
Section titled “tring.disconnect()”disconnect(): Promise<void>Takes the agent offline: closes the connection, stops reconnecting, and releases the microphone. Emits unregistered. A call already in progress uses its own connection and isn’t hung up, so hang it up first.
await tring.disconnect();Tring events
Section titled “Tring events”| Event | Payload | When |
|---|---|---|
registered |
{} |
The agent is online. Fires again after every successful reconnect. |
availability |
{ available: boolean } |
Your backend marked the agent Away (false) or back (true) with POST /v1/agents/{id}/availability. Arrives right after registered if the agent is already Away, then live on every change. tring.available holds the current value. |
reconnecting |
{ attempt: number } |
The connection dropped unexpectedly. The SDK retries with backoff (1 s, doubling, up to 10 s) and fetches a fresh token each time. attempt is currently always 0. |
unregistered |
{ reason?: string } |
The connection was closed by disconnect(). reason is currently not set. |
call:ringing |
{ call: Call } |
A call is ringing this agent. With autoAnswer: true, the SDK accepts it right away. |
call:resuming |
{ call: Call } |
The page was reloaded during a call, and the SDK is rejoining it on its own (no accept() needed). call:answered follows once the agent is back, or call:ended if the gateway already let the call go (about 30 seconds after the reload). The caller’s name isn’t known after a reload, so displayName is empty. |
call:answered |
{ call: Call } |
Audio is flowing. call.status is now "connected". On a click-to-call (POST /v1/calls), this fires when the customer picks up, not when the agent’s own leg answers. |
call:ended |
{ call: Call, cause?: string } |
The call ended: hung up, declined, taken by another agent, or ended by the server. cause is currently not set. |
call:remote-video |
{ call: Call, stream: MediaStream | null } |
The other party started sharing their screen (stream) or stopped (null). |
call:supervisor |
{ call: Call, present: boolean, mode: string, name: string } |
A supervisor started (present: true) or stopped watching this call. mode is, for example, "whisper". The customer is never told. |
error |
{ error: Error } |
A signaling, microphone or token error. If accepting a call fails, you get error, then call:ended for that call. |
tring.on("registered", () => setBadge(tring.available ? "online" : "away"));tring.on("availability", ({ available }) => setBadge(available ? "online" : "away"));tring.on("reconnecting", () => setBadge("reconnecting"));tring.on("call:ringing", ({ call }) => showIncoming(call.displayName ?? "Incoming call"));tring.on("call:answered", ({ call }) => openRecordFor(call.to));tring.on("call:ended", () => hidePanel());tring.on("error", ({ error }) => console.warn(error.message));The object you get in call:ringing, which is the same object in every later event for that call.
| Property | Type | Notes |
|---|---|---|
to |
string |
The label your backend set for the call, such as agent_label or an IVR label. Never a phone number. It can be "". |
displayName |
string | undefined |
The same label, or undefined when it’s empty. |
direction |
"inbound" | "outbound" |
"outbound" for a call the agent placed (click-to-call), otherwise "inbound". |
phone |
boolean |
true when the other party is on a phone line rather than a browser. Screen share can’t reach them. |
status |
"ringing" | "connected" | "ended" |
Updated in place as the call progresses. |
id |
string | undefined |
Declared in the type, but currently never set. |
muted |
boolean (read-only) |
Whether the agent’s microphone is muted. |
sharingScreen |
boolean (read-only) |
Whether the agent is sharing their screen. |
Methods
Section titled “Methods”accept(): Promise<void>Answers a ringing call: the agent joins the call over WebRTC. Call it from the Accept click so the microphone can be granted. It does nothing if the call is already accepted. It rejects, and emits error and then call:ended, if it can’t get a token or the microphone.
acceptBtn.onclick = () => call.accept().catch((e) => showError(e.message));hangup(): Promise<void>Ends the call. On a call that hasn’t been accepted, it declines instead, and Tring rings the next agent straight away.
endBtn.onclick = () => call.hangup();sendDTMF(digit: string): Promise<void>Sends a keypress (0–9, *, #, A–D) to the call’s IVR. Send one key per call: the gateway ignores anything else. The digit goes over the signaling socket, not as an audio tone. It does nothing before the call is accepted.
await call.sendDTMF("1");setMuted(muted: boolean): voidMutes or unmutes the agent’s microphone. This only affects the agent’s browser.
call.setMuted(!call.muted);hold(): voidresume(): voidhold() plays the tenant’s hold music to the other party and mutes the agent. resume() reverses both. They’re sent over the call socket, with no response, so update your UI right away.
let held = false;holdBtn.onclick = () => { held ? call.resume() : call.hold(); held = !held;};transfer(target: { toSip?: string; toTeam?: string }): voidBlind-transfers the call to one agent (toSip, their sip_username) or to a team (toTeam, its name). When the target answers, this agent’s call ends and you get call:ended. If the server rejects the transfer (target unavailable, or a transfer to yourself), the call carries on, and this class doesn’t currently report the failure. Use RtcCall’s transfer-failed event if you need it.
To list targets, call GET /v1/sdk/transfer-targets with the agent JWT (see Agent widget).
call.transfer({ toTeam: "billing" });// orcall.transfer({ toSip: "agent-2002" });shareScreen(constraints?: DisplayMediaStreamOptions): Promise<void>stopScreen(): voidStarts or stops sharing the agent’s screen with the other party. shareScreen opens the browser’s screen picker, so call it from a click. It rejects with "call not active" before the call is accepted, and rejects if the user cancels the picker. constraints is passed to getDisplayMedia and defaults to { video: true }. Sharing also stops if the user ends it from the browser’s own controls.
shareBtn.onclick = async () => { if (call.sharingScreen) return call.stopScreen(); try { await call.shareScreen({ video: true }); } catch { /* cancelled */ }};Showing the caller’s screen
Section titled “Showing the caller’s screen”Audio plays automatically. The SDK creates and removes its own hidden <audio> elements. Video doesn’t: put a shared screen into your own <video> element.
tring.on("call:remote-video", ({ stream }) => { videoEl.srcObject = stream; // null clears it videoEl.hidden = !stream;});TringGuest
Section titled “TringGuest”The visitor client. It places one call into an IVR flow. Nothing connects until you call call().
new TringGuest(opts)
Section titled “new TringGuest(opts)”new TringGuest(opts: GuestInitOpts)| Option | Type | Meaning |
|---|---|---|
credentialsUrl |
string |
Your endpoint that returns a GuestCredentialsResponse. It’s fetched with GET and credentials: "include" on every call(). |
fetchCredentials |
() => Promise<GuestCredentialsResponse> |
Returns credentials yourself, for example with a POST that carries a CAPTCHA response. It wins over credentialsUrl. |
gatewayUrl |
string |
Gateway base URL, if you don’t fetch credentials. Fetched credentials win. |
flowId |
string |
Flow UUID, if you don’t fetch credentials. For a visitor token, the gateway uses the flow pinned in the token instead. |
token |
string |
Call token, if you don’t fetch credentials. It’s single use and valid for 2 minutes, so prefer fetching. |
iceServers |
RTCIceServer[] |
Extra STUN/TURN servers. Fetched ice_servers win. |
The gateway always requires a token. In practice, use credentialsUrl or fetchCredentials so each call gets a freshly minted one.
GuestCredentialsResponse
Section titled “GuestCredentialsResponse”What your endpoint returns, unchanged from POST /v1/guest-credentials:
interface GuestCredentialsResponse { gateway_url: string; flow_id: string; token?: string; ice_servers?: RTCIceServer[];}If your endpoint responds with a non-2xx status, call() fails with the error field of its JSON body, or with credentials request failed (<status>).
Methods and getters
Section titled “Methods and getters”call(): Promise<void>Asks for the microphone, then fetches credentials, then connects and starts the call. Call it directly from a click: the microphone is requested before any network request because iOS Safari requires that. Calling it during a call does nothing. It emits connecting, ringing and connected, and throws (after emitting error) if the page isn’t on HTTPS, the microphone is denied, fetching credentials fails, or there’s no gateway URL.
const guest = new TringGuest({ credentialsUrl: "/tring/visitor-credentials" });callBtn.onclick = () => guest.call().catch((e) => showError(e.message));hangup(): Promise<void>Ends the call, or cancels it before it connects. It’s safe to call at any time.
sendDTMF(digit: string): Promise<void>Sends a keypress (0–9, *, #, A–D) to the IVR. Send one key per call: the gateway ignores anything else.
keypad.onclick = (e) => guest.sendDTMF(e.target.dataset.digit);mute(muted: boolean): Promise<void>toggleMute(): boolean // returns the new muted statemuteBtn.textContent = guest.toggleMute() ? "Unmute" : "Mute";shareScreen(constraints?: DisplayMediaStreamOptions): Promise<void>stopScreen(): voidtoggleScreen(): Promise<boolean> // returns true if sharing is now onShares the visitor’s screen with the agent. Call it from a click. shareScreen throws "not in a call" outside a call.
| Getter | Type | Meaning |
|---|---|---|
inCall |
boolean |
A call is in progress, from the moment it starts connecting until it ends |
isMuted |
boolean |
Whether the microphone is muted |
isSharingScreen |
boolean |
Whether the visitor is sharing their screen |
TringGuest events
Section titled “TringGuest events”| Event | Payload | When |
|---|---|---|
connecting |
{} |
call() started: asking for the microphone and fetching credentials |
ringing |
{} |
The connection to the gateway is being opened, and the call is waiting to be answered |
connected |
{} |
Audio is flowing: the visitor is in the IVR |
ended |
{ reason?: string } |
The call ended. reason is set when the server ended the call ("ended", "peer left", "disconnected", …). This can fire more than once for one call, so make your handler idempotent. |
remote-video |
{ stream: MediaStream | null } |
The agent started or stopped sharing their screen |
error |
{ error: Error } |
Something failed. See Errors. |
A complete custom visitor button, using the voice.js global:
<button id="call">Call us</button><button id="end" hidden>End</button><span id="status"></span>
<script src="https://tring.example.com/sdk/voice.js"></script><script> const guest = new TringGuest({ credentialsUrl: "/tring/visitor-credentials" }); const $ = (id) => document.getElementById(id);
guest.on("connecting", () => ($("status").textContent = "Connecting…")); guest.on("ringing", () => ($("status").textContent = "Calling…")); guest.on("connected", () => { $("status").textContent = "Connected"; $("end").hidden = false; }); guest.on("ended", () => { $("status").textContent = "Call ended"; $("end").hidden = true; }); guest.on("error", ({ error }) => ($("status").textContent = error.message));
$("call").onclick = () => guest.call().catch(() => {}); $("end").onclick = () => guest.hangup();</script>Loading voice.js without data-* attributes logs one console error (“provide data-gateway-url … or data-credentials-url”) and renders no widget. That’s expected here. With a bundler, import TringGuest instead.
Low-level clients
Section titled “Low-level clients”RtcCall and RtcAgent speak the gateway’s /rtc protocol directly. Tring and TringGuest are thin layers over them. You only need them for setups the classes above don’t cover. They’re available from the ES module only.
RtcCall
Section titled “RtcCall”One call: a WebSocket to /rtc plus a WebRTC connection.
new RtcCall(opts: RtcCallOpts)| Option | Type | Default | Meaning |
|---|---|---|---|
gatewayUrl |
string |
(required) | Gateway base URL |
room |
string |
(required) | Room id to join. The gateway keeps rooms separate per tenant. |
token |
string |
(required) | A visitor call token or an agent JWT |
getToken |
() => Promise<string> |
Fresh token for each connect (agents). It wins over token. |
|
name |
string |
Display name shown to the other participants | |
flowId |
string |
Flow to run in the room. For visitor tokens, the flow in the token is used instead. | |
iceServers |
RTCIceServer[] |
[] |
Added before the servers the gateway sends |
mic |
MediaStream |
A microphone stream you already have. If you pass it, join() doesn’t call getUserMedia. |
|
keepMic |
boolean |
false |
Don’t stop mic when the call ends, so you can reuse it |
reconnectAttempts |
number |
3 |
How many times to rejoin after an unexpected drop. 0 turns rejoining off. |
| Method | Description |
|---|---|
join(): Promise<void> |
Gets the microphone (unless you passed mic) and connects. Call it inside a click. It rejects with a friendly microphone message. |
hangup(): void |
Ends the call and emits ended. It doesn’t reconnect. |
sendDtmf(digit: string): void |
Sends a keypress to the room’s IVR |
setMuted(muted: boolean): void |
Mutes or unmutes the outgoing microphone |
hold(): void / resume(): void |
Hold music on or off for the other party. These also mute or unmute you. Agent tokens only. |
transfer({ toSip?, toTeam? }): void |
Blind transfer. Agent tokens only. |
shareScreen(constraints?): Promise<void> |
Starts sharing your screen. It throws "not connected" before the call has connected. |
stopScreen(): void |
Stops sharing your screen |
sharingScreen: boolean |
Read-only |
| Event | Payload |
|---|---|
connecting |
{}. Fires on each connect attempt. |
connected |
{}. The WebRTC connection is up. |
track |
{ kind: "audio" | "video", stream, track }. Audio plays automatically; video is up to you. |
peer-joined / peer-left |
{ participantId, name? } / { participantId }. When the last other participant leaves, the call ends with reason "peer left". |
reconnecting |
{}. The SDK is rejoining after an unexpected drop. |
ended |
{ reason?: string } |
supervisor |
{ present, mode, name }. Agent calls only. |
transfer-failed |
{ reason: string }. The server rejected the transfer, and the call carries on. |
error |
{ error: Error } |
import { RtcCall } from "@tring/voice";
const call = new RtcCall({ gatewayUrl, room: "support-1", token });call.on("transfer-failed", ({ reason }) => toast(`Transfer failed: ${reason}`));call.on("ended", ({ reason }) => console.log("ended", reason));await call.join();RtcAgent
Section titled “RtcAgent”The connection that keeps an agent online. It hands you an RtcCall when you accept a call.
new RtcAgent(opts: RtcAgentOpts)| Option | Type | Meaning |
|---|---|---|
gatewayUrl |
string |
(required) Gateway base URL |
name |
string |
(required) Display name used when joining calls |
token / getToken |
string / () => Promise<string> |
Agent JWT, or a function that fetches a fresh one (preferred) |
iceServers |
RTCIceServer[] |
Passed to each call |
| Method | Description |
|---|---|
goOnline(): Promise<void> |
Asks for the microphone (best effort) and connects. It reconnects with backoff until goOffline(). It throws "agent token required (token or getToken)" if it has no token. |
accept(): Promise<RtcCall | null> |
Joins the ringing call and returns its RtcCall, or null if nothing is ringing |
decline(): void |
Rejects the ringing call, so Tring rings the next agent straight away |
hasIncoming(): boolean |
Whether a call is ringing |
goOffline(): void |
Disconnects, stops reconnecting, and releases the microphone |
| Event | Payload |
|---|---|
online |
{} |
reconnecting |
{} |
offline |
{}. Fires only after goOffline(). |
incoming |
{ room: string, from?: string, auto?: boolean, phone?: boolean }. from is the call’s label, auto marks the agent’s own click-to-call, and phone means the caller is on a phone line. |
resume-call |
{ room: string, auto?: boolean, phone?: boolean }. The page reloaded mid-call and the gateway is still holding this agent’s place in room for about 30 seconds. Call accept() to rejoin it. |
incoming-canceled |
{ room: string }. The ring was withdrawn: another agent answered, or the ring moved on. |
error |
{ error: Error } |
import { RtcAgent } from "@tring/voice";
const agent = new RtcAgent({ gatewayUrl, name: "agent", getToken });agent.on("incoming", ({ from }) => showRing(from));acceptBtn.onclick = async () => { const call = await agent.accept(); call?.on("ended", () => hidePanel());};await agent.goOnline();Errors
Section titled “Errors”The SDK has no custom error classes. Every failure is a plain Error: it’s passed to error listeners and, for the methods marked above, the promise rejects with it. Messages you may see:
| Message | Cause |
|---|---|
Tring.init: gatewayUrl is required |
init was called without gatewayUrl |
Tring.init: provide `getToken` (preferred) or `token` |
init was called with no token |
Microphone needs HTTPS — … |
The page isn’t a secure context |
Microphone blocked — allow microphone access and try again. |
The user or browser denied the microphone (RtcCall) |
No microphone found on this device. / Your microphone is in use by another app. |
The device is missing or busy (RtcCall) |
credentials request failed (<status>), or your endpoint’s error text |
Your credentials endpoint failed (TringGuest) |
TringGuest: gatewayUrl (or credentialsUrl/fetchCredentials) is required |
call() had nowhere to connect |
signaling socket error / presence socket error |
The WebSocket to the gateway failed, for example a refused token (HTTP 401) or a network error |
token rejected |
A visitor token was already used. An expired or invalid token is refused at the WebSocket upgrade instead, and shows up as signaling socket error. |
call not active / not in a call / not connected |
Screen sharing was started outside a connected call |
TringGuest passes microphone errors through as the browser reported them (a DOMException such as NotAllowedError), so check error.name there.
The /rtc protocol
Section titled “The /rtc protocol”Both clients use one WebSocket to {gatewayUrl}/rtc?token=<jwt> (ws:// or wss://, from the gateway URL’s scheme) for signaling. Audio and video go over WebRTC (DTLS-SRTP, Opus) directly to the gateway. FreeSWITCH stays private.
- The first message sets the connection’s role.
registermakes it an agent’s presence connection: the gateway repliesreadyand later sendsincoming/incoming-cancel.join(with aroom) makes it a call connection. - The gateway makes the WebRTC offer. The client answers with
answerand exchangesicecandidates as they’re found, in both directions. - In-call messages from the client:
dtmf,hold,resume,transfer,publish/unpublish(screen sharing), andbye. From the server:peer-joined,peer-left,peer-state,ended,supervisor,transfer-failedanderror. - Agent-only messages.
hold,resume,transferanddialare accepted only on an agent’s call connection. Sent with a visitor token they’re ignored and the gateway replieserror(“only an agent can hold” / “only an agent can resume”),transfer-failedfortransfer, orerrorfordial. A visitor may senddtmf,publish/unpublish(screen sharing its own screen) andbye. - Who can join a call. A
jointo a room that already has a call in it is only accepted from an agent that was rung for it (incoming), and only the first of them to answer; anyone else getserror“not invited to this call”. Joining a new, empty room is always allowed. Asession_tokenrejoin is unaffected, and so is an agent rejoining the call the gateway is holding for them after a page reload (resume-call). - Limits. The first message must arrive within 10 seconds of connecting. A single message over 64 KB closes the socket.
- Keepalive. The gateway drops a socket that has been silent for about 90 seconds, so the SDK sends a
ping(calls) or a repeatedregister(presence) every 25 seconds. - Rejoining.
joinedreturns asession_token. After a drop, the client sends it in its nextjointo get its place in the call back within the gateway’s grace period.
All message types are exported as ClientMsgType and ServerMsgType, and the message shape as the RtcMessage type. The protocol’s source of truth is sdk/js/src/rtc-protocol.ts, which mirrors internal/media/signaling/protocol.go. See the architecture overview for how the gateway fits in.