Skip to main content

Start, Stop, and Hand Off an AI Call from Your Server

Use this guide when your application already has an active MediaSFU SIP call that was created with an AI agent configuration. These controls are MediaSFU HTTP operations; they are not methods on every room SDK.

Your application server can:

  • read the current call state;
  • start or stop the configured agent;
  • switch the active media source between the agent and a human;
  • end the call.

Do not send MediaSFU authorization to a browser, mobile app, or desktop client. The client calls your application. Your application authenticates the person, checks that they may control this call, and then calls MediaSFU.

Prerequisites

You need:

  • the sipCallId of an existing call owned by your MediaSFU account;
  • a server-side MediaSFU authorization grant with sipCallState for reads, sipCallControl for start, stop, and source switching, and sipCallEnd for ending a call;
  • an application rule that decides which signed-in people may control each call;
  • HTTPS between your application server and MediaSFU.

Starting an agent does not create a call, choose its agent configuration, or authorize an operator. Complete those product steps before exposing the control.

The HTTP operations

OutcomeMethod and pathRequest body
Read current stateGET /v1/sipcall/:sipCallId/statenone
Start the configured agentPOST /v1/sipcall/:sipCallId/start-agent{}
Stop the configured agentPOST /v1/sipcall/:sipCallId/stop-agent{}
Switch to the agentPOST /v1/sipcall/:sipCallId/switch-source{ "targetType": "agent" }
Switch to a personPOST /v1/sipcall/:sipCallId/switch-source{ "targetType": "human", "humanName": "Pat" }
End the callPOST /v1/sipcall/:sipCallId/end{ "reason": "Operator ended the call" }

MediaSFU may return 429 when controls arrive too quickly. Use retryAfterMs or retryAfterSeconds from the response instead of repeatedly sending the action.

Complete server-side client

This module rejects non-HTTPS upstream origins, encodes call IDs, keeps authorization behind a function, preserves rate-limit recovery fields, and provides one method for every operation above.

const allowedSourceTypes = new Set(['agent', 'human']);

function requireHttpsBaseUrl(value) {
const url = new URL(value);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error('MediaSFU server URL must be a safe HTTPS origin.');
}
return url.origin;
}

function callPath(callId, action) {
const value = String(callId ?? '').trim();
if (!value) throw new Error('callId is required.');
return `/v1/sipcall/${encodeURIComponent(value)}/${action}`;
}

export function createMediaSfuCallControl({
mediaSfuBaseUrl,
getServerAuthorization,
fetchImpl = fetch,
}) {
if (typeof window !== 'undefined') {
throw new Error('MediaSFU call control must run on the application server.');
}
const baseUrl = requireHttpsBaseUrl(mediaSfuBaseUrl);

async function request(callId, action, { method = 'POST', body } = {}) {
const authorization = await getServerAuthorization();
if (!authorization) throw new Error('Server authorization is unavailable.');

const response = await fetchImpl(`${baseUrl}${callPath(callId, action)}`, {
method,
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(
data.error ?? `MediaSFU call control failed with status ${response.status}.`,
);
error.status = response.status;
error.retryAfterMs = data.retryAfterMs;
error.retryAfterSeconds = data.retryAfterSeconds;
throw error;
}
return data;
}

return {
getState: (callId) => request(callId, 'state', { method: 'GET' }),
startAgent: (callId) => request(callId, 'start-agent'),
stopAgent: (callId) => request(callId, 'stop-agent'),
switchSource(callId, targetType, humanName) {
if (!allowedSourceTypes.has(targetType)) {
throw new Error("targetType must be 'agent' or 'human'.");
}
const body = { targetType };
if (targetType === 'human' && humanName?.trim()) {
body.humanName = humanName.trim();
}
return request(callId, 'switch-source', { body });
},
endCall: (callId, reason = 'Operator ended the call') =>
request(callId, 'end', { body: { reason } }),
};
}

Create it only in server code:

import { createMediaSfuCallControl } from './server-call-control.mjs';

const mediaSfuCallControl = createMediaSfuCallControl({
mediaSfuBaseUrl: process.env.MEDIASFU_SERVER_BASE_URL,
getServerAuthorization() {
const value = process.env.MEDIASFU_SERVER_AUTHORIZATION;
if (!value) throw new Error('MediaSFU server authorization is not configured.');
return value;
},
});

const state = await mediaSfuCallControl.getState(process.env.MEDIASFU_CALL_ID);
console.log({
callStateReceived: Boolean(state),
});

Store the variables in your server secret manager. Do not put them in a frontend .env file, build-time client variable, public log, screenshot, or error response.

Build the operator flow

Use the operations in this order:

  1. Authenticate the operator in your application.
  2. Verify that the call belongs to the operator's account or assigned queue.
  3. Read state and disable controls while the call is unavailable or ended.
  4. Start the agent and wait for the returned success response.
  5. Read state again before showing the agent as active.
  6. Switch to a human only after that person is ready to take the call.
  7. Stop the agent when the product should no longer process its media.
  8. End the call once; disable further controls after the ended state appears.

Treat the HTTP response as command acceptance. Your UI should use a following state read or its approved live call-state feed to show the confirmed outcome.

Data buffers and audio ownership

MediaSFU's call and agent runtime can use data-buffer plumbing behind these controls. That plumbing is not a general public SDK method for application code. Do not emit raw startDataBuffer or stopDataBuffer socket messages from a browser or invent their payloads.

For supported call control, use the HTTP operations above or the supplied MediaSFU call/operator experience. Let the MediaSFU runtime own the matching agent media and buffer lifecycle. When your product uses a separate approved agent integration, follow that integration's own start, pause, resume, and teardown contract.

Failure and recovery

ResultUser-facing action
400Show that the requested transition was rejected; refresh call state
401Keep the control disabled and repair server authorization
403Tell the signed-in person they cannot control this call
404Mark the call unavailable or already ended
429Disable the action until the supplied retry delay passes
504Show a retry action, then read state before sending another command

Do not automatically repeat start, stop, switch, or end commands without first checking state. A timed-out command may still have reached the call runtime.

Combine HTTP control with an SDK room

Keep the responsibilities separate:

  • the SDK joins, produces, consumes, and renders the WebRTC room;
  • your application server performs privileged room, protocol, or call-control HTTP operations;
  • your application passes only the safe result or confirmed state to its UI.

The SDK does not need to hold the call-control authorization. The server can start or stop the agent while an SDK room remains mounted and continues to show the current participants and media.

Before release

  • Authorization exists only in server-side secret storage.
  • Every control checks the signed-in person's call ownership or assignment.
  • Start, stop, switch, and end are disabled while a command is pending.
  • The UI confirms transitions from current call state.
  • 429 and timeout recovery do not cause duplicate commands.
  • Human handoff is tested with the human ready before source switching.
  • Ending a call disables all later controls and removes local timers.
  • Logs contain call-safe identifiers and outcomes, never authorization.

The complete module and deterministic tests are in examples/ai-call-control. Exercise the flow with an approved non-production call before releasing it to operators.