Web widget
One agent as one button on any page: the tag, the two endpoints your server exposes, and the look.
A web component that puts a Pinecall agent on any page: a button that opens a voice call from the browser, a chat, or the agent's phone number with the call's live transcript. One JavaScript file, no build step, no framework dependency.
It is served by your gateway at https://<gateway>/widget/pinecall-widget.js, and by jsDelivr at
https://cdn.jsdelivr.net/gh/pinecall/widget@main/pinecall-widget.js.
<script type="module" src="https://cloud.pinecall.io/widget/pinecall-widget.js"></script>
<pinecall-widget agent="bidfire-sales" name="Sam" company="BidFire"
token-url="/pinecall/token" log-url="/pinecall/log"></pinecall-widget>
The tag#
| attribute | ||
|---|---|---|
agent |
required | the agent's slug, as pinecall start prints it |
token-url |
required, unless tokenProvider is set |
your token endpoint (below) |
log-url |
optional | your log endpoint (below): transcripts from the call log, live phone calls, a "Show tools" toggle |
code-url |
optional | your code endpoint (below): "Call us" shows the number and a 4-digit code that ties the phone call to this page |
gateway |
optional | gateway origin for codes and their logs; defaults to the script's origin (cloud.pinecall.io for the jsDelivr copy) |
phone |
optional | number shown by "Call us" when there is no code-url; with neither, "Call us" is hidden |
name |
optional | what the agent is called on screen, Assistant when left out |
company |
optional | whose agent it is, shown under the name |
tagline |
optional | the line under the name, after the company |
label |
optional | the button's text, Talk to <name> when left out |
icon |
optional | none leaves the button without its icon |
button |
optional | none draws no button at all: open the panel with toggle() from anything of yours |
lang |
optional | the language of the widget's own words — its menu, buttons and statuses: en (default) or es. The agent answers in whatever language the visitor speaks either way |
position |
optional | bottom-right (default), bottom-left, top-right, top-left, or inline: the button right where the tag is, in the page's own flow |
greeting |
optional | the agent's first bubble, shown before a call starts; plain text (markup is escaped) |
theme |
optional | auto (default, follows prefers-color-scheme), light or dark |
autostart |
optional | the opening click starts a voice call directly; if the microphone is refused, the menu opens with the reason |
history |
optional | off keeps no conversations in this browser. By default the visitor's last 20 chats and calls with this agent are kept in localStorage — the lines as they settled, at most 200 each — listed under Your conversations in the menu, any one readable again, and cleared by the visitor with Clear |
Attributes are read once when the element connects, except token-url, which is read for every
token. Use one tag per agent; several can share a page. The console stores these settings per
agent (GET/PUT /v1/agents/{slug}/widget) to generate a snippet; the widget itself only reads
its attributes.
Without log-url, transcripts come from LiveKit text streams only, "Call us" shows just the
number, and there is no tools toggle.
Your two endpoints#
The org's API key stays on your server, which exposes these endpoints; the widget sends the
agent's slug with each request, so one set of endpoints serves every agent (reject slugs your site
doesn't serve). Use a production key with the talk scope (tokens, codes) and calls (logs), sent
as Authorization: Bearer <key>.
token-url#
The widget sends:
POST <token-url>
Content-Type: application/json
{ "agent": "<slug>", "scope": "talk" | "chat" }
talk for a voice call, chat for a written one. Your server posts to Pinecall:
POST https://<gateway>/v1/tokens
Authorization: Bearer <key>
Content-Type: application/json
{ "agent": "<slug>", "scope": "talk" | "chat", "ttl_s": 60, "metadata": { … } }
Return Pinecall's response unchanged: 201 with
{ "server_url", "participant_token", "call", "log_token" } (log_token reads that call's log
and opens no room).
ttl_s defaults to 60 (max 600). metadata is sealed into the call: the agent can read it and
the browser cannot alter it. Errors come as { "detail": "…" } (404 unknown agent, 429 quota
spent, 503 fleet full); pass them through and the widget shows detail. room_name,
participant_name and participant_metadata are rejected with 400.
Instead of token-url, you can set the element's tokenProvider property to
(scope, agent) => Promise<token JSON>:
document.querySelector("pinecall-widget").tokenProvider = (scope, agent) =>
fetch("/my/mint", { method: "POST", body: JSON.stringify({ scope, agent }) }).then((r) => r.json());
code-url — optional, and the way to put a phone call on the page#
Links a phone call to the page: the widget shows the agent's number and a 4-digit code; the caller enters or says it when the agent asks, and the panel then follows that call only.
The widget sends:
POST <code-url>
Content-Type: application/json
{ "agent": "<slug>" }
Your server posts to Pinecall with a key that has the talk scope:
POST https://<gateway>/v1/codes
Authorization: Bearer <key>
Content-Type: application/json
{ "agent": "<slug>" }
Return the response unchanged: 201 with { "code", "number", "expires_at", "code_token" }. The
agent needs a phone number (pinecall numbers import), otherwise 409. A code lasts 10 minutes
(ttl_s, 60–1800). The browser then talks to the gateway directly: it polls
GET /v1/codes/{code}?wait=1 with the code token and, once a call claims the code, reads that
call's log with the returned log token. Both are credential-less cross-origin GETs, so there is
nothing to relay.
Or no endpoint: set the element's codeProvider property to (agent) => Promise<code JSON>.
log-url — optional#
Two GET queries, both with agent added to whatever query string log-url already has:
<log-url>?agent=<slug>&list=1 — the agent's latest telephone calls. The widget polls it once a
second while "Call us" is open, and expects 200 with:
{ "calls": [ { "call": "call_…", "live": true, "from": "···169", "started_at": 1789000000.5 } ] }
Your server reads GET https://<gateway>/v1/agents/<slug>/sessions?limit=30 (newest first,
{ "calls": [ … ] }), keeps only the rows with channel == "phone", and answers those four fields
of each. started_at is Unix seconds; the widget shows the call that is live, or a call that
started after "Call us" was opened. from is printed on the page as it comes, and the page is
public: cut the caller's number to its last three digits.
<log-url>?agent=<slug>&call=<id> — one call's log, live, as server-sent events. Your server
relays
GET https://<gateway>/v1/calls/<id>/events?types=call.started,user.transcript,agent.transcript,turn.user,turn.agent,tool.call,tool.result,call.ended&after=0
Authorization: Bearer <key>
Accept: text/event-stream
and streams the answer back byte for byte as text/event-stream, flushing every chunk, with
Cache-Control: no-cache and buffering off (X-Accel-Buffering: no behind nginx). Without that
Accept header Pinecall answers a JSON page instead of the stream. Each frame is
id: <seq> · event: <type> · data: <JSON>, and the widget reads the entry's payload from the
JSON's data field. Check that <id> is a call id before putting it in the URL.
Your conversations#
A visitor who comes back finds their last chats and calls in the menu: the first thing they said,
when, how many lines. Opening one shows it as it was, with New chat or New call to go on
from there. It is this browser's alone — nothing is sent anywhere, nothing is read from the
gateway — so a shared computer is a reason to set history="off", and the visitor can always
press Clear.
The look#
The font is inherited from the page, and theme picks the light colours or the dark ones.
Everything else is a custom property on the tag, and one the page sets wins in either theme — set
it under pinecall-widget[theme="dark"] or a prefers-color-scheme query to give a theme its own:
pinecall-widget {
--pc-accent: #0f766e; /* the button, the links, the agent's bubbles — default #6d28d9 */
--pc-accent-soft: #f0fdfa; /* the soft background behind them — default #f5f3ff */
--pc-ink: #111; /* the text — default #1f2937 */
--pc-muted: #6b7280; /* the second line of text */
--pc-line: #e5e7eb; /* borders */
--pc-radius: 14px; /* the panel's corners */
--pc-offset: 20px; /* the distance from the page's edge */
--pc-font: Inter, sans-serif; /* default: the page's own */
--pc-font-size: 15px;
--pc-z: 2147483000;
}
pinecall-widget::part(button) { border-radius: 8px; } /* the button, outright */
pinecall-widget::part(panel) { box-shadow: none; } /* the panel, outright */
The button#
The button is yours to style, as far as you want to take it. Four ways, from a colour to a launcher of your own:
/* 1. Its own custom properties, in either theme */
pinecall-widget {
--pc-button-bg: #111; /* default: --pc-accent */
--pc-button-ink: #fff;
--pc-button-border: 1px solid #333;
--pc-button-radius: 10px; /* default 999px */
--pc-button-padding: 14px 22px;
--pc-button-shadow: none;
--pc-button-weight: 500;
--pc-button-font-size: 16px;
}
/* 2. Anything CSS can say, through its parts */
pinecall-widget::part(button) { text-transform: uppercase; letter-spacing: .08em; }
pinecall-widget::part(button-icon) { color: #facc15; }
pinecall-widget::part(button-label) { font-style: italic; }
-
icon="none"takes the icon away. -
Your own element, in place of the button: give the tag a child with
slot="button". It is your markup in your page, styled by your CSS like any other element, and a click on it opens the panel (autostartincluded). Withbutton="none"there is no button at all, and anything of yours opens the panel withtoggle().
<pinecall-widget agent="front-desk" token-url="/pinecall/token">
<a slot="button" class="my-cta" href="#">Talk to us now</a>
</pinecall-widget>
Events#
The tag dispatches pinecall:open when the panel opens, pinecall:started (detail: {call, scope}) when a voice call or chat has joined, and pinecall:ended (detail: {call}) when it
leaves, so a page can track conversions. New call / New chat ends the current
conversation (pinecall:ended) and starts another with a fresh token (pinecall:started). toggle() opens the panel, or closes it when open:
document.querySelector("pinecall-widget").toggle().
In React, Vue, Svelte#
A custom element is an element: render the tag and it works.
// React — import the module once, then the tag. Attributes are strings.
import "https://cloud.pinecall.io/widget/pinecall-widget.js";
export function Support() {
return <pinecall-widget agent="bidfire-sales" name="Sam" token-url="/pinecall/token" log-url="/pinecall/log" />;
}
<script setup>
import "https://cloud.pinecall.io/widget/pinecall-widget.js";
</script>
<template>
<pinecall-widget agent="bidfire-sales" name="Sam" token-url="/pinecall/token" log-url="/pinecall/log" />
</template>
In TypeScript, declare the tag once: declare global { namespace JSX { interface IntrinsicElements { "pinecall-widget": any } } }.
What is on the wire#
The browser connects to the token's server_url with LiveKit's client SDK (livekit-client@2,
loaded from jsDelivr); a Content Security Policy must allow both. A token opens one room for one
agent and expires after ttl_s if unused.
What is not there yet#
- Wrappers with typed props and slots for React and Vue, so the chat's bubbles, header and composer can be replaced with your own components rather than only restyled.
- A headless core (
open,send,leave, the transcript as events) for a page that draws its own UI entirely.