Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build cross-platform VoIP calling apps with Flutter using Telnyx WebRTC SDK. Covers authentication, making/receiving calls, push notifications (FCM + APNS), call quality metrics, and AI Agent integration. Works on Android, iOS, and Web.
.claude/skills/team-telnyx-telnyx-webrtc-client-flutter/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 74% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 116% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 110% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 99% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 124% | 0% |
Build real-time voice communication into Flutter applications (Android, iOS, Web).
> Prerequisites: Create WebRTC credentials and generate a login token using the Telnyx server-side SDK. See the telnyx-webrtc-* skill in your server language plugin (e.g., telnyx-python, telnyx-javascript).
For faster implementation, consider Telnyx Common - a higher-level abstraction that simplifies WebRTC integration with minimal setup.
Add to pubspec.yaml:
yamldependencies: telnyx_webrtc: ^latest_version
Then run:
bashflutter pub get
Add to AndroidManifest.xml:
xml<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
Add to Info.plist:
xml<key>NSMicrophoneUsageDescription</key> <string>$(PRODUCT_NAME) needs microphone access for calls</string>
dartfinal telnyxClient = TelnyxClient(); final credentialConfig = CredentialConfig( sipUser: 'your_sip_username', sipPassword: 'your_sip_password', sipCallerIDName: 'Display Name', sipCallerIDNumber: '+15551234567', notificationToken: fcmOrApnsToken, // Optional: for push autoReconnect: true, debug: true, logLevel: LogLevel.debug, ); telnyxClient.connectWithCredential(credentialConfig);
dartfinal tokenConfig = TokenConfig( sipToken: 'your_jwt_token', sipCallerIDName: 'Display Name', sipCallerIDNumber: '+15551234567', notificationToken: fcmOrApnsToken, autoReconnect: true, debug: true, ); telnyxClient.connectWithToken(tokenConfig);
| Parameter | Type | Description | |-----------|------|-------------| | sipUser / sipToken | String | Credentials from Telnyx Portal | | sipCallerIDName | String | Caller ID name displayed to recipients | | sipCallerIDNumber | String | Caller ID number | | notificationToken | String? | FCM (Android) or APNS (iOS) token | | autoReconnect | bool | Auto-retry login on failure | | debug | bool | Enable call quality metrics | | logLevel | LogLevel | none, error, warning, debug, info, all | | ringTonePath | String? | Custom ringtone asset path | | ringbackPath | String? | Custom ringback tone asset path |
darttelnyxClient.call.newInvite( 'John Doe', // callerName '+15551234567', // callerNumber '+15559876543', // destinationNumber 'my-custom-state', // clientState );
Listen for socket events:
dartInviteParams? _incomingInvite; Call? _currentCall; telnyxClient.onSocketMessageReceived = (TelnyxMessage message) { switch (message.socketMethod) { case SocketMethod.CLIENT_READY: // Ready to make/receive calls break; case SocketMethod.LOGIN: // Successfully logged in break; case SocketMethod.INVITE: // Incoming call! _incomingInvite = message.message.inviteParams; // Show incoming call UI... break; case SocketMethod.ANSWER: // Call was answered break; case SocketMethod.BYE: // Call ended break; } }; // Accept the incoming call void acceptCall() { if (_incomingInvite != null) { _currentCall = telnyxClient.acceptCall( _incomingInvite!, 'My Name', '+15551234567', 'state', ); } }
dart// End call telnyxClient.call.endCall(telnyxClient.call.callId); // Decline incoming call telnyxClient.createCall().endCall(_incomingInvite?.callID); // Mute/Unmute telnyxClient.call.onMuteUnmutePressed(); // Hold/Unhold telnyxClient.call.onHoldUnholdPressed(); // Toggle speaker telnyxClient.call.enableSpeakerPhone(true); // Send DTMF tone telnyxClient.call.dtmf(telnyxClient.call.callId, '1');
dart// main.dart @pragma('vm:entry-point') Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); if (defaultTargetPlatform == TargetPlatform.android) { await Firebase.initializeApp(); FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler); } runApp(const MyApp()); }
dartFuture<void> _firebaseBackgroundHandler(RemoteMessage message) async { // Show notification (e.g., using flutter_callkit_incoming) showIncomingCallNotification(message); // Listen for user action FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { switch (event!.event) { case Event.actionCallAccept: TelnyxClient.setPushMetaData( message.data, isAnswer: true, isDecline: false, ); break; case Event.actionCallDecline: TelnyxClient.setPushMetaData( message.data, isAnswer: false, isDecline: true, // SDK handles decline automatically ); break; } }); }
dartFuture<void> _handlePushNotification() async { final data = await TelnyxClient.getPushMetaData(); if (data != null) { PushMetaData pushMetaData = PushMetaData.fromJson(data); telnyxClient.handlePushNotification( pushMetaData, credentialConfig, tokenConfig, ); } }
dartbool _waitingForInvite = false; void acceptCall() { if (_incomingInvite != null) { _currentCall = telnyxClient.acceptCall(...); } else { // Set flag if invite hasn't arrived yet _waitingForInvite = true; } } // In socket message handler: case SocketMethod.INVITE: _incomingInvite = message.message.inviteParams; if (_waitingForInvite) { acceptCall(); // Accept now that invite arrived _waitingForInvite = false; } break;
swift// AppDelegate.swift func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) { let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined() SwiftFlutterCallkitIncomingPlugin.sharedInstance? .setDevicePushTokenVoIP(deviceToken) } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { guard type == .voIP else { return } if let metadata = payload.dictionaryPayload["metadata"] as? [String: Any] { let callerName = (metadata["caller_name"] as? String) ?? "" let callerNumber = (metadata["caller_number"] as? String) ?? "" let callId = (metadata["call_id"] as? String) ?? UUID().uuidString let data = flutter_callkit_incoming.Data( id: callId, nameCaller: callerName, handle: callerNumber, type: 0 ) data.extra = payload.dictionaryPayload as NSDictionary SwiftFlutterCallkitIncomingPlugin.sharedInstance? .showCallkitIncoming(data, fromPushKit: true) } }
dartFlutterCallkitIncoming.onEvent.listen((CallEvent? event) { switch (event!.event) { case Event.actionCallIncoming: PushMetaData? pushMetaData = PushMetaData.fromJson( event.body['extra']['metadata'] ); telnyxClient.handlePushNotification( pushMetaData, credentialConfig, tokenConfig, ); break; case Event.actionCallAccept: // Handle accept break; } });
dartconst CALL_MISSED_TIMEOUT = 60; // seconds void handlePushMessage(RemoteMessage message) { DateTime now = DateTime.now(); Duration? diff = now.difference(message.sentTime!); if (diff.inSeconds > CALL_MISSED_TIMEOUT) { showMissedCallNotification(message); return; } // Handle normal incoming call... }
Enable with debug: true in config:
dart// When making a call call.newInvite( callerName: 'John', callerNumber: '+15551234567', destinationNumber: '+15559876543', clientState: 'state', debug: true, ); // Listen for quality updates call.onCallQualityChange = (CallQualityMetrics metrics) { print('MOS: ${metrics.mos}'); print('Jitter: ${metrics.jitter * 1000} ms'); print('RTT: ${metrics.rtt * 1000} ms'); print('Quality: ${metrics.quality}'); // excellent, good, fair, poor, bad };
| Quality Level | MOS Range | |---------------|-----------| | excellent | > 4.2 | | good | 4.1 - 4.2 | | fair | 3.7 - 4.0 | | poor | 3.1 - 3.6 | | bad | ≤ 3.0 |
Connect to a Telnyx Voice AI Agent:
darttry { await telnyxClient.anonymousLogin( targetId: 'your_ai_assistant_id', targetType: 'ai_assistant', // Default targetVersionId: 'optional_version_id', // Optional ); } catch (e) { print('Login failed: $e'); }
darttelnyxClient.newInvite( 'User Name', '+15551234567', '', // Destination ignored for AI Agent 'state', customHeaders: { 'X-Account-Number': '123', // Maps to {{account_number}} 'X-User-Tier': 'premium', // Maps to {{user_tier}} }, );
darttelnyxClient.onTranscriptUpdate = (List<TranscriptItem> transcript) { for (var item in transcript) { print('${item.role}: ${item.content}'); // role: 'user' or 'assistant' // content: transcribed text // timestamp: when received } }; // Get current transcript anytime List<TranscriptItem> current = telnyxClient.transcript; // Clear transcript telnyxClient.clearTranscript();
dartCall? activeCall = telnyxClient.calls.values.firstOrNull; if (activeCall != null) { activeCall.sendConversationMessage( 'Hello, I need help with my account' ); }
dartclass MyCustomLogger extends CustomLogger { @override log(LogLevel level, String message) { print('[$level] $message'); // Send to analytics, file, server, etc. } } final config = CredentialConfig( // ... other config logLevel: LogLevel.debug, customLogger: MyCustomLogger(), );
| Issue | Solution | |-------|----------| | No audio on Android | Check RECORD_AUDIO permission | | No audio on iOS | Check NSMicrophoneUsageDescription in Info.plist | | Push not working (debug) | Push only works in release mode | | Login fails | Verify SIP credentials in Telnyx Portal | | 10-second timeout | INVITE didn't arrive - check network/push setup | | sender_id_mismatch | FCM project mismatch between app and server |
<!-- BEGIN AUTO-GENERATED API REFERENCE -- do not edit below this line -->
references/webrtc-server-api.md has the server-side WebRTC API — credential creation, token generation, and push notification setup. You MUST read it when setting up authentication or push notifications.
TelnyxClient() is the core class of the SDK, and can be used to connect to our backend socket connection, create calls, check state and disconnect, etc.
dartTelnyxClient _telnyxClient = TelnyxClient();
To log into the Telnyx WebRTC client, you'll need to authenticate using a Telnyx SIP Connection. Follow our quickstart guide to create JWTs (JSON Web Tokens) to authenticate. To log in with a token we use the connectWithToken() method. You can also authenticate directly with the SIP Connection username and password with the connectWithCredential() method:
dart_telnyxClient.connectWithToken(tokenConfig) //OR _telnyxClient.connectWithCredential(credentialConfig) ``` ### Listening for events and reacting - Accepting a Call In order to be able to accept a call, we first need to listen for invitations. We do this by getting the Telnyx Socket Response callbacks from our TelnyxClient: ### Call ### Call The Call class is used to manage the call state and call actions. It is used to accept, decline, end, mute, hold, and send DTMF tones during a call. ### Accept Call In order to accept a call, we simply retrieve the instance of the call and use the .acceptCall(callID) method:
_telnyxClient.call.acceptCall(_incomingInvite?.callID);
### Decline / End Call
In order to end a call, we can get a stored instance of Call and call the .endCall(callID) method. To decline an incoming call we first create the call with the .createCall() method and then call the .endCall(callID) method:
if (_ongoingCall) { _telnyxClient.call.endCall(_telnyxClient.call.callId); } else { _telnyxClient.createCall().endCall(_incomingInvite?.callID); }
### DTMF (Dual Tone Multi Frequency)
In order to send a DTMF message while on a call you can call the .dtmf(callID, tone), method where tone is a String value of the character you would like pressed:
_telnyxClient.call.dtmf(_telnyxClient.call.callId, tone);
### Mute a call
To mute a call, you can simply call the .onMuteUnmutePressed() method:
_telnyxClient.call.onMuteUnmutePressed();
### Toggle loud speaker
To toggle loud speaker, you can simply call .enableSpeakerPhone(bool):
_telnyxClient.call.enableSpeakerPhone(true);
### Put a call on hold
To put a call on hold, you can simply call the .onHoldUnholdPressed() method:
_telnyxClient.call.onHoldUnholdPressed();
<!-- END AUTO-GENERATED API REFERENCE -->| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | fail→fail | 12,646 | 5,570 | -56% | 1 | 1 | 0% | 2,272 | 5,285 | +133% | 0 | 0 | — |
case-04 | fail→pass | 16,375 | 5,651 | -65% | 1 | 1 | 0% | 2,986 | 5,194 | +74% | 0 | 0 | — |
case-01 | fail→pass | 16,412 | 12,123 | -26% | 1 | 1 | 0% | 3,093 | 6,690 | +116% | 0 | 0 | — |
case-02 | fail→pass | 17,123 | 13,288 | -22% | 1 | 1 | 0% | 3,255 | 6,825 | +110% | 0 | 0 | — |
case-03 | fail→fail | 18,231 | 8,144 | -55% | 1 | 1 | 0% | 3,893 | 5,812 | +49% | 0 | 0 | — |
case-06 | pass→pass | 12,685 | 3,144 | -75% | 1 | 1 | 0% | 2,321 | 4,660 | +101% | 0 | 0 | — |
case-07 | pass→pass | 2,617 | 1,780 | -32% | 1 | 1 | 0% | 412 | 4,352 | +956% | 0 | 0 | — |
case-08 | fail→pass | 13,354 | 3,685 | -72% | 1 | 1 | 0% | 2,425 | 4,828 | +99% | 0 | 0 | — |
case-09 | fail→pass | 10,350 | 2,491 | -76% | 1 | 1 | 0% | 2,029 | 4,545 | +124% | 0 | 0 | — |
case-10 | fail→pass | 16,144 | 4,894 | -70% | 1 | 1 | 0% | 3,027 | 5,060 | +67% | 0 | 0 | — |
case-11 | pass→pass | 12,581 | 4,833 | -62% | 1 | 1 | 0% | 2,247 | 5,087 | +126% | 0 | 0 | — |
case-12 | fail→pass | 13,934 | 5,329 | -62% | 1 | 1 | 0% | 2,709 | 5,157 | +90% | 0 | 0 | — |
case-13 | pass→pass | 18,062 | 7,812 | -57% | 1 | 1 | 0% | 3,031 | 5,612 | +85% | 0 | 0 | — |
case-14 | pass→pass | 10,529 | 5,203 | -51% | 1 | 1 | 0% | 2,206 | 5,050 | +129% | 0 | 0 | — |
case-15 | fail→pass | 13,990 | 5,439 | -61% | 1 | 1 | 0% | 2,505 | 5,101 | +104% | 0 | 0 | — |
case-16 | fail→pass | 12,326 | 2,939 | -76% | 1 | 1 | 0% | 2,340 | 4,620 | +97% | 0 | 0 | — |
case-17 | fail→pass | 13,154 | 7,426 | -44% | 1 | 1 | 0% | 2,352 | 5,503 | +134% | 0 | 0 | — |
case-18 | fail→pass | 15,727 | 7,240 | -54% | 1 | 1 | 0% | 3,295 | 5,643 | +71% | 0 | 0 | — |
case-19 | fail→pass | 16,719 | 4,918 | -71% | 1 | 1 | 0% | 2,829 | 5,000 | +77% | 0 | 0 | — |
case-20 | pass→pass | 15,347 | 7,685 | -50% | 1 | 1 | 0% | 3,165 | 5,528 | +75% | 0 | 0 | — |
case-21 | pass→pass | 13,618 | 6,077 | -55% | 1 | 1 | 0% | 2,466 | 5,190 | +110% | 0 | 0 | — |
case-22 | pass→pass | 9,958 | 6,683 | -33% | 1 | 1 | 0% | 2,027 | 5,427 | +168% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +55 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.