Skip to content

calloop_agent ​

The agent side of Calloop's agent protocol for Flutter: connect, go available, ring, answer, hang up, transfer, keep calls alive across network drops, app restarts and device switches, and — for supervisors — the tenant's live calls and monitoring (listen / whisper / barge). Same model and behaviour as the JS SDK (sdk/agent-js).

The app keeps its own screens and ringtones; the SDK gives it state (ChangeNotifier, works with Provider), events (streams) and the audio.

dart
import 'package:calloop_agent/calloop_agent.dart';

final agent = await CalloopAgent.connect(
  url: 'https://api.calloop.net',
  deviceId: storedDeviceId,                       // stable per installation
  getToken: () => api.fetchCalloopToken(),         // your backend signs it; called on every (re)connect
);

agent.setAvailability(Availability.available);

agent.onIncoming.listen((call) {
  showRinging(call.customer);                     // your UI
  // later: await call.accept();  or  call.decline();
});
agent.onCallEnded.listen((e) => closeCallScreen(e.call, e.end.reason));

The agent ​

CalloopAgent.connect(url:, deviceId:, token: | getToken:, purpose:?, media:?, transport:?)completes on session:ready. purpose: 'session' (default) is the agent's main connection; anything else is auxiliary — no presence, no redelivered ringing calls, no resync on connect. media / transport replace the WebRTC and socket layers (e.g. fakes in tests)
agent.readya Future that completes with the agent once it is first identified
agent.statusconnecting · ready · reconnecting · closed
agent.closedReasonset when Calloop ended the connection itself (status closed without close(): the token's signing key was revoked). It won't reconnect: close() the agent and connect again with a token signed by a current key
agent.me, agent.isSupervisor, agent.availability
agent.session, agent.iceServersthe whole session:ready; the ICE servers Calloop gave this session
agent.setAvailability(Availability.available | offline, agentId:?)onCall is Calloop's
agent.calls, agent.ringingCalls, agent.activeCall, agent.call(id)this agent's calls
agent.queues, agent.teamqueue snapshots, agents' availability — only for supervisors and agents whose tenant allows the team view; otherwise no queues, and the team holds only this agent
agent.sync()re-read the calls and queue snapshots now (done on every connect)
agent.startOutbound(callId)a call your backend created (POST /v1/tenants/{t}/calls)
agent.refreshSession()new ICE/TURN credentials (automatic before they expire)
agent.close()

Streams: onIncoming, onElsewhere (your call is on another device), onCallState, onCallEnded, onError, onBoardCallEnded. onError is a Stream<Object>: a CalloopError (code, message, callId) for Calloop's call:error, or the socket's raw connect_error value (e.g. an authentication refusal) — check the type.

A call ​

call.state: ringing → connecting → active → ended; dialing for outbound before the customer answers; elsewhere when its media is on another of your devices.

call.accept() / call.decline() / call.hangup()
call.mute(bool), call.muted
call.transfer(agentId:) / call.transfer(queueId:)
call.setPrivate(bool)talk only to the monitoring supervisor
call.switchHere()take a call that's active on another device
call.customer, call.direction, call.callUuid, call.data
call.isRingAlloffered to every member of a RING_ALL queue at once (first accept wins)
call.endReasonthe CallEnd once the call is over, else null
call.remoteStream, call.localStreamnative platforms play the remote audio by themselves
call.signalsDtmfSignal, CustomerMediaSignal, NetworkTerminatingSignal, SupervisorModeSignal, PrivateChangedSignal
call.endedFuture<CallEnd> — reason: hangup, declined, withdrawn, answered_elsewhere, terminated (see terminationReason: the call ended — or, with transferred, this agent's part of it did, while the call goes on with the next agent), gone, accept_failed, media_failed, error. A failed call's failure: { code, title, details? }, what the provider said (a SIP trunk: 404, Not Found, its Reason header)

Supervisors ​

agent.board — the tenant's live calls (CallData: status, customer, agent, queue, ivr position), kept current and rebuilt on every sync.

A dashboard on a large tenant narrows and pages instead (the agent protocol's Board section): agent.subscribeBoard(channelIds: […], queueIds: […], agentIds: […]), agent.unsubscribeBoard(), agent.boardCalls(channelIds:, queueIds:, agentIds:, cursor:, limit:) → (calls, nextCursor), agent.boardCounters() and the pushed agent.onCounters stream.

agent.board and onBoardCallEnded are supervisors' only. An agent whose tenant has the team view can page the board with boardCalls(), but the SDK gives it no live board updates.

A filter lasts for one connection: after a reconnect Calloop subscribes the new connection to the whole tenant again, so call subscribeBoard() again each time agent.status returns to ready.

Supervisors also move any call with agent.transferCall(callId, agentId: …) or agent.transferCall(callId, queueId: …).

dart
final monitor = await agent.monitor(callId);   // asks for the microphone
monitor.setMode(MonitorMode.whisper);           // listen | whisper | barge
monitor.mute(true);                             // your microphone
monitor.stop();

agent.monitorOf(callId) returns the call's monitor, if any. monitor.stream (the call as the supervisor hears it), monitor.mode, monitor.state, monitor.muted, monitor.error, monitor.endReason (once it ended), monitor.ended — completes with the reason: stopped, call_ended, ended (Calloop ended it), disconnected, failed (Calloop couldn't start it; monitor.error says why), not_allowed (the tenant doesn't allow monitoring), closed (agent.close()). A setMode() Calloop refuses (whisper on a direct call, a mode the tenant doesn't allow) sets monitor.error and monitoring goes on in the current mode. A reconnect ends monitoring. Unlike the JS SDK, the monitor doesn't yet tell you when the agent talks privately to you or when the agent's leg was rebuilt.

Direct calls (call.data.isDirect: a personal line's 1:1 call that Calloop relays without a room) reconnect like any other call (the SDK sends its own offer); monitoring one is listen-only, without the microphone.

The native call screen (package:calloop_agent/native_calls.dart) ​

The phone's own call UI: full-screen incoming call and ConnectionService on Android, CallKit on iOS (via flutter_callkit_incoming). Import it only if you want it; calloop_agent.dart doesn't use it.

dart
import 'package:calloop_agent/native_calls.dart';

// In the app, once the agent is connected:
final native = CalloopNativeCalls(
  agent,
  style: const NativeCallStyle(appName: 'Acme Support'),
  ringPolicy: NativeRingPolicy.whenInBackground,   // or .always
  appInForeground: () => WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed,
);
await native.attach();
  • A ringing call shows the native screen (keyed by Calloop's callUuid); when the call ends anywhere (answered elsewhere, withdrawn, hung up) the screen goes.
  • Accept / Decline / End on the native screen act on the call: accept answers it, decline rejects it, end hangs up. Once answered the native call is marked connected.
  • A tap that comes before the SDK knows the call (the app woke up from a push and is still connecting, or was launched by the Accept tap) is kept for pendingFor (45 s) and applied when the call arrives.

In the push handler (Android: FCM data messages, including the background isolate):

dart
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
  await CalloopBackground.handlePush(message.data);   // null → not a Calloop call push
}

handlePush shows the screen for call.incoming and dismisses it for call.cancelled. When the user declines while the app isn't running, CalloopBackground.declineCall(url:, deviceId:, getToken:, callId:) connects, sends the reject and disconnects, so the call moves on to the next agent right away instead of waiting for the ring timeout. Its connection is auxiliary (purpose: 'decline'): it doesn't count as the agent coming online, and Calloop doesn't ring it with the call it is declining.

Android setup: USE_FULL_SCREEN_INTENT, POST_NOTIFICATIONS (asked at runtime on Android 13+), RECORD_AUDIO, FOREGROUND_SERVICE / FOREGROUND_SERVICE_PHONE_CALL / FOREGROUND_SERVICE_MICROPHONE in the manifest; see the flutter_callkit_incoming docs. iOS (PushKit VoIP pushes and CallKit) comes next.

What it handles ​

  • Resync on every connect: ringing calls ring, calls on this device reconnect after an app restart, calls on another device show as elsewhere.
  • ICE candidates buffered both ways until each side is ready (including the ones Calloop sends while a call rings).
  • Media recovery: no media within 15 s or a failed connection → one reconnect. If that fails too, the call ends locally with media_failed and the SDK asks Calloop to end it (system_failed), so Calloop records it FAILED / NETWORK_ERROR.
  • One socket per agent (never shared with another connection to the same URL); a fresh token on every reconnect; TURN credentials from Calloop, refreshed.

Testing ​

flutter test drives the SDK through the protocol with a scripted gateway and fake WebRTC (test/fakes.dart), and the native layer with a fake call screen (test/native_test.dart).

Calloop — the call engine behind your product.