Calls
The monitoring API
The six REST endpoints behind live-call monitoring: list the calls in progress, mint a browser credential, take over, end the call, close a session, and read the audit.
A member of your staff opens a call that is happening right now, reads the conversation as it is spoken, and listens to the audio. If the caller needs a person, they press a button, the assistant says one short line and goes quiet, and your colleague speaks to the caller directly. Closing the panel returns the call to the assistant; hanging up ends it.
The integration has the same two security boundaries as browser calling:
- Your backend calls the endpoints on this page with an OnCore API key.
- Your browser receives only a short-lived join credential, then connects to the call with it.
Keep the API key server-side
Never call these endpoints from browser JavaScript. Anyone can read credentials bundled into frontend code or sent by the browser. Only the short-lived join credential belongs in the client.
Start with the introduction if you have not read it yet. This page is the endpoint reference.
Before you start#
You need:
- An inbound call in progress on one of your OnCore phone numbers. Monitoring covers phone calls only; a browser or WhatsApp call cannot be monitored.
- An API key held in your backend: either a tenant key, or an organization key plus
X-Tenant-Idnaming the customer tenant, exactly as in the reseller quickstart. A tenant key already carries its tenant and needs no header. - A place in your product where the signed-in user has already been authorized to listen. Read Authorization is yours to enforce before you build the button.
The REST API is available, the browser package is preview
Every REST endpoint on this page is available now. Listening to a call and speaking on it are not REST features and need a browser connected to the call as well: In the browser is that half, and it can be built either on the preview @oncore/live-call-sdk or directly on the public livekit-client against the documented wire protocol. Capabilities and availability is the canonical status.
The examples use Next.js App Router. The same split applies in any framework: session creation and every control call on your server, the connection in browser code.
The endpoints#
| Method | Path | Purpose |
|---|---|---|
| GET | /calls/live | List the calls in progress for this tenant |
| POST | /calls/{id}/monitor-session | Mint one browser join credential for one live call |
| POST | /calls/{id}/takeover | Stand the assistant down and let the monitor speak |
| POST | /calls/{id}/end | Hang up the call |
| DELETE | /calls/{id}/monitor-session | Close one monitor session. Does not end the call |
| GET | /calls/{id}/monitor-sessions | Read who monitored a call, during it or after it |
id and callId are different identifiers
Every live-calls row carries both. id is the call's identifier in the API and the only one a path ever takes, the same one GET /calls/{id} already takes, and the value every response on this page echoes back. callId is the identifier the assistant writes into its own records, which is what you quote on a support ticket. It is never accepted in a path, and passing it produces a 404.
The two GET routes use the standard response envelope, so their payloads sit under data. The three POST routes do not: their success bodies are bare JSON, because a browser's fetch hands the join material straight to the SDK, and their 404, 409, and 429 bodies are bare JSON too. Their 400 is the one exception and does use the envelope, because an over-length field is an ordinary validation failure with no reason code to carry. The DELETE answers 204 with no body at all. Errors and recovery has all of them.
Wire your buttons to these endpoints#
Six endpoints, and each one is a whole feature from your backend's point of view. This table is the map from a control in your interface to the call that backs it.
| Your button | Call | Body | On success |
|---|---|---|---|
| Refresh the list of calls in progress | GET /calls/live | none | data.calls[], each with an id, who is calling, how long it has been running, and whether it can be monitored |
| Listen to this call | POST /calls/{id}/monitor-session | { "actorLabel": "who asked" }, optional | { id, url, token, expiresAt, agentIdentity, monitorIdentity }, the join credential for one browser |
| Speak to this caller | POST /calls/{id}/takeover | { "monitorIdentity": "monitor-..." } | { id, monitorIdentity, state: "bridging", takeoverStartedAt } |
| Hang up | POST /calls/{id}/end | { "monitorIdentity": "monitor-...", "reason": "resolved" }, both optional | { id, state: "ending" } |
| Close the panel | DELETE /calls/{id}/monitor-session?monitorIdentity=monitor-... | none | 204, and the call carries on without that listener |
| Show who listened | GET /calls/{id}/monitor-sessions | none | data.sessions[], one row per session with its actorLabel and takeover timestamps |
Every one takes your API key in an Authorization: ApiKey header, from your backend.
Every {id} is the id field of a /calls/live row.
Two of these six need a browser in the call, and the API cannot supply it
Listening and speaking are not REST features. The audio and the live transcript travel
over the browser's own connection to the call, so POST /calls/{id}/monitor-session returns
join material rather than a stream, and there is no server-side transcript feed to poll.
POST /calls/{id}/takeover inherits that. It does not move the call to a person: it stands
the assistant down and promotes an already-connected browser participant to speak. Its
monitorIdentity has to name a session whose browser has joined the call, and a takeover
called without one is refused 409 session_closed rather than half-working. Calling it
from a backend with no browser attached would silence the assistant while nobody is there
to talk, so the API refuses instead.
So a backend-only integration gets four of the six on its own: list the calls in progress, hang one up, close a session, and read the audit. Listening and taking over need a browser in the call, which is In the browser.
The order the calls go in#
GET /calls/live # poll every few seconds, render the rows
POST /calls/{id}/monitor-session # when your user opens one: hand url and token
# to the browser, keep the rest server-side
POST /calls/{id}/takeover # when your user presses "speak", and only
# after the browser has connected
POST /calls/{id}/end # when your user hangs up
DELETE /calls/{id}/monitor-session # when the panel closes, alwaysDELETE is the one that is easy to forget and the one worth wiring first: nothing else
closes a session, and sessions that are never closed count against the per-call monitor
cap until their credential lapses.
Authorization is yours to enforce#
An OnCore API key carries a tenant and nothing else. It carries no user, no role, and no record of who is holding it. When your backend asks for a monitor session, OnCore can tell the request came from your tenant. It cannot tell which of your staff is behind it, and so it cannot refuse one of them.
So the decision about which of your people may listen to a live customer call is enforced in your backend, before it calls OnCore. There is no OnCore-side permission to switch on and no role to assign. If the code path from your browser to POST /calls/{id}/monitor-session has no authorization check in it, every signed-in user of that tenant can listen to every live call in that tenant.
actorLabel does not change this. It is free text, at most 200 characters, recorded verbatim on the audit record so a later reviewer can see who your backend said was listening. OnCore never verifies it and never makes a decision with it. Send the identity your own authentication already established, and treat the field as a log line rather than as a credential.
This delegation is identical to the one browser calling already relies on, and to the tenant boundary in the reseller quickstart: you authenticate the person, OnCore authenticates the tenant.
Disclosure is yours to make#
A live person listening to a call in progress is call monitoring, and several jurisdictions require the people on the call to be told. Whether disclosure is required for a given call, and what it has to say, is your obligation as the operator of the line. OnCore does not review your compliance position, and nothing on this page is legal advice.
The cheapest mechanism the platform gives you is the assistant's own greeting. firstMessage is spoken before anyone has said anything, so one sentence there reaches every caller on that line whether or not anybody ends up listening:
curl -X PATCH https://core-api.heysadie.ai/assistants/0195f1e2-2222-7000-8000-000000000000 \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "firstMessage": "Thanks for calling Riverside Dental. Calls may be monitored by our team. How can I help?" }'Recording consent is a separate question with its own answer: see Recording and transcripts. Recording and monitoring are independent settings, and a call can be monitored whether or not it is recorded.
List the calls in progress#
GET /calls/live takes no query parameters and no pagination.
curl https://core-api.heysadie.ai/calls/live \
-H "Authorization: ApiKey YOUR_API_KEY"{
"data": {
"calls": [
{
"id": "0195f1e2-1111-7000-8000-000000000000",
"callId": "0195f1e2-3333-7000-8000-000000000000",
"assistantId": "0195f1e2-2222-7000-8000-000000000000",
"assistantName": "Front desk",
"customerNumber": "+15555550123",
"callSource": "pstn",
"status": "initiated",
"startedAt": "2026-09-02T14:03:11.000Z",
"durationSeconds": 74,
"monitorSupported": true,
"monitorUnsupportedReason": null,
"monitorCount": 1
}
],
"total": 1
},
"metadata": { "timestamp": "2026-09-02T14:04:25.000Z", "version": "1.0", "path": "/calls/live" }
}| Field | Type | Description |
|---|---|---|
id | UUID | The identifier every path on this page takes |
callId | string | The identifier the assistant records. Never used in a path |
assistantId / assistantName | UUID / string | Which assistant is handling the call |
customerNumber | string | The caller's number, E.164 |
callSource | string | pstn, webrtc, or whatsapp |
status | string | The call's current status, from the same set as /calls/statuses. Not a ringing-versus-connected signal: a live row commonly reads initiated for the whole call |
startedAt | ISO 8601 | When the call began |
durationSeconds | number | How long the call has been running, computed on the server so a skewed browser clock still renders a sane number |
monitorSupported | boolean | Whether a monitor session can be minted for this call |
monitorUnsupportedReason | string | null | null when the call can be monitored. Otherwise agent_build_unsupported or not_phone_call |
monitorCount | number | Monitor sessions currently counting against the per-call cap |
Render a row with monitorSupported: false and disable its button rather than hiding the call, and put monitorUnsupportedReason in the tooltip. A greyed-out button with no reason is the kind of dead end that becomes a support ticket. not_phone_call means the call did not arrive over a phone line, and agent_build_unsupported means the software handling this particular call cannot accept a monitor. Branch on the two values you know and show the raw string for anything else.
monitorCount is the number that decides whether a supervisor may join, so it is the same number you should show them. A session counts while its join window is open, and for as long as it is holding the call however old its token is. A supervisor who shut their tab without ending the session stops counting once their window lapses, and minting a session also releases any earlier session whose owner never arrived. So a long call cannot be locked out by abandoned panels, and a supervisor reloading their own panel does not eat into the cap.
The list is bounded by a maximum call age, which is the platform's own ceiling on how long a call may run rather than a guess at how long calls usually last. A call past it stops being listed whatever its status says, because a call that has stopped reporting looks exactly like a live one if you trust status alone. A row dropping off the list is not a live call you can no longer see; it is a call that is no longer known to be live. The figure is not published.
Two things a row deliberately does not carry, said here so you do not go hunting. There is no dialled number: a call record does not store which of a multi-line tenant's numbers rang, and it is not derivable from the assistant, which may answer several. And there is no direction, because v1 monitors inbound phone calls only. Both are planned rather than hidden.
The row is otherwise deliberately lean. If your list needs more than these fields, read the full record with GET /calls/{id} for the same id.
Poll this endpoint on a timer for the list view; there is no push feed of live calls. A few seconds between polls is enough, and every polling loop should also respect rate limits. Wrap the call in a route of your own, behind the same authorization check as the session route below, so the key never reaches the browser.
Mint a monitor session#
POST /calls/{id}/monitor-session returns one join credential for one call. This is the endpoint your authorization check guards.
| Body field | Type | Required | Description |
|---|---|---|---|
actorLabel | string | No | Free text, maximum 200 characters, recorded verbatim on the audit record. Opaque to OnCore and never treated as an identity |
curl -X POST https://core-api.heysadie.ai/calls/0195f1e2-1111-7000-8000-000000000000/monitor-session \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "actorLabel": "[email protected]" }'{
"id": "0195f1e2-1111-7000-8000-000000000000",
"url": "wss://example.livekit.cloud",
"token": "eyJhbGciOi...",
"expiresAt": "2026-09-02T14:14:31.000Z",
"agentIdentity": "agent-AJ4nQ8vTzWpb",
"monitorIdentity": "monitor-0195f1e2-4444-7000-8000-000000000000"
}| Field | Description |
|---|---|
id | The call this credential is for, echoing the path |
url | The WebSocket URL the browser connects to |
token | The join credential. Subscribe only: it cannot publish audio |
expiresAt | When the token stops being accepted for a new connection |
agentIdentity | The assistant's participant identity in the room. The SDK keys the assistant's audio, state, and messages off it |
monitorIdentity | This session's own identity. Send it back on takeover and end |
The token expiry is a join deadline and nothing else#
The token is valid for ten minutes from the moment it is minted, and it is checked exactly once, when the browser connects. Three consequences follow, and the third is the one that gets built wrong.
A browser that connects inside the window keeps listening for as long as the call runs, however long that is. The token lapsing does not disconnect anybody.
A browser that tries to connect after the window has closed is refused, and needs a new monitor session. A page refresh at minute twelve is exactly this case, which is why the session route should stay callable rather than being hit once when the page loads.
Takeover and end do not check it. A supervisor who listens for twenty minutes and then presses Take over succeeds. A session stays usable for control for as long as it is open and the call is live, and nothing else is consulted.
Take over and end#
Each call identifies the session with the monitorIdentity your monitor-session response returned, required on takeover, optional on end.
Both are idempotent with respect to a retry, not with respect to a second intervention. A double-submit while the monitor already holds the call returns the original timestamp and sends nothing further to the assistant, which is what idempotency is for. A supervisor whose hold has since been released (by closing the panel, or by leaving the room) and who then needs to step in again on the same session gets a genuine second takeover. Two supervisors racing for the same call is the other case: the first wins, and the second gets 409 held_by_other_monitor.
One honest consequence of allowing the second intervention. A re-takeover overwrites the session's takeover timestamps, so the audit record carries the most recent intervention per session rather than every one. Per-intervention history is planned. The session-level record still answers who was listening and that they intervened.
A hold is only honoured while its holder is still in the room. If a supervisor refreshes their page mid-takeover, their old session's hold is released rather than blocking them, so the new panel can take the call over again. The caller hears no second bridge line for that, because the assistant is already standing down.
Takeover promotes the microphone and nothing else. A promoted monitor still cannot publish a camera or a screen share, which matters because the call recording is a single mixed composite.
POST /calls/{id}/takeover
| Body field | Type | Required | Description |
|---|---|---|---|
monitorIdentity | string | Yes | From the monitor-session response |
POST /calls/{id}/end
| Body field | Type | Required | Description |
|---|---|---|---|
monitorIdentity | string | No | Closes that session and attributes the end to it |
reason | string | No | Recorded with the end |
Ending does not require having taken over, and that is deliberate. Anyone who can obtain a monitor session for a call can already make the assistant stand down, so there is no extra privilege in hanging up. Requiring a takeover first would only mean that a supervisor who hears something that has to stop must take the call over in order to end it, which is two steps and a bridge line for no gain.
CALL=0195f1e2-1111-7000-8000-000000000000
MONITOR=monitor-0195f1e2-4444-7000-8000-000000000000
API=https://core-api.heysadie.ai
curl -X POST "$API/calls/$CALL/takeover" \
-H "Authorization: ApiKey YOUR_API_KEY" -H "Content-Type: application/json" \
-d "{ \"monitorIdentity\": \"$MONITOR\" }"
curl -X POST "$API/calls/$CALL/end" \
-H "Authorization: ApiKey YOUR_API_KEY" -H "Content-Type: application/json" \
-d "{ \"monitorIdentity\": \"$MONITOR\", \"reason\": \"resolved\" }"{
"id": "0195f1e2-1111-7000-8000-000000000000",
"monitorIdentity": "monitor-0195f1e2-4444-7000-8000-000000000000",
"state": "bridging",
"takeoverStartedAt": "2026-09-02T14:05:12.000Z"
}End answers with { "id": "...", "state": "ending" }.
state is the conversation state at the moment the endpoint answered, not the final one. Takeover returns bridging because the assistant is still saying its bridge line; the state moves to human a moment later, and the SDK's control event is what tells you it arrived. Drive your interface off the event and treat the response as an acknowledgement.
What the caller hears#
On takeover the assistant finishes its sentence, says one short bridge line, and goes quiet, so nobody is dropped into silence mid-word. When the assistant resumes it comes back with a line of its own before carrying on.
Ending a call that a person took over deliberately skips the assistant's goodbye. The caller has just been speaking to a human, and the assistant reappearing to say "have a great day" is worse than a clean hangup.
What the transcript does during a takeover#
The transcript does not stop, but it becomes one-sided, and this is the shape your panel has to be designed around.
The caller keeps being transcribed for the whole takeover. Their segments keep arriving exactly as before. The assistant standing down does not touch them.
The assistant's voice and the assistant's own transcript stop together. That is what standing down means, and the pair moves as one.
Your colleague is never transcribed. Not live, and not into the stored record afterwards.
So during a takeover a panel shows the caller live and the supervisor's side as a gap. Label the gap, or fill it from what your own interface knows your colleague did, but do not wait for segments that are not coming. What the call record shows afterwards covers the same asymmetry in the stored transcript.
The assistant also does not remember the turns it heard while standing down, so it comes back without a garbled memory of a conversation it was not part of. Your note is how it learns what happened.
Close the session when the panel goes away#
DELETE /calls/{id}/monitor-session?monitorIdentity=monitor-... closes one monitor session. It answers 204 with no body, and it is idempotent.
This closes the session. It does not end the call
DELETE .../monitor-session and POST .../end sit next to each other and do opposite things. The DELETE detaches one supervisor and leaves the caller talking to the assistant. POST .../end hangs up on a customer. That is exactly why closing a session is a DELETE on the resource the mint created, rather than a third verb alongside takeover and end: the shape of the request says which one you meant.
Call it whenever your panel goes away, whether the supervisor closes it, navigates elsewhere, or shuts the tab.
The reason to bother is a consequence rather than housekeeping. The per-call monitor cap counts open sessions, so a panel that opens and closes without cleaning up holds a slot until its token lapses. On a call several people are watching, that is the difference between a colleague being able to join and being told the call is full.
Mint-time release is the backstop: a session whose owner never arrived is released the next time anyone mints one on that call. So a close that never lands costs a few minutes of one slot rather than costing correctness. Do not build retry machinery around this request. Send it once, ignore the outcome, and let the backstop cover what gets lost.
Two more things worth knowing:
- An identity that names no open session still returns
204. Already closed, never existed, and belonging to another tenant are all the same answer, deliberately, so the endpoint cannot be used to find out which sessions exist. - Closing while holding the call releases the hold, and this is how you give a call back. A few seconds after the supervisor leaves the room the assistant resumes on its own, and the audit's
takeoverEndedAtrecords when human control actually ended.
From the browser, send it with keepalive so the request survives the page actually unloading rather than only a component unmounting. navigator.sendBeacon, the usual tool for a request on the way out, cannot send a DELETE, so fetch with keepalive: true is the one that works. The panel above does this in closeSession, from both its unmount and a pagehide listener.
// Alongside the POST handler that mints the session.
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const user = await getSession();
if (!user || !user.permissions.includes("calls:monitor")) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const monitorIdentity = new URL(request.url).searchParams.get("monitorIdentity");
if (!monitorIdentity) return new Response(null, { status: 204 });
const query = new URLSearchParams({ monitorIdentity });
const upstream = await oncore(
user.tenantId,
`/calls/${id}/monitor-session?${query}`,
{ method: "DELETE" },
);
return new Response(null, { status: upstream.status });
}Read who monitored a call#
GET /calls/{id}/monitor-sessions is the audit read path. It is read-only, tenant-scoped, and works during the call and after it has ended, because after is usually when somebody asks.
curl https://core-api.heysadie.ai/calls/0195f1e2-1111-7000-8000-000000000000/monitor-sessions \
-H "Authorization: ApiKey YOUR_API_KEY"{
"data": {
"sessions": [
{
"id": "0195f1e2-5555-7000-8000-000000000000",
"monitorIdentity": "monitor-0195f1e2-4444-7000-8000-000000000000",
"actorLabel": "[email protected]",
"createdAt": "2026-09-02T14:04:31.000Z",
"expiresAt": "2026-09-02T14:14:31.000Z",
"takeoverStartedAt": "2026-09-02T14:05:12.000Z",
"takeoverEndedAt": "2026-09-02T14:06:40.000Z",
"closedAt": null
}
],
"total": 1
},
"metadata": {
"timestamp": "2026-09-02T14:31:08.000Z",
"version": "1.0",
"path": "/calls/0195f1e2-1111-7000-8000-000000000000/monitor-sessions"
}
}| Field | Description |
|---|---|
id | The monitor session's own identifier, not the call's |
monitorIdentity | The identity that joined the room |
actorLabel | Exactly the string your backend sent, or null |
createdAt | When the session was minted |
expiresAt | When its join window closed |
takeoverStartedAt / takeoverEndedAt | The most recent intervention on this session, or null if it only listened. takeoverEndedAt is when human control ended |
closedAt | When the session was closed, or null |
actorLabel comes back exactly as it was supplied and is not evidence of anything. It is a label the caller attached, recorded verbatim and never believed, because an API key carries a tenant and no person. Say so wherever you display it: a reviewer reading "[email protected]" should know it means "your backend asserted this", not "OnCore verified this".
Errors and recovery#
| Where | Symptom | Action |
|---|---|---|
OnCore 400 | actorLabel over 200 characters, or reason over 200 | Clamp them in your own code before sending |
OnCore 401 | Key missing or invalid | Check the server-side key and the ApiKey scheme |
OnCore 403 | Key cannot reach that tenant | Check organization-key tenant selection |
OnCore 404 | Call unknown, another tenant's, or deleted | Confirm you sent the id from /calls/live, not the callId beside it |
OnCore 409 | A reason code, listed below | Branch on reason. Retry agent_absent, never retry not_live |
OnCore 429 | Per-tenant session limit | Wait retryAfterSeconds, or read the Retry-After header |
SDK disconnected | The room is gone or the browser left it | Treat it as final. Re-check /calls/live, and mint a new session if the call is still there. The reason is an optional free-text string, so show it rather than branching on it |
setMicrophoneEnabled rejects | mic_requires_takeover, or mic_denied | Never retry either one; the SDK already has. Treat mic_requires_takeover as a bug in your own ordering, and mic_denied as a browser permission to explain. See Opening the microphone |
404 is byte-identical for a call that does not exist, a call in another tenant, and a deleted call, so the endpoint cannot be used to discover which calls exist:
{ "error": "not_found" }409 carries a reason code. The values below are a contract, so branching on them is safe. Treat any other reason as an unknown refusal rather than failing.
{ "error": "conflict", "reason": "not_live" }reason | Meaning |
|---|---|
not_live | The call has ended, or has run past the platform's maximum call duration |
agent_absent | The call is live, but no assistant is in the room right now |
not_monitorable | The call cannot be monitored. monitorUnsupportedReason on the live-calls row says which of the two cases applies |
monitor_cap_reached | The per-call monitor cap is already met |
session_closed | The named monitor session has been closed. Mint a new one |
held_by_other_monitor | Another session is holding the call and its holder is still in the room |
Two of those codes look alike and mean opposite things. not_live means stop. agent_absent means try again shortly.
An assistant that reconnects part-way through a call is out of the room for a moment while the call itself is perfectly alive, and agent_absent is that moment. Retry it once or twice with a short delay before giving up. Never retry not_live, which is final. A client that treats the two alike will tell a supervisor "this call has ended" while they are listening to it.
There is no reason code for an expired token, because the control endpoints do not consider one. session_closed means the session was actually closed, by an end call or by the call finishing, and the answer to it is a new session rather than a retry.
400 is the exception among these bodies: it is the ordinary error envelope with error.code and per-field details, because an over-length field is a plain validation failure. Clamp in your own code rather than relying on it.
429 carries its cooldown at the top level, not inside an envelope:
{ "error": "rate_limited", "retryAfterSeconds": 42 }The same number is in a standard Retry-After header. Read the header or the top-level field; error.details is not present on that response.
What the call record shows afterwards#
Two facts about a monitored call matter more than anything else on this page if you review calls for compliance, and neither has a workaround today.
The person who takes over is not transcribed. Nothing your colleague says reaches the transcript, live or retroactively. What the stored record carries instead is a labelled marker showing that a person took the call over, when, and when they handed it back, so a later reader sees a marked gap rather than a non-sequitur. Do not parse that marker: this release does not publish its exact wording. The caller is unaffected, and is transcribed for the whole call including the part a person handled.
The recording is one mixed track. When recording is on, a call is captured as a single mixed audio track of the whole conversation. Your colleague's voice lands on the same track as the caller's and the assistant's, and there is no way to separate them afterwards. If your review process needs the supervisor's half on its own, this is not the tool for it.
Everything else about a monitored call is ordinary. It appears in call activity like any other call, and it fires the same end-of-call report.
Limits, and what you cannot change#
- The bridge line and the resume line are fixed. What the caller hears when a supervisor steps in, and when the assistant comes back, is not a per-assistant setting in v1. This is a deliberate deferral rather than an oversight: a per-assistant setting has to reach three separate hand-maintained lists before it takes effect on a call, and a feature has already shipped broken by missing one of them.
- The monitor cap, the rate limit, the maximum call age, and the wait before the assistant resumes are platform constants. They are not per-tenant configuration and no figures are published, so read the
409and the429rather than predicting them. - Inbound phone calls only. Browser and WhatsApp calls report
monitorSupported: false, and a row carries no direction because there is nothing else it could be. - One person speaks at a time. Takeover is exclusive, and a second monitor asking for it gets
held_by_other_monitorwhile the holder is present. Listeners stay listeners. - Refreshing recovers, and costs nothing. A supervisor who reloads their panel mints a fresh session and carries on, whether they were listening or mid-takeover. The reload does not consume a monitor cap slot, and mid-takeover it does not make the caller hear a second bridge line.
- A dropped browser does not leave the caller in silence. If the supervisor's page closes for good while they are holding the call, the assistant takes the conversation back after a few seconds. A reload normally lands well inside that window.
- There is no server-side transcript feed. The transcript rides the browser's own connection. If you need mid-call transcripts on your server, this release does not provide them.
- There is no prebuilt panel. OnCore supplies the endpoints and the browser package; the interface is yours, as it is for browser calling.
Next steps#
- In the browser: audio, the live transcript, and the wire protocol.
- Read call activity: find a monitored call afterwards with its transcript and marker.
- Recording and transcripts: what the written and audio record contains, and the recording-consent position.
- Browser calling: the same backend-mints-a-token pattern for placing a call in a page.