> For clean Markdown of any page, append `.md` to the page URL.
> For a complete documentation index, see https://docs.sarvam.ai/llms.txt.
> For full documentation content in one file, see https://docs.sarvam.ai/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.sarvam.ai/_mcp/server.
# Flutter
For real-time voice on iOS and Android, the Flutter SDK (`sarvamconv_ai_sdk`) wraps the [WebSocket interface](/conversations/deploy/deploy-with-code#websocket-integration) in `SamvaadAgent`, with `DefaultAudioInterface` for microphone capture and playback.
## Install
Add the SDK to `pubspec.yaml`:
```yaml
dependencies:
sarvamconv_ai_sdk: ^1.0.0
```
```bash
flutter pub get
```
**iOS.** Add microphone permission to `ios/Runner/Info.plist`:
```xml
NSMicrophoneUsageDescription
This app needs microphone access for voice conversations
```
**Android.** Add permissions to `android/app/src/main/AndroidManifest.xml`:
```xml
```
## Start a voice session
```dart
import 'package:flutter/material.dart';
import 'package:sarvamconv_ai_sdk/sarvamconv_ai_sdk.dart';
class VoiceChat extends StatefulWidget {
const VoiceChat({super.key});
@override
State createState() => _VoiceChatState();
}
class _VoiceChatState extends State {
SamvaadAgent? _agent;
DefaultAudioInterface? _audioInterface;
bool _isConnected = false;
String _transcript = '';
Future startConversation() async {
_audioInterface = DefaultAudioInterface(inputSampleRate: 16000);
final config = InteractionConfig(
orgId: 'your_org_id',
workspaceId: 'your_workspace_id',
appId: 'your_app_id',
userIdentifier: 'user123',
userIdentifierType: UserIdentifierType.custom,
interactionType: InteractionType.call,
sampleRate: 16000,
);
_agent = SamvaadAgent(
apiKey: 'your_api_key',
config: config,
audioInterface: _audioInterface,
textCallback: (msg) async {
if (msg is ServerTextChunkMsg) {
setState(() => _transcript += msg.text);
}
},
eventCallback: (event) async {
if (event is ServerInteractionConnectedEvent) {
setState(() => _isConnected = true);
} else if (event is ServerInteractionEndEvent) {
setState(() => _isConnected = false);
}
},
);
await _agent!.start();
final connected = await _agent!.waitForConnect(
timeout: const Duration(seconds: 10),
);
if (!connected) {
throw Exception('Connection timeout');
}
}
Future stopConversation() async {
await _agent?.stop();
_agent = null;
_audioInterface = null;
setState(() => _isConnected = false);
}
@override
void dispose() {
stopConversation();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Voice Chat')),
body: Column(
children: [
ElevatedButton(
onPressed: _isConnected ? stopConversation : startConversation,
child: Text(_isConnected ? 'Stop Voice Chat' : 'Start Voice Chat'),
),
Expanded(
child: SingleChildScrollView(
child: Text('Transcript: $_transcript'),
),
),
],
),
);
}
}
```
`DefaultAudioInterface` captures the microphone and plays agent audio (16-bit PCM mono at 8 kHz, 16 kHz, or 48 kHz). `start()` fetches a signed WebSocket URL and begins streaming.
## InteractionConfig
| Field | Required | Description |
| --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userIdentifierType` | Yes | `UserIdentifierType.custom`, `.email`, `.phoneNumber`, or `.unknown` |
| `userIdentifier` | Yes | The identifier value; also what you search by in [Log Analyser](/conversations/monitor/agent-analytics/log-analyser) |
| `orgId` | Yes | Your organization ID |
| `workspaceId` | Yes | Your workspace ID |
| `appId` | Yes | The agent to connect to |
| `interactionType` | Yes | `InteractionType.call` for a voice session |
| `sampleRate` | Yes | `8000`, `16000`, or `48000` (16-bit PCM mono) |
| `version` | No | Pins a specific committed agent version. If omitted, the SDK uses the latest committed version, and the connection fails if the agent has no committed version |
| `agentVariables` | No | Seed [agent variables](/conversations/build/variables-personalization) at session start |
| `initialLanguageName` | No | Starting language; must be one of the agent's allowed languages (for example `SarvamToolLanguageName.hindi`) |
| `initialStateName` | No | Starting state, if the agent uses [states](/conversations/build/states-conversation-flow) |
| `initialBotMessage` | No | First message from the agent |
## Callbacks
Pass any of these to `SamvaadAgent` to react to what happens during the call:
| Callback | Fires with | Use it for |
| --------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `textCallback` | `ServerTextMsg` or `ServerTextChunkMsg` | Streaming or complete agent text |
| `audioCallback` | audio chunk (`audioBytes`, `sampleRate`) | Raw agent audio, if you're handling playback yourself instead of using `audioInterface` |
| `eventCallback` | session event | `ServerInteractionConnectedEvent`, `ServerUserInterruptEvent`, `ServerInteractionEndEvent`, speech start/end, language change, state transition, variable update |
## DefaultAudioInterface
```dart
final audioInterface = DefaultAudioInterface(
inputSampleRate: 16000,
outputSampleRate: 16000,
);
```
To bring your own I/O, implement `AudioInterface`:
```dart
class CustomAudioInterface implements AudioInterface {
@override
Future start(AudioInputCallback inputCallback) async {
// Capture audio and call inputCallback with chunks
}
@override
Future output(Uint8List audio, {int? sampleRate}) async {
// Play audio through the speaker
}
@override
void interrupt() {
// Stop queued playback
}
@override
Future stop() async {
// Cleanup
}
}
```
## Session methods
| Method | Description |
| ------------------------------------------------------------ | ------------------------------------------------- |
| `await agent.start()` | Fetch a signed WebSocket URL and connect |
| `await agent.waitForConnect(timeout: Duration(seconds: 10))` | Wait until connected (returns `false` on timeout) |
| `await agent.sendAudio(audioBytes)` | Send raw 16-bit PCM mono at `config.sampleRate` |
| `agent.isConnected` | Current connection status |
| `agent.interactionId` | The current interaction (call) ID, once connected |
| `await agent.waitForDisconnect()` | Wait until the session ends |
| `await agent.stop()` | Close the connection and clean up |
> **Note**
>
> Call `await agent.stop()` in `dispose()` so the WebSocket and audio interface are cleaned up. Reconnection is not supported: each WebSocket URL is single-use. If the connection drops, call `stop()` and create a new `SamvaadAgent`.
Stop the agent when the app goes to the background:
```dart
class _MyWidgetState extends State with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_agent?.stop();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_agent?.stop();
}
}
}
```
## Troubleshooting
**Microphone permission denied.** Request the microphone before starting:
```dart
import 'package:permission_handler/permission_handler.dart';
final status = await Permission.microphone.request();
if (!status.isGranted) {
// Ask the user to enable the permission
}
```
**No audio output.** Check device volume, that `DefaultAudioInterface` is initialized, and that `sampleRate` matches the session config.
**Connection timeout.** Check connectivity, API key, `orgId` / `workspaceId` / `appId`, and that the agent has a committed version.
> **Warning**
>
> Never embed your API key in the app binary. Omit `apiKey`, set `baseUrl` to a backend you control, and pass your app auth in `headers`:
```dart
final agent = SamvaadAgent(
config: config,
baseUrl: 'https://your-proxy-server.com/sarvam-proxy/',
headers: {
'Authorization': 'Bearer user_session_token',
},
audioInterface: DefaultAudioInterface(inputSampleRate: 16000),
);
```