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
sipCallIdof an existing call owned by your MediaSFU account; - a server-side MediaSFU authorization grant with
sipCallStatefor reads,sipCallControlfor start, stop, and source switching, andsipCallEndfor 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
| Outcome | Method and path | Request body |
|---|---|---|
| Read current state | GET /v1/sipcall/:sipCallId/state | none |
| Start the configured agent | POST /v1/sipcall/:sipCallId/start-agent | {} |
| Stop the configured agent | POST /v1/sipcall/:sipCallId/stop-agent | {} |
| Switch to the agent | POST /v1/sipcall/:sipCallId/switch-source | { "targetType": "agent" } |
| Switch to a person | POST /v1/sipcall/:sipCallId/switch-source | { "targetType": "human", "humanName": "Pat" } |
| End the call | POST /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:
- Authenticate the operator in your application.
- Verify that the call belongs to the operator's account or assigned queue.
- Read state and disable controls while the call is unavailable or ended.
- Start the agent and wait for the returned success response.
- Read state again before showing the agent as active.
- Switch to a human only after that person is ready to take the call.
- Stop the agent when the product should no longer process its media.
- 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
| Result | User-facing action |
|---|---|
400 | Show that the requested transition was rejected; refresh call state |
401 | Keep the control disabled and repair server authorization |
403 | Tell the signed-in person they cannot control this call |
404 | Mark the call unavailable or already ended |
429 | Disable the action until the supplied retry delay passes |
504 | Show 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.
-
429and 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.