# API Documentation (legacy) > The AI Voice Calling Agent API provides a comprehensive platform for managing user campaigns, triggering automated outbound voice calls, and analyzing campaign performance. The system allows you to organize users into campaigns, attach relevant data, create triggers with AI-generated columns, and execute targeted calling campaigns. URL: /api-reference/call-trigger-apis Markdown: /api-reference/call-trigger-apis.md ## Base URLs [#base-urls] * **Production**: `https://api.revrag.ai` * **Staging**: `https://staging-api.revrag.ai` ## Authentication [#authentication] All API requests require an API key to be included in the request headers: ``` X-API-Key: YOUR_API_KEY ``` ## Single Trigger APIs [#single-trigger-apis] > **πŸš€ Quick Start:** Want to test these APIs immediately? Check out our [Postman Collection](./postman-collection) with all endpoints pre-configured and ready to import! ### 1. Get All Agents [#1-get-all-agents] **GET** `/v1/campaigns/agents` Get All Agents #### Header Parameters [#header-parameters] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------- | | `X-API-Key` | string | Yes | API Key for authentication | #### Response [#response] **Success (200 OK)** ```json [ { "id": "agent_123e4567-e89b-12d3-a456-426614174000", "name": "customer-support-agent", "variables": [ { "name": "customer_name", "type": "string", "default_value": null } ] } ] ``` #### cURL Example [#curl-example] ```bash curl -X GET "https://staging-api.revrag.ai/v1/campaigns/agents" \ -H "X-API-Key: YOUR_API_KEY" ``` *** ### 2. Trigger Single Call [#2-trigger-single-call] **POST** `/v1/campaigns/trigger/single` Trigger a single AI call for testing purposes. #### Header Parameters [#header-parameters-1] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------- | | `X-API-Key` | string | Yes | API Key for authentication | #### Request Body [#request-body] ```json { "agent_id": "agent_123e4567-e89b-12d3-a456-426614174000", "to_phone_number": "+1234567890", "variable_fields": { "first_name": "John", "company": "Acme Inc." }, "custom_metadata": { "campaign_type": "follow_up", "source": "web_form", "priority": "high" } } ``` #### Request Parameters [#request-parameters] | Parameter | Type | Required | Description | | ----------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------- | | `agent_id` | string (uuid) | Yes | The ID of the agent | | `to_phone_number` | string | Yes | Recipient phone number | | `variable_fields` | object or null | No | Variable fields for the call | | `custom_metadata` | object or null | No | Custom metadata fields for tracking and organization. This field is flexible and can accept any key-value pairs | #### Response [#response-1] **Success (200 OK)** ```json { "success": true, "message": "Call triggered successfully", "to_phone_number": "+1234567890", "agent_id": "agent_123e4567-e89b-12d3-a456-426614174000", "call_id": "call_456e7890-f12c-34d5-b678-901234567890" } ``` #### cURL Example [#curl-example-1] ```bash curl -X POST "https://staging-api.revrag.ai/v1/campaigns/trigger/single" \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_123e4567-e89b-12d3-a456-426614174000", "to_phone_number": "+1234567890", "variable_fields": { "first_name": "John", "company": "Acme Inc." }, "custom_metadata": { "campaign_type": "follow_up", "source": "web_form", "priority": "high" } }' ``` **Validation Error (422)** ```json { "detail": [ { "loc": ["body", "to_phone_number"], "msg": "Invalid phone number format", "type": "value_error" } ] } ``` *** ### 3. Get Call Status [#3-get-call-status] **GET** `/v1/campaigns/trigger/status/{call_id}` Get the status of a specific call by its call\_id. #### Path Parameters [#path-parameters] | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------ | | `call_id` | string (uuid) | Yes | The ID of the call | #### Header Parameters [#header-parameters-2] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | -------------------------- | | `X-API-Key` | string | Yes | API Key for authentication | #### Response [#response-2] **Success (200 OK)** ```json { "call_status": "ENDED", "call_id": "call_123e4567-e89b-12d3-a456-426614174000", "agent_id": "agent_456e7890-f12c-34d5-b678-901234567890", "from_number": "+918045342561", "to_number": "+1234567890", "summary": "Customer inquired about pricing options for our services. Discussed various pricing tiers and provided information about premium features. Customer showed interest in upgrading their current plan.", "start_time": "2024-01-15T10:00:00.000000", "end_time": "2024-01-15T10:15:30.500000", "duration": 930.5, "disconnection_reason": "USER_HANGUP", "recording_url": "https://cdn.revrag.ai/voice/recordings/room_abc123def456/room_abc123def456_audio.mp4", "transcription": { "messages": [ { "role": "assistant", "content": "Hello, this is Sarah from Customer Support. Am I speaking with John?", "timestamp": "2024-01-15T10:00:15.000000+00:00" }, { "role": "user", "content": "Yes, this is John speaking.", "timestamp": "2024-01-15T10:00:25.000000+00:00" }, { "role": "assistant", "content": "Great! I'm calling to follow up on your recent inquiry about our services.", "timestamp": "2024-01-15T10:00:35.000000+00:00" }, { "role": "user", "content": "Yes, I was interested in learning more about the pricing options.", "timestamp": "2024-01-15T10:00:50.000000+00:00" } ] }, "custom_variables": [ { "key": "customer_tier", "description": "Customer subscription tier level", "value": "premium", "type": "string" }, { "key": "account_balance", "description": "Current account balance in USD", "value": 1250.75, "type": "number" }, { "key": "is_first_time_caller", "description": "Whether this is the customer's first call", "value": false, "type": "boolean" } ], "variables_fields": { "first_name": "John", "company": "Acme Inc." }, "custom_metadata": { "campaign_type": "follow_up", "source": "web_form", "priority": "high" } } ``` #### Response Parameters [#response-parameters] | Parameter | Type | Description | | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `call_status` | string | Current status of the call (QUEUED, RUNNING, or ENDED) | | `call_id` | string (uuid) | Unique identifier for the call | | `agent_id` | string (uuid) | ID of the agent used for this call | | `from_number` | string | Phone number from which the call was made | | `to_number` | string | Phone number to which the call was made | | `summary` | string | AI-generated summary of the call conversation | | `start_time` | string | ISO 8601 timestamp when the call started | | `end_time` | string | ISO 8601 timestamp when the call ended | | `duration` | number | Call duration in seconds | | `disconnection_reason` | string | Reason why the call ended (see Disconnection Reason Values) | | `recording_url` | string | URL to the call recording (MP4 format) | | `transcription` | object | Full transcription of the call with messages and timestamps | | `custom_variables` | array | AI-extracted variables from the conversation during the call | | `variables_fields` | object or null | The original variable fields that were passed when triggering the call (note: the request body uses `variable_fields`; the response key has an extra `s`) | | `custom_metadata` | object or null | The original custom metadata that was passed when triggering the call | #### cURL Example [#curl-example-2] ```bash curl -X GET "https://staging-api.revrag.ai/v1/campaigns/trigger/status/call_123e4567-e89b-12d3-a456-426614174000" \ -H "X-API-Key: YOUR_API_KEY" ``` *** ### 4. Webhook Notifications (Optional) [#4-webhook-notifications-optional] Instead of polling the call status endpoint, you can configure a webhook URL to receive real-time call completion notifications. When a call ends, we'll automatically send the same response body structure as the "Get Call Status" API to your webhook endpoint. #### Webhook Configuration [#webhook-configuration] Contact our support team at **[contact@revrag.ai](mailto:contact@revrag.ai)** to configure your webhook URL. #### Webhook Payload [#webhook-payload] The webhook will receive a **POST** request with the same JSON structure as the call status response, including the original values you passed as `variable_fields` (echoed back on the payload as `variables_fields`) and `custom_metadata`: ```json { "call_status": "ENDED", "call_id": "call_123e4567-e89b-12d3-a456-426614174000", "agent_id": "agent_456e7890-f12c-34d5-b678-901234567890", "from_number": "+918045342561", "to_number": "+1234567890", "summary": "Customer inquired about pricing options for our services. Discussed various pricing tiers and provided information about premium features. Customer showed interest in upgrading their current plan.", "start_time": "2024-01-15T10:00:00.000000", "end_time": "2024-01-15T10:15:30.500000", "duration": 930.5, "disconnection_reason": "USER_HANGUP", "recording_url": "https://cdn.revrag.ai/voice/recordings/room_abc123def456/room_abc123def456_audio.mp4", "transcription": { "messages": [ { "role": "assistant", "content": "Hello, this is Sarah from Customer Support. Am I speaking with John?", "timestamp": "2024-01-15T10:00:15.000000+00:00" }, { "role": "user", "content": "Yes, this is John speaking.", "timestamp": "2024-01-15T10:00:25.000000+00:00" } ] }, "custom_variables": [ { "key": "customer_tier", "description": "Customer subscription tier level", "value": "premium", "type": "string" }, { "key": "account_balance", "description": "Current account balance in USD", "value": 1250.75, "type": "number" }, { "key": "is_first_time_caller", "description": "Whether this is the customer's first call", "value": false, "type": "boolean" } ], "variables_fields": { "first_name": "John", "company": "Acme Inc." }, "custom_metadata": { "campaign_type": "follow_up", "source": "web_form", "priority": "high" } } ``` *** ### Call Status Values [#call-status-values] | Status | Description | | --------- | ----------------------------- | | `QUEUED` | Call is queued for execution | | `RUNNING` | Call is currently in progress | | `ENDED` | Call has ended | ### Disconnection Reason Values [#disconnection-reason-values] When a call ends, the `disconnection_reason` field indicates why the call was terminated: | Reason | Description | | -------------------- | -------------------------------------------------------------------------------- | | `USER_HANGUP` | The callee hung up the call | | `USER_REJECTED` | Callee rejected the call (busy) | | `MAX_DURATION` | The set maximum call duration was reached | | `VOICEMAIL_REACHED` | Voicemail was reached | | `AGENT_HANGUP` | The agent ended the call as per prompt or upon detecting conversation completion | | `SILENCE_TIMEOUT` | Callee was silent for longer than the configured silence timeout period | | `CALL_NOT_PICKED` | Call was not picked up by the callee | | `ERROR_OCCURRED` | An error occurred during the call | | `CALL_NOT_CONNECTED` | Call was not connected due to Telephony Issue | | `CALL_DROPPED` | Call was dropped in Between due to Network Issues | *** ## Support [#support] If you encounter any issues or need assistance with the API, please contact our support team: **Email**: [contact@revrag.ai](mailto:contact@revrag.ai) Our team will respond to your inquiries and help resolve any technical issues you may experience while using the API. --- # Campaigns API > Push one or more records to a RevRag campaign via the API and let the agent dial them per the campaign's schedule, retry, and concurrency configuration; then poll record and campaign status, or receive webhooks. URL: /api-reference/campaigns-api Markdown: /api-reference/campaigns-api.md ## Overview [#overview] A **campaign** in RevRag is a workspace-scoped definition that bundles an agent, a schedule, retry/follow-up rules, and concurrency limits. This page documents the endpoints you use to push records into an already-running campaign and to track their status: | Endpoint | Use when | | ------------------------- | ------------------------------------------------------------------------------------- | | `POST /records/push-bulk` | Push one or more records into the campaign (a single record goes as a 1-element list) | | `GET /records/{run_id}` | Poll the status of a record you pushed (by the `run_id` returned on push) | | `GET /records` | List a campaign's records, filterable by status | | `GET /status` | Get the campaign's status + per-status record counts | The push endpoint returns a **`run_id`** for every record so you can correlate and poll each one. The campaign itself β€” name, agent, schedule, end date, retry rules, concurrency β€” is configured and launched in the RevRag dashboard. By the time you call these APIs, the campaign should already be running. The dashboard's **Campaign β†’ Settings** tab shows a copy-pasteable curl with your campaign's id pre-filled. ## Base URLs [#base-urls] * **Production**: `https://api.revrag.ai` * **Staging**: `https://staging-api.revrag.ai` ## Authentication [#authentication] All requests require a workspace-scoped API key in the `X-API-Key` header: ``` X-API-Key: YOUR_API_KEY ``` The workspace is resolved from the API key itself β€” you do not pass a workspace header. The campaign in the path must belong to the same workspace as the API key, otherwise the request returns `404 Not Found`. ## Before You Push [#before-you-push] The campaign is fully created, configured, and started from the RevRag dashboard. From the API caller's side you only need: * The **`campaign_id`** β€” copy it from the dashboard's Campaign β†’ Settings tab (it's also in the URL of the campaign detail page). * Your **`X-API-Key`** β€” issued for your workspace. * The agent's **variable names**, if the agent expects per-call variables. Variable names are visible on the campaign's Settings tab. ## Endpoints [#endpoints] ### 1. Push Records [#1-push-records] **POST** `/v1/campaigns/{campaign_id}/records/push-bulk` Queue one or more records for calling. Send a single record as a **1-element `records` array**. Each record is treated independently β€” the runner picks them up on its next polling cycle (typically within \~1 second) and dispatches each call subject to the campaign's daily time-window, attempt schedule, and concurrency cap. Invalid records are rejected individually (partial success), all in a single `200 OK`. #### Path Parameters [#path-parameters] | Parameter | Type | Required | Description | | ------------- | ------------- | -------- | --------------------------------- | | `campaign_id` | string (uuid) | Yes | The campaign to push records into | #### Header Parameters [#header-parameters] | Parameter | Type | Required | Description | | -------------- | ------ | -------- | -------------------------- | | `X-API-Key` | string | Yes | Workspace-scoped API key | | `Content-Type` | string | Yes | Must be `application/json` | #### Request Body [#request-body] ```json { "records": [ { "phone_number": "+919876543210", "variables": { "first_name": "Pankaj", "company": "Acme Inc." }, "app_user_id": "user_12345", "app_session_id": "sess_abcdef" }, { "phone_number": "+919876543211", "variables": { "first_name": "Riya" } } ] } ``` #### Request Parameters [#request-parameters] | Parameter | Type | Required | Description | | --------- | ----- | -------- | ----------------------------------------------------------------------- | | `records` | array | Yes | One or more records to push (use a 1-element array for a single record) | Each record in `records`: | Field | Type | Required | Description | | ---------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `phone_number` | string | Yes | E.164 format recommended (`+919876543210`) | | `variables` | object | No | Key-value pairs matching the agent's variable names. Sent into the agent's prompt at call time | | `app_user_id` | string | No | Your system's user id β€” a **top-level** correlation field (not inside `variables`). Returned top-level on the record and in webhooks | | `app_session_id` | string | No | Your system's session id β€” a **top-level** correlation field (not inside `variables`). Returned top-level on the record and in webhooks | **Cross-agent memory:** send `app_user_id` if you want this call to share memory with the same user's in-app agent sessions. RevRag keys conversation memory on `app_user_id`, so the calling agent picks up context from the in-app agent (and vice versa) only when both send the **same** id. Use the same value you pass in the SDK's `USER_DATA` event. Omit it and the call starts with no prior context. #### Response [#response] The response returns a **per-record result** for every record you submitted, in the same order as the `records` array (`results[i]` corresponds to `records[i]`). Each accepted record carries its own **`run_id`** so you can correlate every record to its created run and poll its status later (see [Get Record Status](#2-get-record-status)). **Success (200 OK)** ```json { "accepted": 2, "rejected": 0, "results": [ { "phone_number": "+919876543210", "accepted": true, "run_id": "00000000-0000-0000-0000-000000000001", "status": "queued", "error": null }, { "phone_number": "+919876543211", "accepted": true, "run_id": "00000000-0000-0000-0000-000000000002", "status": "queued", "error": null } ] } ``` **Partial success (200 OK)** Invalid records are rejected individually β€” the valid ones are still created. A rejected record has `accepted: false`, `run_id: null`, and an `error` message. ```json { "accepted": 1, "rejected": 1, "results": [ { "phone_number": "+919876543210", "accepted": true, "run_id": "00000000-0000-0000-0000-000000000001", "status": "queued", "error": null }, { "phone_number": "not-a-number", "accepted": false, "run_id": null, "status": null, "error": "Invalid Indian phone number: not-a-number" } ] } ``` #### Response Parameters [#response-parameters] | Parameter | Type | Description | | ---------- | ------ | ---------------------------------------------------------------------------------- | | `accepted` | number | How many records were inserted and queued for calling | | `rejected` | number | How many were rejected (e.g. invalid phone number) | | `results` | array | Per-record outcome, in the same order as the submitted `records`. See fields below | Each entry in `results`: | Field | Type | Description | | -------------- | --------------------- | --------------------------------------------------------------------------------------------------------- | | `phone_number` | string | The phone number you submitted for this record | | `accepted` | boolean | `true` if the record was created and queued, `false` if rejected | | `run_id` | string (uuid) \| null | The created run id β€” present when `accepted: true`. Use it with [Get Record Status](#2-get-record-status) | | `status` | string \| null | Initial status (`queued`) when accepted, `null` when rejected | | `error` | string \| null | Why the record was rejected β€” present when `accepted: false` | > Pushing a single record? Send a 1-element `records` array β€” you get that record's `run_id` back in `results[0]`. #### cURL Example [#curl-example] ```bash curl -X POST \ "https://staging-api.revrag.ai/v1/campaigns/{campaign_id}/records/push-bulk" \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "phone_number": "+919876543210", "variables": { "first_name": "Pankaj" } }, { "phone_number": "+919876543211", "variables": { "first_name": "Riya" } } ] }' ``` *** ### 2. Get Record Status [#2-get-record-status] **GET** `/v1/campaigns/{campaign_id}/records/{run_id}` Fetch the current status and details of a pushed record, using the `run_id` you got back from the push (`results[].run_id`). #### Path Parameters [#path-parameters-1] | Parameter | Type | Required | Description | | ------------- | ------------- | -------- | -------------------------------------------- | | `campaign_id` | string (uuid) | Yes | The campaign the record belongs to | | `run_id` | string (uuid) | Yes | The record's run id (from the push response) | #### Header Parameters [#header-parameters-1] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | ------------------------ | | `X-API-Key` | string | Yes | Workspace-scoped API key | #### Response [#response-1] **Success (200 OK)** β€” the full campaign record: ```json { "id": "00000000-0000-0000-0000-000000000001", "campaign_id": "11111111-1111-1111-1111-111111111111", "workspace_id": "22222222-2222-2222-2222-222222222222", "phone_number": "+919876543210", "status": "completed", "variables": { "first_name": "Pankaj" }, "app_user_id": "user_12345", "app_session_id": "sess_abcdef", "attempt_count": 1, "max_attempts": 3, "call_history": [{ "call_id": "...", "status": "completed" }], "next_attempt_at": null, "dnc_at": null, "dnc_reason": null, "created_at": "2026-05-27T03:00:00.000000+00:00", "updated_at": "2026-05-27T03:05:12.000000+00:00" } ``` See [Record Status Values](#record-status-values) for what each `status` means. The `call_history` array holds one entry per attempt, including the per-call `call_id` you can pass to [Get Call Status](#get-call-status) for the transcript and recording. #### cURL Example [#curl-example-1] ```bash curl -X GET \ "https://staging-api.revrag.ai/v1/campaigns/{campaign_id}/records/{run_id}" \ -H "X-API-Key: YOUR_API_KEY" ``` *** ### 3. List Records [#3-list-records] **GET** `/v1/campaigns/{campaign_id}/records` List the campaign's records, optionally filtered by status β€” useful for polling everything in a given state (e.g. all `queued`, or all `completed`). #### Query Parameters [#query-parameters] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------------- | | `status` | string | No | Filter by [record status](#record-status-values) (e.g. `queued`, `completed`, `failed`) | | `page` | number | No | 1-based page number (default `1`) | | `page_size` | number | No | Records per page (default `50`, max `200`) | #### Response [#response-2] **Success (200 OK)** ```json { "items": [ { "id": "00000000-0000-0000-0000-000000000001", "phone_number": "+919876543210", "status": "completed", "attempt_count": 1, "max_attempts": 3 } ], "total": 100, "has_more": true, "page": 1, "page_size": 50 } ``` Each item has the same fields as a single record (see [Get Record Status](#2-get-record-status)). #### cURL Example [#curl-example-2] ```bash curl -X GET \ "https://staging-api.revrag.ai/v1/campaigns/{campaign_id}/records?status=completed&page=1&page_size=50" \ -H "X-API-Key: YOUR_API_KEY" ``` *** ### 4. Get Campaign Status [#4-get-campaign-status] **GET** `/v1/campaigns/{campaign_id}/status` Get the campaign's lifecycle status plus aggregate per-status record counts β€” a quick way to track overall progress without listing every record. #### Response [#response-3] **Success (200 OK)** ```json { "campaign_id": "11111111-1111-1111-1111-111111111111", "status": "running", "stats": { "total_records": 100, "queued": 18, "in_progress": 4, "completed": 70, "failed": 3, "dnc": 2, "rescheduled": 1, "retry_pending": 2, "follow_up_pending": 0 } } ``` #### Response Parameters [#response-parameters-1] | Parameter | Type | Description | | ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `campaign_id` | string (uuid) | The campaign | | `status` | string | Campaign lifecycle status (`running`, `paused`, `completed`, `cancelled`, …) | | `stats` | object | Per-status record counts (`total_records`, `queued`, `in_progress`, `completed`, `failed`, `dnc`, `rescheduled`, `retry_pending`, `follow_up_pending`) | #### cURL Example [#curl-example-3] ```bash curl -X GET \ "https://staging-api.revrag.ai/v1/campaigns/{campaign_id}/status" \ -H "X-API-Key: YOUR_API_KEY" ``` *** ## How Records are Called [#how-records-are-called] Pushing a record does not always trigger an immediate dial. After your push is accepted, the campaign runner picks up queued records each cycle and applies the campaign's configured gates (all set in the dashboard): | Gate | Effect on your record | | -------------------------------------------------------------------------- | ----------------------------------------- | | Outside the campaign's daily calling window | Stays queued until the window opens | | Day not in the campaign's active days (e.g. weekend on a Mon-Fri campaign) | Queued until the next active day's window | | First-attempt schedule is later than today | Queued until that day | | Campaign's max concurrency reached | Queued until a slot frees | | Phone is in the workspace's Do-Not-Call list | Marked `dnc`, no call placed | When all gates are open, dispatch is typically within \~1 second of the push. See [Record Status Values](#record-status-values) for what each status means in flight. If the campaign has **Dial first attempt immediately** enabled (a dashboard setting), the **first** attempt skips the *first-attempt schedule* gate and is dialed as soon as the record is pushed β€” but still only within the calling window and the concurrency/DNC gates. Retries and follow-ups (attempts 2+) follow all the gates above as normal. If you push to a campaign that has finished (past its end date) or has been cancelled/paused-and-stopped from the dashboard, the API returns `400`. The campaign must be in an active state on the dashboard to accept pushes. ## Get Call Status [#get-call-status] **GET** `/v1/campaigns/trigger/status/{call_id}` Fetch the status, transcript, recording URL, summary, and extracted variables for a specific call. The `call_id` for a pushed record is available in the record's `call_history` (via [Get Record Status](#2-get-record-status)), delivered via webhook (see below), or visible on the campaign's Records tab in the dashboard. The push response itself returns the record's **`run_id`** (not the per-call `call_id`) β€” the call is placed asynchronously by the campaign runner, so poll the record (or wait for the webhook) to get the `call_id`. #### Path Parameters [#path-parameters-2] | Parameter | Type | Required | Description | | --------- | ------------- | -------- | -------------------------------------------------------------------- | | `call_id` | string (uuid) | Yes | The call id, obtained from the webhook payload or from the dashboard | #### Header Parameters [#header-parameters-2] | Parameter | Type | Required | Description | | ----------- | ------ | -------- | ------------------------ | | `X-API-Key` | string | Yes | Workspace-scoped API key | #### Response [#response-4] **Success (200 OK)** ```json { "call_status": "completed", "call_id": "123e4567-e89b-12d3-a456-426614174000", "agent_id": "33333333-3333-3333-3333-333333333333", "from_number": "+911140000000", "to_number": "+919876543210", "summary": "Customer confirmed the appointment for Friday.", "start_time": "2026-05-27T03:05:00+00:00", "end_time": "2026-05-27T03:06:12+00:00", "duration": 72, "disconnection_reason": "agent_hangup", "recording_url": "https://.../recording.mp3", "transcription": { "messages": [ { "role": "assistant", "content": "Hi Pankaj, calling about your appointment…" }, { "role": "user", "content": "Yes, Friday works." } ] }, "custom_variables": [ { "key": "appointment_confirmed", "type": "boolean", "value": true } ], "variables_fields": { "first_name": "Pankaj" }, "campaign_id": "11111111-1111-1111-1111-111111111111", "attempt_no": 1, "app_user_id": "user_12345", "app_session_id": "sess_abcdef" } ``` #### Response Parameters [#response-parameters-2] | Field | Type | Description | | -------------------------------- | --------------------- | --------------------------------------------------------------------------------- | | `call_status` | string | Final call status (e.g. `completed`, `failed`) | | `call_id` | string (uuid) | The call this status is for | | `agent_id` | string (uuid) | The agent that placed the call | | `from_number` / `to_number` | string | Caller / callee numbers | | `summary` | string | LLM-generated call summary (when a transcript exists) | | `start_time` / `end_time` | string \| null | ISO-8601 timestamps | | `duration` | number | Call duration in seconds | | `disconnection_reason` | string \| null | Normalized hangup reason | | `recording_url` | string \| null | Presigned recording URL (for transcribed calls) | | `transcription` | object \| null | Turn-by-turn messages | | `custom_variables` | array | Post-call variables extracted by the agent (`key`, `type`, `value`) | | `variables_fields` | object \| null | The `variables` you sent on push (correlation IDs are surfaced top-level instead) | | `campaign_id` | string (uuid) \| null | Campaign the call belongs to (null for non-campaign calls) | | `attempt_no` | number \| null | Which attempt this call was | | `app_user_id` / `app_session_id` | string \| null | Your correlation IDs (top-level), if you sent them | #### cURL Example [#curl-example-4] ```bash curl -X GET \ "https://staging-api.revrag.ai/v1/campaigns/trigger/status/123e4567-e89b-12d3-a456-426614174000" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Webhook Notifications [#webhook-notifications] Instead of polling for status, configure a webhook URL to receive a `POST` notification when each call ends. Webhooks fire for every call placed by the agent β€” including calls dispatched from campaigns pushed via this API. #### Configuration [#configuration] Contact **[contact@revrag.ai](mailto:contact@revrag.ai)** to configure your webhook URL and signing secret. #### Security & Headers [#security--headers] Campaign webhooks use the **same** signing scheme as every RevRag webhook β€” HMAC-SHA256 computed over `"{timestamp}.{raw_body}"` with your shared secret. Each request carries: | Header | Purpose | | --------------------- | --------------------------------------------- | | `X-Webhook-Event` | Event type (`call.ended`) | | `X-Webhook-Timestamp` | Unix timestamp (seconds) the webhook was sent | | `X-Webhook-Signature` | `t=,v1=` | | `X-Webhook-ID` | Unique id for deduplication | See [Webhook Security](./webhook-security) for the full verification steps and Python / Node.js examples. #### Payload [#payload] The webhook body is the same JSON shape as the [Get Call Status](#get-call-status) response. For calls placed from a campaign it also carries **`campaign_id`**, **`attempt_no`**, and your top-level correlation IDs **`app_user_id`** / **`app_session_id`**. The `variables` you sent are echoed back as **`variables_fields`** (with the correlation IDs kept out of it). ```json { "call_status": "completed", "call_id": "123e4567-e89b-12d3-a456-426614174000", "agent_id": "33333333-3333-3333-3333-333333333333", "from_number": "+911140000000", "to_number": "+919876543210", "summary": "Customer confirmed the appointment for Friday.", "start_time": "2026-05-27T03:05:00+00:00", "end_time": "2026-05-27T03:06:12+00:00", "duration": 72, "disconnection_reason": "agent_hangup", "recording_url": "https://.../recording.mp3", "transcription": { "messages": [{ "role": "assistant", "content": "Hello…" }] }, "custom_variables": [ { "key": "appointment_confirmed", "type": "boolean", "value": true } ], "variables_fields": { "first_name": "Pankaj" }, "campaign_id": "11111111-1111-1111-1111-111111111111", "attempt_no": 1, "app_user_id": "user_12345", "app_session_id": "sess_abcdef" } ``` ## Record Status Values [#record-status-values] | Status | Description | | ------------------- | ---------------------------------------------------------------------------------------------------- | | `queued` | Accepted, waiting for the runner to pick it up | | `in_progress` | Call is being placed or is live | | `completed` | Call finished β€” see `call_history` on the record for details | | `failed` | Call attempt failed (e.g. telephony error). Will retry if attempts remain and retry conditions match | | `retry_pending` | Waiting for the next retry attempt at `next_attempt_at` | | `follow_up_pending` | Waiting for a follow-up attempt scheduled by post-call logic | | `rescheduled` | Caller requested a callback β€” will dispatch at the requested time | | `dnc` | Phone added to Do-Not-Call list, no further attempts | ## Common Errors [#common-errors] | Status | When | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | Campaign is not currently accepting pushes (e.g. already finished or cancelled on the dashboard). A malformed `phone_number` is **not** a request-level error β€” it's rejected per-record in `results` (still `200`) | | `401 Unauthorized` | Missing or invalid `X-API-Key` header | | `404 Not Found` | `campaign_id` doesn't exist in the API key's workspace, or `campaign_id` isn't a valid UUID | | `422 Unprocessable Entity` | Request body doesn't match the schema (missing `phone_number`, malformed JSON, etc.) | **Example error body** ```json { "detail": "Cannot push records: campaign is completed." } ``` ## Support [#support] For API keys, campaign setup, or any technical issue, contact **[contact@revrag.ai](mailto:contact@revrag.ai)**. --- # Postman Collection > Ready-to-use Postman collection for testing RevRag AI Voice Calling Agent APIs. Import and start testing all Single Trigger APIs instantly. URL: /api-reference/postman-collection Markdown: /api-reference/postman-collection.md # Postman Collection [#postman-collection] **πŸš€ Ready-to-use Postman Collection for all Single Trigger APIs** We've created a complete Postman collection with all 3 Single Trigger APIs pre-configured and ready to test. This collection includes proper authentication, sample request bodies, and environment variables for seamless testing. ## **πŸ“₯ Download Options** [#-download-options] ### **Option 1: Direct Download** [#option-1-direct-download] Right-click and save: **[postman-collection.json](./postman-collection.json)** ### **Option 2: Copy & Save** [#option-2-copy--save] Copy the JSON collection below and save it as `RevRag_Single_Trigger_APIs.postman_collection.json`: ```json { "info": { "name": "RevRag Single Trigger APIs", "description": "Collection for RevRag AI Voice Calling Agent APIs - Single Trigger endpoints", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ { "key": "base_url", "value": "https://staging-api.revrag.ai", "type": "string" }, { "key": "api_key", "value": "YOUR_API_KEY", "type": "string" } ], "item": [ { "name": "1. Get All Agents", "request": { "method": "GET", "header": [ { "key": "X-API-Key", "value": "{{api_key}}", "type": "text" } ], "url": { "raw": "{{base_url}}/v1/campaigns/agents", "host": ["{{base_url}}"], "path": ["v1", "campaigns", "agents"] }, "description": "Retrieve all workspace agents with their detailed information including variables" } }, { "name": "2. Trigger Single Call", "request": { "method": "POST", "header": [ { "key": "X-API-Key", "value": "{{api_key}}", "type": "text" }, { "key": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "{\n \"agent_id\": \"agent_123e4567-e89b-12d3-a456-426614174000\",\n \"to_phone_number\": \"+1234567890\",\n \"variable_fields\": {\n \"first_name\": \"John\",\n \"company\": \"Acme Inc.\"\n }\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/v1/campaigns/trigger/single", "host": ["{{base_url}}"], "path": ["v1", "campaigns", "trigger", "single"] }, "description": "Trigger a single AI call for testing purposes" } }, { "name": "3. Get Call Status", "request": { "method": "GET", "header": [ { "key": "X-API-Key", "value": "{{api_key}}", "type": "text" } ], "url": { "raw": "{{base_url}}/v1/campaigns/trigger/status/{{call_id}}", "host": ["{{base_url}}"], "path": ["v1", "campaigns", "trigger", "status", "{{call_id}}"], "variable": [ { "key": "call_id", "value": "call_123e4567-e89b-12d3-a456-426614174000", "description": "The ID of the call to get status for" } ] }, "description": "Get the status of a specific call by its call_id" } } ] } ``` ## **πŸ“– How to Import to Postman** [#-how-to-import-to-postman] ### **Step-by-Step Instructions:** [#step-by-step-instructions] 1. **Download or copy** the collection JSON above 2. **Open Postman** application on your computer 3. **Click "Import"** button (located in the top left corner) 4. **Select the JSON file** you downloaded, or paste the JSON content directly 5. **Click "Import"** to add the collection to your workspace 6. **Update API Key**: * Go to the collection variables tab * Replace `YOUR_API_KEY` with your actual RevRag API key 7. **Select environment**: Make sure the staging environment variables are active 8. **Start testing** all 3 APIs instantly! πŸŽ‰ ### **Environment Setup:** [#environment-setup] The collection includes these environment variables: * `{{base_url}}`: Set to `https://staging-api.revrag.ai` for staging environment * `{{api_key}}`: Replace with your actual API key * `{{call_id}}`: Used for testing the Get Call Status endpoint ## **βœ… What's Included** [#-whats-included] * βœ… **All 3 Single Trigger APIs** pre-configured with proper endpoints * βœ… **Environment variables** for easy base URL and API key management * βœ… **Sample request bodies** with realistic test data and proper formatting * βœ… **Proper headers** including `X-API-Key` authentication and content types * βœ… **Path variables** configured for dynamic call ID usage * βœ… **Descriptions** for each API endpoint explaining their purpose * βœ… **Ready for staging environment** pointing to `staging-api.revrag.ai` ## **🎯 API Endpoints Included** [#-api-endpoints-included] ### **1. Get All Agents** [#1-get-all-agents] * **Method**: GET * **Endpoint**: `/v1/campaigns/agents` * **Purpose**: Retrieve all workspace agents with variables * **Headers**: X-API-Key authentication ### **2. Trigger Single Call** [#2-trigger-single-call] * **Method**: POST * **Endpoint**: `/v1/campaigns/trigger/single` * **Purpose**: Trigger a single AI call for testing * **Headers**: X-API-Key authentication, Content-Type: application/json * **Body**: JSON with agent\_id, phone number, and variable fields ### **3. Get Call Status** [#3-get-call-status] * **Method**: GET * **Endpoint**: `/v1/campaigns/trigger/status/{call_id}` * **Purpose**: Get detailed status of a specific call * **Headers**: X-API-Key authentication * **Variables**: Dynamic call\_id from previous responses ## **πŸ’‘ Testing Workflow** [#-testing-workflow] ### **Recommended Testing Order:** [#recommended-testing-order] 1. **Start with "Get All Agents"** * Verify your API key works * Get available agent IDs for testing * Copy an agent ID for the next step 2. **Use "Trigger Single Call"** * Update the `agent_id` with a real agent ID from step 1 * Modify phone number if needed (for testing) * Execute the call and save the returned `call_id` 3. **Check "Get Call Status"** * Replace the `call_id` variable with the actual ID from step 2 * Monitor the call status and get detailed information ### **Pro Tips:** [#pro-tips] * **Save responses**: Copy important values like `call_id` from responses for use in subsequent requests * **Environment switching**: Create separate environments for staging vs production testing * **Variables usage**: Leverage Postman variables to automatically pass data between requests * **Collection runner**: Use Postman's collection runner to test all APIs in sequence * **Test scripts**: Add test scripts to automatically validate responses and extract values ## **πŸ”§ Troubleshooting** [#-troubleshooting] ### **Common Issues:** [#common-issues] **❌ 401 Unauthorized** * Check that your API key is correctly set in the collection variables * Ensure the API key has proper permissions for the endpoints **❌ 404 Not Found** * Verify the base URL is correct (`https://staging-api.revrag.ai`) * Check that endpoint paths match the API documentation **❌ 422 Validation Error** * Ensure request body format matches the expected schema * Validate phone number format and agent ID values ### **Getting Help:** [#getting-help] If you encounter issues with the Postman collection: 1. Check the [API Documentation](./call-trigger-apis) for detailed endpoint specifications 2. Verify your API key permissions and validity 3. Contact support at **[contact@revrag.ai](mailto:contact@revrag.ai)** for technical assistance *** **Ready to start testing?** Download the collection and import it to Postman to begin exploring the RevRag AI Voice Calling APIs! πŸš€ --- # Webhook Security > Learn how to securely receive and verify webhook notifications from RevRag.ai URL: /api-reference/webhook-security Markdown: /api-reference/webhook-security.md ## Overview [#overview] RevRag.ai sends webhook notifications to your configured endpoint when specific events occur (such as when a call ends). To ensure the security and authenticity of these webhooks, we implement cryptographic signature verification that allows you to confirm that requests are genuinely from RevRag.ai. ## Authentication Method [#authentication-method] We use **HMAC-based authentication** to secure webhook notifications. This method uses a shared secret to cryptographically sign webhook payloads, providing strong security guarantees for verifying the authenticity and integrity of each request. ## Configuration [#configuration] To set up webhooks, you'll need to configure: * **Webhook URL**: The HTTPS endpoint where you'll receive webhook notifications (HTTP is not supported for security reasons) * **Secret Key**: A secure shared secret used for HMAC signature verification * **Event Types**: Select which events you want to receive (currently supported: `call.ended`) Contact our support team at **[contact@revrag.ai](mailto:contact@revrag.ai)** to configure your webhook settings. ## Webhook Headers [#webhook-headers] Every webhook request includes the following headers: | Header | Purpose | Example | | --------------------- | ----------------------------------------------- | --------------------------- | | `Content-Type` | Ensures receiver parses payload correctly | `application/json` | | `X-Webhook-Event` | Identifies the event type | `call.ended` | | `X-Webhook-ID` | Unique identifier for deduplication and logging | `evt_01HC3Q0MZQABR3...` | | `X-Webhook-Timestamp` | Unix timestamp when the webhook was sent | `1698064496` | | `X-Webhook-Signature` | HMAC signature for verifying authenticity | `t=1698064496,v1=abc123...` | ## Webhook Payload [#webhook-payload] The webhook payload contains the same data structure as described in the [Webhook Notifications section](/api-reference/call-trigger-apis#4-webhook-notifications-optional) of the API documentation. For complete payload details and field descriptions, please refer to that section. ## Signature Verification [#signature-verification] ### How It Works [#how-it-works] We use HMAC-SHA256 to sign every webhook request. The signature is computed over a string consisting of: ``` timestamp + "." + raw_request_body ``` Where: * `timestamp` is the Unix timestamp (in seconds) from the `X-Webhook-Timestamp` header * `raw_request_body` is the exact byte sequence of the JSON payload (before parsing) ### Signature Format [#signature-format] The `X-Webhook-Signature` header contains two components: ``` t=,v1= ``` * `t`: The timestamp when the signature was generated * `v1`: The HMAC-SHA256 signature in hexadecimal format ### Verification Steps [#verification-steps] To verify a webhook request is authentic, follow these steps: 1. **Extract the timestamp and signature** from the `X-Webhook-Signature` header 2. **Verify the timestamp** is within an acceptable time window (recommended: Β±5 minutes) to prevent replay attacks 3. **Reconstruct the signed string** by concatenating the timestamp, a period, and the raw request body 4. **Compute the HMAC-SHA256** using your shared secret 5. **Compare signatures** using a constant-time comparison function to prevent timing attacks 6. **Check the X-Webhook-ID** (optional but recommended) to prevent duplicate processing ### Implementation Examples [#implementation-examples] #### Python [#python] ```python import hmac import hashlib import time from flask import request, abort WEBHOOK_SECRET = "your_32_byte_secret_key" # 256-bit secret TOLERANCE_SECONDS = 300 # 5 minutes def verify_webhook(): # Get the raw request body raw_body = request.get_data() # Parse the signature header signature_header = request.headers.get('X-Webhook-Signature', '') parts = dict(item.split('=') for item in signature_header.split(',')) timestamp_str = parts.get('t') received_signature = parts.get('v1') if not timestamp_str or not received_signature: abort(401, "Missing signature components") # Verify timestamp to prevent replay attacks timestamp = int(timestamp_str) current_time = int(time.time()) if abs(current_time - timestamp) > TOLERANCE_SECONDS: abort(401, "Timestamp outside tolerance window") # Reconstruct the signed payload signed_payload = f"{timestamp_str}.{raw_body.decode('utf-8')}" # Compute the expected signature expected_signature = hmac.new( WEBHOOK_SECRET.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Constant-time comparison to prevent timing attacks if not hmac.compare_digest(expected_signature, received_signature): abort(401, "Invalid signature") # Optional: Check for duplicate webhook IDs webhook_id = request.headers.get('X-Webhook-ID') # Store and check webhook_id in your database to prevent duplicates return True ``` #### Node.js (Express) [#nodejs-express] ```javascript const crypto = require('crypto'); const express = require('express'); const WEBHOOK_SECRET = 'your_32_byte_secret_key'; // 256-bit secret const TOLERANCE_SECONDS = 300; // 5 minutes function verifyWebhook(req, res, next) { // Get raw body (ensure you're using express.raw() middleware) const rawBody = req.body; // Parse the signature header const signatureHeader = req.headers['x-webhook-signature'] || ''; const parts = {}; signatureHeader.split(',').forEach(part => { const [key, value] = part.split('='); parts[key] = value; }); const timestampStr = parts.t; const receivedSignature = parts.v1; if (!timestampStr || !receivedSignature) { return res.status(401).send('Missing signature components'); } // Verify timestamp to prevent replay attacks const timestamp = parseInt(timestampStr, 10); const currentTime = Math.floor(Date.now() / 1000); if (Math.abs(currentTime - timestamp) > TOLERANCE_SECONDS) { return res.status(401).send('Timestamp outside tolerance window'); } // Reconstruct the signed payload const signedPayload = `${timestampStr}.${rawBody.toString('utf-8')}`; // Compute the expected signature const expectedSignature = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(signedPayload) .digest('hex'); // Constant-time comparison to prevent timing attacks if (!crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(receivedSignature) )) { return res.status(401).send('Invalid signature'); } // Optional: Check for duplicate webhook IDs const webhookId = req.headers['x-webhook-id']; // Store and check webhookId in your database to prevent duplicates next(); } // Use with express.raw() to preserve the raw body app.use('/webhooks', express.raw({ type: 'application/json' })); app.post('/webhooks', verifyWebhook, (req, res) => { // Process the webhook const payload = JSON.parse(req.body.toString()); // ... handle the webhook event res.sendStatus(200); }); ``` ## Security Best Practices [#security-best-practices] ### 1. Use HTTPS Only [#1-use-https-only] Always use HTTPS endpoints for webhooks. We reject HTTP URLs to prevent man-in-the-middle attacks. ### 2. Validate the Timestamp [#2-validate-the-timestamp] Always verify the timestamp is within an acceptable window (recommended: Β±5 minutes). This prevents replay attacks where an attacker attempts to resend a captured webhook. ### 3. Use Constant-Time Comparison [#3-use-constant-time-comparison] Always use constant-time comparison functions (like `hmac.compare_digest` in Python or `crypto.timingSafeEqual` in Node.js) when comparing signatures. This prevents timing attacks. ### 4. Store the Secret Securely [#4-store-the-secret-securely] * Generate a cryptographically secure random secret (32 bytes / 256 bits) * Store it encrypted using a key management service (KMS, HashiCorp Vault, etc.) * Never commit secrets to version control * Rotate secrets periodically ### 5. Implement Idempotency [#5-implement-idempotency] Use the `X-Webhook-ID` header to track which webhooks you've already processed. Store this ID in your database and reject duplicate deliveries. ### 6. Verify the Raw Body [#6-verify-the-raw-body] The signature is computed over the **raw request bytes**, not the parsed JSON. Make sure your verification code uses the exact body bytes received, before any parsing or transformation. ### 7. Return Appropriate Status Codes [#7-return-appropriate-status-codes] * Return `200` or `204` for successful processing * Return `401` for signature verification failures * Return `400` for malformed requests * Return `500` for internal server errors We will retry failed webhook deliveries with exponential backoff for status codes in the 5xx range. ## Troubleshooting [#troubleshooting] ### Signature Verification Fails [#signature-verification-fails] **Common causes:** * Using parsed JSON instead of raw request body for verification * Incorrect timestamp extraction or format * Secret key mismatch * Using string concatenation instead of proper byte operations * Not using UTF-8 encoding consistently **Solution:** * Ensure you're computing the HMAC over the exact raw bytes received * Verify your secret key matches what was configured * Check that you're extracting the timestamp correctly from the header * Use logging to compare your computed signature with the received signature ### Timestamp Out of Range [#timestamp-out-of-range] **Common causes:** * Server clock drift * Processing delays * Timezone issues **Solution:** * Synchronize your server clock using NTP * Use Unix timestamps (seconds since epoch) in UTC * Adjust your tolerance window if needed (but don't exceed 10 minutes) ### Duplicate Webhooks [#duplicate-webhooks] **Common causes:** * Network issues causing retries * Not implementing idempotency checks **Solution:** * Track `X-Webhook-ID` values in your database * Implement idempotent webhook handlers that can safely process the same event multiple times ## Testing Webhooks [#testing-webhooks] When testing webhook integration, you can use tools like: * **ngrok** or **localtunnel** to expose your local server to receive webhooks * **Request logging** to inspect the exact headers and body received * **Manual signature generation** to test your verification logic Contact support at **[contact@revrag.ai](mailto:contact@revrag.ai)** for assistance with testing webhooks in the staging environment. ## Support [#support] If you encounter any issues with webhook setup or signature verification, please contact our support team: **Email**: [contact@revrag.ai](mailto:contact@revrag.ai) --- # In-App agent > Learn how to integrate RevRag's In-App agent into your applications URL: /embed/embedded-agent-integration Markdown: /embed/embedded-agent-integration.md ## Quick Start [#quick-start] 1. **Choose your platform** from our supported integrations 2. **Install the SDK** for your selected platform 3. **Configure your API key** and initialize the agent 4. **Customize the interface** to match your brand ## Platform Support [#platform-support] * βœ… **React (Web)**: Full support with comprehensive documentation * βœ… **React Native**: Full support with comprehensive documentation * βœ… **Flutter**: Full support with comprehensive documentation * βœ…**Android Native**: Full support with comprehensive documentation * 🚧 **NodeJS**: Coming soon * 🚧 **iOS Native**: Coming soon ## Ready to Get Started? [#ready-to-get-started] {/*
πŸ“š
*/} Explore detailed documentation and features of the In-App agent
{/*
πŸš€
*/} Jump into Platform-specific integration guides and start building
*** **Need help?** Visit our [website](https://revrag.ai) or check out the detailed guides in the In-App agent tab. --- # Android Native > Android SDK Integration Guide - Voice-enabled AI agent with real-time communication capabilities (Kotlin, XML & Jetpack Compose) URL: /embed/integration/android Markdown: /embed/integration/android.md # Android Integration Guide [#android-integration-guide] The complete guide for adding the Revrag AI voice agent to an Android app. | | | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **What you get** | One floating button that follows your user across every screen, keeps its position, and keeps a live call running while they navigate | | **Current version** | `2.0.0` | | **minSdk** | 24 | | **JDK** | 17 | | **Language** | Kotlin 2.0+ | | **API key** | Get it from [https://app.revrag.ai](https://app.revrag.ai) | **Every app integrates with the same three calls** β€” XML or Compose, one Activity or fifty: | Step | Call | Where | Details | | ----------------- | ---------------------------------------- | ---------------------------------------------------- | ----------------------------------------- | | 1. **Initialize** | `EmbedSDK.initialize` | Once, in `Application.onCreate` β€” not in an Activity | [Β§2](#2-initialize) | | 2. **Mount** | `EmbedProvider.attachOverlay` | Safe from every Activity's `onResume` | [Β§3](#3-mount) | | 3. **Identify** | `EmbedSDK.event(EventKeys.USER_DATA, …)` | After login, before the first call | [Identify your user](#identify-your-user) | **[Section 5](#5-your-apps-shape) shows the exact setup for your app.** Everything before it applies to every app. *** ## Table of contents [#table-of-contents] 1. [Install](#1-install) β€” dependency and permissions 2. [Initialize](#2-initialize) β€” one call, in `Application` 3. [Mount](#3-mount) β€” one call, and the SDK follows the user 4. [Decide where it appears](#4-decide-where-it-appears) β€” the visibility config 5. [Your app's shape](#5-your-apps-shape) β€” **pick one**, complete code 6. [Dialogs and bottom sheets](#6-dialogs-and-bottom-sheets) Β· [Position the button](#6b-position-the-button) Β· [Control it from your code](#6c-control-it-from-your-own-code) Β· [Events and analytics](#6d-events-and-analytics) Β· [A drop-in event logger](#6e-a-drop-in-event-logger) 7. [How screens get their names](#7-how-screens-get-their-names) 8. [Verify it works](#8-verify-it-works) 9. [Troubleshooting](#9-troubleshooting) 10. [Checklist](#10-checklist) 11. [Upgrading](#11-upgrading) *** ## 1. Install [#1-install] ### Dependency [#dependency] ```kotlin // settings.gradle.kts dependencyResolutionManagement { repositories { google() mavenCentral() // hosts ai.revrag:embed-android maven("https://jitpack.io") // required β€” see below } } // app/build.gradle.kts dependencies { implementation("ai.revrag:embed-android:2.0.0") } ``` **Add the JitPack repository.** The SDK's voice transport (`io.livekit:livekit-android`) depends on `com.github.davidliu:audioswitch`, which is published only on JitPack. Without it, Gradle sync fails with `Could not find com.github.davidliu:audioswitch`. No Compose toolchain is required. If your app is pure XML you do not need the Compose compiler plugin, `buildFeatures.compose`, or any Compose dependency β€” the SDK brings its own UI. ### Permissions β€” do not skip this [#permissions--do-not-skip-this] **The SDK ships no permissions of its own.** Nothing is merged into your manifest, by design: an SDK should not silently add a microphone permission to your app. You declare them: ```xml ``` Without `RECORD_AUDIO` the widget appears and the call fails to start. The SDK requests the runtime permission itself when the user first starts a call β€” you do not need to write a permission flow. No Bluetooth permission is needed: headset routing is OS-managed, and call audio goes to a connected headset automatically. ### Requirements [#requirements] | | | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `minSdk` | 24 (Android 7.0) | | `compileSdk` | 34 or newer | | JDK | 17 | | Kotlin | **2.0 or newer** β€” the SDK is compiled with 2.0.21 and its classes carry Kotlin metadata 2.0, which a 1.9.x compiler refuses to read ([section 9](#9-troubleshooting)) | | Activity type | `ComponentActivity` or any subclass β€” `AppCompatActivity` qualifies | | API key | from Revrag, per environment | **What it pulls in.** LiveKit (WebRTC), Lottie, Coil and Kotlin Coroutines arrive transitively. **Ktor is shaded** into `ai.revrag.shaded.ktor.*` and does not appear in your dependency tree, so it cannot clash with your own Ktor version. No ProGuard/R8 rules of your own are needed β€” the AAR ships what R8 needs. **Your API key and environment are a pair.** A widget is provisioned per key per environment. A key from the dev backend returns no widget on production and the button will never appear β€” with no error, because "this key has no widget here" is a valid answer. If the button never shows, [section 9](#9-troubleshooting) starts here. *** ## 2. Initialize [#2-initialize] Once, in `Application.onCreate` β€” not in an Activity. Initializing in an Activity means screens shown before it can never display the agent. ```kotlin class MyApp : Application() { override fun onCreate() { super.onCreate() // Third argument is embedUrl β€” omit it for production, or pass your // environment's URL. It must match the environment your key belongs to. EmbedSDK.initialize(this, "YOUR_API_KEY") { result -> if (!result.success) Log.e("Embed", "init failed: ${result.error}") } } } ``` Register it in the manifest: ```xml ``` ### Optional parameters [#optional-parameters] | Parameter | Default | Meaning | | --------------------- | ------------------ | ------------------------------------------------------------------------------------------- | | `embedUrl` | production | Backend URL. Must match the environment your key belongs to. | | `autoTrackActivities` | `true` | The widget follows whichever Activity is resumed, and every Activity reports a screen name. | | `appVersion` | your `versionName` | **New in 1.2.0.** Overrides the app version the SDK reports. | ```kotlin EmbedSDK.initialize(context = this, apiKey = "YOUR_API_KEY", appVersion = "2.4.0") { result -> } ``` Use named arguments when you pass any of these. ### Identify your user [#identify-your-user] ```kotlin EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123")) ``` Import only from `ai.revrag.embed.android.*`. Never import `ai.revrag.embed.core.*` β€” those are internal packages and can move between releases. *** ## 3. Mount [#3-mount] `EmbedProvider.attachOverlay` is the only integration call, and it is safe from every Activity's `onResume`: * the **first** call mounts the widget; * a call from a **new** Activity moves the same widget into that window β€” position, expanded card and live call intact; * a repeat call in the same window just updates the screen name. The SDK registers its own Activity observer, so it moves the widget and names screens on its own. The one thing it deliberately will **not** do is the *first* mount β€” mounting carries your `appUserId` and `visibilityConfig`, and doing it for you would use neither. ```kotlin // In MyApp.onCreate(), alongside initialize() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { EmbedProvider.attachOverlay( activity = activity as? ComponentActivity ?: return, appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY // section 4 ) } // all other overrides empty }) ``` Attaching from every resume is simply the cheapest way to say "whichever Activity is first" β€” the second call onward costs nothing. **Do not** detach in `onPause` or `onDestroy`. The incoming Activity claims the widget before the outgoing one dies, and the SDK owns the hand-off. Teardown code is the most common cause of a widget that vanishes mid-flow. `attachOverlay` must run **after** `setContentView()`. `onResume` always satisfies this. ### Full parameter list [#full-parameter-list] ```kotlin val handle: EmbedOverlayHandle = EmbedProvider.attachOverlay( activity = this, // ComponentActivity β€” required appUserId = "user-123", // your stable user id visibilityConfig = EMBED_VISIBILITY, // where the widget may appear navController = null, // pass it if this Activity has one currentScreen = null, // an explicit name, if you want one accentColor = 0xFF6C63FF.toInt(), // @ColorInt chatPanelUrl = null // your own chat page, optional ) ``` All parameters except `activity` have defaults. **Use named arguments** β€” the list grows over time and positional calls are how integrations break on upgrade. The returned `EmbedOverlayHandle` is how you name screens yourself: `setCurrentScreen(name)` for a whole screen, and `setSubScreen(name)` for pages inside one ([5G](#5g--webview-with-several-pages-on-one-screen)). *** ## 4. Decide where it appears [#4-decide-where-it-appears] You do not name your screens; you *decide about* them. Names come from your Activity or destination automatically ([section 7](#7-how-screens-get-their-names)). ```kotlin val EMBED_VISIBILITY = EmbedButtonVisibilityConfig( // Where the widget MAY appear. Anywhere else it is hidden β€” and a live call // ends, because leaving the allowlist is leaving the agent. allowedScreens = listOf("LoginActivity", "Home", "Plans"), // Where it must NOT appear. Use this rather than "skip the attach there": // it also ends an active call, which skipping does not. excludedScreens = listOf("SplashActivity"), groups = listOf( EmbedButtonGroupConfig( id = "main_tabs", screens = listOf("Home", "Plans"), // CONTINUOUS = one journey: the widget stays up across these screens // and a voice call SURVIVES every move inside the group. continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, // entrance delay delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ) ) ) ``` ### What this buys you, with no further code [#what-this-buys-you-with-no-further-code] | User does | Widget does | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Moves between screens in one CONTINUOUS group | Stays visible β€” no blink, no re-delay; a live call keeps running | | Moves between `setSubScreen` pages of one screen | Stays visible β€” no blink; a live call keeps running | | Crosses into another group, or between two standalone `allowedScreens` | One clean entrance with that screen's delay; the previous call ends by design | | Opens a screen not in `allowedScreens` | Hides, and an active call ends | | Opens a screen in `excludedScreens` | Hides, and an active call ends | | Rotates, or the system recreates an Activity | Reappears immediately β€” delays are never replayed | | Backgrounds and returns | Same state, same position | **A call can only run where the widget shows.** `EmbedSDK.startCall()` on a screen where the widget is hidden is refused. Set `endCallWhenHiddenByVisibility = false` only if you need a call to continue off allowed screens. ### All options [#all-options] **`EmbedButtonVisibilityConfig`** | Field | Default | Meaning | | ------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `allowedScreens` | `emptyList()` | Screens where the widget may appear. **Empty means everywhere.** | | `excludedScreens` | `emptyList()` | Screens where it never appears. Wins over `allowedScreens`. | | `showDelay` | `0L` | Default entrance delay in ms, when a group does not set one. | | `groups` | `emptyList()` | Journeys β€” see below. | | `defaultInset` | SDK default | Starting position of the button, in dp from each edge ([section 6B](#6b-position-the-button)). | | `endCallWhenHiddenByVisibility` | `true` | End a live call when the widget hides, and refuse `startCall` where it is hidden. Leave on unless you know why not. | **`EmbedButtonGroupConfig`** | Field | Default | Meaning | | ------------- | ------------ | ----------------------------------------------------------------------------------- | | `id` | β€” | Any stable string. | | `screens` | β€” | The screens in this journey. | | `continuity` | `PER_SCREEN` | `CONTINUOUS` keeps the widget and the call alive across the group. | | `inset` | `null` | Position override for this group's screens ([section 6B](#6b-position-the-button)). | | `delayMs` | `0L` | Entrance delay for this group. | | `delayPolicy` | `PER_SCREEN` | `PER_SCREEN`, `ONCE_PER_GROUP_ENTRY`, or `ONCE_PER_APP_SESSION`. | An `allowedScreens` entry that no screen ever reports does not crash β€” it silently hides the widget. [Section 8](#8-verify-it-works) shows how to see the name the SDK actually has. *** ## 5. Your app's shape [#5-your-apps-shape] Find your app below. Each is complete and copy-pasteable. | Your app | Go to | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | XML, one Activity per screen | [5A](#5a--xml-one-activity-per-screen) | | XML, one Activity hosting fragments / tabs | [5B](#5b--xml-one-activity-many-screens) | | XML, several Activities that each host several screens | [5C](#5c--xml-several-activities-each-with-several-screens) | | Jetpack Compose, single Activity + NavController | [5D](#5d--jetpack-compose-single-activity) | | Jetpack Compose, several Activities | [5E](#5e--jetpack-compose-several-activities) | | Bottom tabs with one NavHost per tab | [5F](#5f--bottom-tabs-with-one-navhost-per-tab) | | A WebView with several pages on one screen (KYC, DigiLocker, eSign) | [5G](#5g--webview-with-several-pages-on-one-screen) β€” combine with any of the above | *** ### 5A β€” XML, one Activity per screen [#5a--xml-one-activity-per-screen] The classic case. **Your Activities need zero Embed code**; the whole integration is one file. ```kotlin // MyApp.kt β€” the entire integration package com.example.bank import ai.revrag.embed.android.* import android.app.Activity import android.app.Application import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity class MyApp : Application() { override fun onCreate() { super.onCreate() // 1) Initialize once, before any Activity. EmbedSDK.initialize(this, "YOUR_API_KEY") { result -> if (!result.success) Log.e("Embed", "init failed: ${result.error}") } EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123")) // 2) Mount. The SDK follows the user from here on and names every // screen after its Activity class. registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { EmbedProvider.attachOverlay( activity = activity as? ComponentActivity ?: return, appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY ) } override fun onActivityCreated(a: Activity, b: Bundle?) = Unit override fun onActivityStarted(a: Activity) = Unit override fun onActivityPaused(a: Activity) = Unit override fun onActivityStopped(a: Activity) = Unit override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit override fun onActivityDestroyed(a: Activity) = Unit }) } companion object { // 3) Screen names ARE your Activity class names. val EMBED_VISIBILITY = EmbedButtonVisibilityConfig( allowedScreens = listOf( "LoginActivity", "HomeActivity", "PlansActivity" ), excludedScreens = listOf("SplashActivity"), groups = listOf( EmbedButtonGroupConfig( id = "main", screens = listOf("HomeActivity", "PlansActivity"), continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ) ) ) } } ``` Your Activities stay exactly what they are: ```kotlin class HomeActivity : AppCompatActivity() { // zero Embed code override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_home) } } ``` *** ### 5B β€” XML, one Activity, many screens [#5b--xml-one-activity-many-screens] One Activity swapping fragments or nav-graph destinations. Hand the SDK your `NavController` **once** and each destination names itself from its `android:label`. ```kotlin class MainActivity : AppCompatActivity() { private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // FragmentContainerView-safe lookup β€” findNavController() from onCreate // is the classic crash. val navHost = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHost.navController // Re-attach on each destination change: same window, so this is just a // screen update. No name is passed β€” the controller is authoritative. navController.addOnDestinationChangedListener { _, _, _ -> EmbedProvider.attachOverlay( activity = this, appUserId = "user-123", visibilityConfig = MyApp.EMBED_VISIBILITY, navController = navController ) } } } ``` Your nav graph supplies the names: ```xml ``` ```kotlin allowedScreens = listOf("Home", "Plans") // Account omitted β†’ hidden there ``` **No NavController?** If you swap fragments manually, tell the SDK yourself: ```kotlin EmbedProvider.attachOverlay( activity = this, appUserId = "user-123", visibilityConfig = MyApp.EMBED_VISIBILITY, currentScreen = "Home" ) ``` Naming even one screen by hand takes ownership: automatic fragment naming stands down for the whole session. If you start naming, name them all. *** ### 5C β€” XML, several Activities each with several screens [#5c--xml-several-activities-each-with-several-screens] Combine 5A and 5B: mount from `Application` for the Activities that are whole screens, and hand over the `NavController` from the Activity that has one. ```kotlin // MyApp.kt object EmbedIntegration { fun attach(activity: Activity) { val host = activity as? ComponentActivity ?: return EmbedProvider.attachOverlay( activity = host, appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY, // Only the tabbed Activity has one. Everything else passes null, // which hands naming back to the SDK. navController = (activity as? MainActivity)?.embedNavController, currentScreen = null ) } val EMBED_VISIBILITY = EmbedButtonVisibilityConfig( allowedScreens = listOf( "LoginActivity", "VerifyPanActivity", // Activity class names "Home", "Plans" // nav-graph labels ), excludedScreens = listOf("SplashActivity"), groups = listOf( EmbedButtonGroupConfig( id = "onboarding", screens = listOf("LoginActivity", "VerifyPanActivity"), continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ), EmbedButtonGroupConfig( id = "main_tabs", screens = listOf("Home", "Plans"), continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ) ) ) } ``` ```kotlin // MyApp.onCreate β€” mount from every resume registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) = EmbedIntegration.attach(activity) /* other overrides empty */ }) ``` ```kotlin // MainActivity β€” expose the controller and re-attach on destination change class MainActivity : AppCompatActivity() { private lateinit var navController: NavController val embedNavController: NavController? get() = if (::navController.isInitialized) navController else null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navController = (supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController navController.addOnDestinationChangedListener { _, _, _ -> EmbedIntegration.attach(this) } } } ``` Naming ownership follows whoever holds the widget: it moves to the controller on the way into the tabs, and back to the SDK on the way out. Working example: `examples/android-xml` in the [SDK repository](https://github.com/revrag-ai/embed-android). *** ### 5D β€” Jetpack Compose, single Activity [#5d--jetpack-compose-single-activity] Wrap your content in `EmbedProviderComposable` and pass your `NavController`. ```kotlin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() EmbedProviderComposable( currentScreen = backStack?.destination?.route ?: "Home", appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY, navController = navController ) { NavHost(navController, startDestination = "home") { composable("home") { HomeScreen() } composable("plans") { PlansScreen() } } } } } } ``` Screen names are your **routes** (`"home"`, `"plans"`), so spell `allowedScreens` the same way: ```kotlin allowedScreens = listOf("home", "plans") ``` **Smarter name matching (optional, recommended for tabs and nested screens).** By default only a screen's primary name matches. Turn on alias, ancestor and fingerprint matching so `"details"` also matches `"details/{id}"`, tabs inherit their parent's allow, and Compose state tabs and pagers get names of their own: ```kotlin EmbedSDK.setScreenMatching(EmbedScreenMatching.ALL) // any time; survives config refreshes ``` Your setting wins over the tenant's dashboard flags; `setScreenMatching(null)` hands control back to the dashboard. **No NavController?** Drive it from your own state β€” any string works: ```kotlin var screen by remember { mutableStateOf("home") } EmbedProviderComposable(currentScreen = screen, ... ) { /* content */ } ``` `EmbedProviderComposable` cannot use `setSubScreen` ([5G](#5g--webview-with-several-pages-on-one-screen)) or move the widget into dialogs ([section 6](#6-dialogs-and-bottom-sheets)). If you need either, mount with `attachOverlay` as in [5E](#5e--jetpack-compose-several-activities) β€” it works for single-Activity Compose apps too. *** ### 5E β€” Jetpack Compose, several Activities [#5e--jetpack-compose-several-activities] Use the XML mount (`EmbedProvider.attachOverlay`) from `Application`, exactly as in [5A](#5a--xml-one-activity-per-screen) β€” it works for Compose Activities too, and one mount serves all of them. Then, inside any Activity that has its own `NavController`, hand it over as in 5C. ```kotlin // Application β€” mount once, SDK follows override fun onActivityResumed(activity: Activity) { EmbedProvider.attachOverlay( activity = activity as? ComponentActivity ?: return, appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY, navController = (activity as? MainActivity)?.embedNavController ) } ``` Do **not** also wrap that Activity's content in `EmbedProviderComposable` β€” that would mount a second widget. Pick one mechanism per app. *** ### 5F β€” Bottom tabs with one NavHost per tab [#5f--bottom-tabs-with-one-navhost-per-tab] If each tab owns its own `NavController`, pass **the active one**. Re-attach when the tab changes as well as when a destination inside it changes: ```kotlin // whenever the selected tab OR its destination changes EmbedProvider.attachOverlay( activity = this, appUserId = "user-123", visibilityConfig = EMBED_VISIBILITY, navController = controllerForSelectedTab ) ``` Nested graphs need nothing extra β€” a nested destination reports its own label/route. *** ### 5G β€” WebView with several pages on one screen [#5g--webview-with-several-pages-on-one-screen] **New in 1.2.0.** For a WebView that moves through several pages on one screen or nav route β€” KYC, DigiLocker, eSign, a partner page. Each web page reaches the backend as its own screen, while the widget follows the screen that hosts the WebView. **1. List the WebView's screen** in `visibilityConfig`, the same way you list other screens. Don't list the web pages themselves: ```kotlin EmbedButtonVisibilityConfig( allowedScreens = listOf("home", "kyc/digilocker") // the route, not page names ) ``` **2. Name each web page with `setSubScreen`**, using the handle `attachOverlay` returns: ```kotlin webViewClient = object : WebViewClient() { override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { if (!isReload) embedHandle?.setSubScreen(pageNameFor(url)) } } ``` **3. Remove any `setCurrentScreen(...)` calls you added for web pages.** `setCurrentScreen` replaces the screen, so an unlisted page name hides the widget. What you get: * The widget shows on every page of an allowed screen. * Moving between web pages doesn't flicker the widget or end a live call. * Leaving the screen clears the page name automatically β€” there's nothing to reset. To return to the parent screen while staying on it, call `setSubScreen(null)`. * A page name that *is* listed in `allowedScreens`, `excludedScreens` or a group follows its own rule; an unlisted one follows its parent screen. **No tokens, session IDs, user IDs or personal data in page names** β€” the name is sent to the backend. Keep names stable: the same page should always get the same name. Derive them from the URL path, not the full URL with its query string. `setSubScreen` needs the `attachOverlay` mount. The agent cannot yet read or fill content inside a WebView. *** ## 6. Dialogs and bottom sheets [#6-dialogs-and-bottom-sheets] A dialog gets its own window, which always paints above the Activity's β€” so an untouched widget would be covered by it. Lift it in for as long as the dialog shows: ```kotlin // e.g. in DialogFragment.onStart() / BottomSheetDialogFragment.onStart() EmbedProvider.attachOverlay(dialog!!, requireActivity()) ``` The widget returns to the Activity automatically on every dismissal path β€” you do not write teardown. For a raw `Window` (a Compose `Dialog`, a `PopupWindow`): ```kotlin EmbedProvider.attachOverlay(window, activity) ``` This needs the `attachOverlay` mount; `EmbedProviderComposable` hosts have no widget view to move. The SDK also does this by itself for **full-screen** windows, such as bottom sheets and full-screen dialogs. It leaves **floating** dialogs alone, such as an `AlertDialog`, a default `DialogFragment` or a small loader, so they keep their size; the button stays underneath them (1.2.2). Calling `attachOverlay` yourself always works, and is the guaranteed path. To keep the button out of a dialog: ```kotlin EmbedProvider.excludeFromWidget(dialog) // one dialog (or a Compose Dialog's window) EmbedProvider.setAutoAttachDialogs(false) // no automatic adoption at all ``` *** ## 6B. Position the button [#6b-position-the-button] The widget is draggable, but you choose where it starts. Insets are in **dp** from each edge: ```kotlin EmbedButtonVisibilityConfig( defaultInset = EmbedButtonInset(right = 24, bottom = 80), groups = listOf( EmbedButtonGroupConfig( id = "checkout", screens = listOf("Cart", "Payment"), // lift it above this flow's sticky "Pay" bar inset = EmbedButtonInset(right = 24, bottom = 160) ) ) ) ``` `EmbedButtonInset(right, bottom, left, top)` β€” a group's `inset` overrides `defaultInset` for its screens. Use it wherever a bottom bar, FAB or sticky CTA would otherwise sit under the widget. *** ## 6C. Control it from your own code [#6c-control-it-from-your-own-code] The widget drives itself, but everything is available programmatically. ### Start and end calls [#start-and-end-calls] ```kotlin EmbedSDK.startCall(activity) // requests RECORD_AUDIO if needed EmbedSDK.endCall() EmbedSDK.isCallActive() // Boolean // with a completion callback EmbedSDK.startCall(activity) { started -> if (!started) { /* could not start β€” the reason is already reported */ } } ``` `startCall` is refused on a screen where the widget is hidden ([section 4](#4-decide-where-it-appears)). #### Who owns the UI β€” `agentTriggerMode` [#who-owns-the-ui--agenttriggermode] A call can run in one of two modes, and **this is the host's decision, not the backend's** β€” whether Revrag may draw its own screen over your app is your integration's call, so it lives in your code: ```kotlin EmbedSDK.startCall(activity, AgentTriggerMode.CO_PILOT) EmbedSDK.startCall(activity, AgentTriggerMode.WORKFLOW) { started -> } ``` | Mode | Who draws the UI | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CO_PILOT` | **Your app.** The agent works inside your own screens and dialogs; the SDK draws nothing but its button. The natural state is the expanded button with your app visible. | | `WORKFLOW` | **Revrag.** The agent drives a panel the SDK renders β€” the avatar screen with its Voice / Chat / Avatar tabs. | Three things worth knowing: * **It is passed at `startCall` only β€” never at `initialize`.** There is no app-level default to declare. * **A call the USER starts by tapping the button carries no mode**, because there is no host frame to supply one, so it takes the default below. Only calls *your code* starts can carry this value. If you need every call in one mode, start calls yourself rather than relying on the button. * **It does not turn video on.** Video is the backend's half β€” it needs `media_mode: "video"` in the widget config. Two declarations, from two places, because they answer different questions: *who owns the screen* and *what media the call uses*. **The default is `CO_PILOT`.** Omitting the argument, passing `null`, or letting the user start the call from the button all resolve to: | | | | ------------------ | ---------------------------------------------------------- | | `agentTriggerMode` | `CO_PILOT` β€” your app owns the UI | | `mediaMode` | `AUDIO`, unless the backend's widget config says otherwise | | avatar surface | full page, when the user opens the avatar | So the SDK never draws its own screen unless you ask for `WORKFLOW` or the user opens the avatar themselves. If co-pilot is what you want β€” and for most apps it is β€” you do not need to pass anything. Every call logs which mode it actually ran in (debug builds, [section 8](#8-verify-it-works)): ``` call mode LATCHED action=CO_PILOT media=AUDIO surface=FULL_PAGE video=false ``` One quirk worth knowing: co-pilot has no chat surface, so a co-pilot call whose backend config says `media_mode: "chat"` runs as audio and logs `media_mode=chat has no co-pilot surface β€” running as audio`. It downgrades rather than failing the call. ### The widget card [#the-widget-card] ```kotlin EmbedSDK.expandWidget() // open the card EmbedSDK.collapseWidget() // dismiss the card, keep the call EmbedSDK.isWidgetExpanded // StateFlow EmbedSDK.minimizeAvatar() // leave full-screen avatar mode EmbedSDK.isAvatarOpen // StateFlow ``` ### Send text to the agent [#send-text-to-the-agent] ```kotlin EmbedSDK.sendText("Show me my EMI schedule") ``` The text goes to the call that is already running; it does not start one. ### Know when a call starts and ends [#know-when-a-call-starts-and-ends] ```kotlin private val onConnected: AgentEventCallback = { /* … */ } EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED, onConnected) EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED) { data -> // data carries call duration } EmbedSDK.onAvatarState { isOpen -> /* full-screen avatar opened/closed */ } ``` Deregister with `offAgent(...)` / `offAvatarState(...)` when your listener's owner dies β€” a listener held by a destroyed screen is a leak. `AgentEvent`: `AGENT_CONNECTED`, `AGENT_DISCONNECTED`, `POPUP_MESSAGE_VISIBLE`. ### Readiness [#readiness] ```kotlin EmbedSDK.isInitialized() // Boolean, right now EmbedSDK.isInitializedFlow // StateFlow, to observe EmbedSDK.widgetConfig // StateFlow β€” non-null = can render ``` `isInitializedFlow` means "the SDK has credentials", which cannot fail. To know the **button can actually be drawn**, observe `widgetConfig` β€” see [section 9](#9-troubleshooting). ### On logout [#on-logout] ```kotlin EmbedSDK.clearStorageCache() ``` **Call this whenever the user signs out.** Without it, stored identity and conversation context carry into the next user's session on a shared device. *** ## 6D. Events and analytics [#6d-events-and-analytics] Everything the widget does is observable, and you can push your own context in. ### Sending events β€” `EmbedSDK.event(key, data)` [#sending-events--embedsdkeventkey-data] Four keys, each with a shape the backend expects. **`USER_DATA`** β€” who the user is. Send after login, before the first call. ```kotlin EmbedSDK.event( EventKeys.USER_DATA, mapOf( "app_user_id" to "user_123", // your stable id β€” the important one "name" to "Jane Doe", "email" to "jane@email.com" ) ) ``` **`SCREEN_STATE`** β€” manual screen tracking, for hosts with no NavController. This enriches *events*; it does not drive widget visibility (use `currentScreen` / `setCurrentScreen` / `setSubScreen` for that). ```kotlin EmbedSDK.event( EventKeys.SCREEN_STATE, mapOf("screen" to "ProductDetail", "action" to "enter") ) ``` **`ANALYTICS_DATA`** β€” analytics, keyed by `event_name`. This is also the key the SDK fires its own events on, which is what makes them observable. ```kotlin EmbedSDK.event( EventKeys.ANALYTICS_DATA, mapOf("event_name" to "checkout_started") ) ``` **`CUSTOM_EVENT`** β€” free-form host events. ### Listening β€” `on` / `off` [#listening--on--off] ```kotlin val cb: EventCallback = { data -> Log.d("Revrag", "data: $data") } EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) EmbedSDK.off(EventKeys.ANALYTICS_DATA, cb) // same instance, or it won't unhook ``` `off` matches on the **callback instance**. Hold it in a property β€” a lambda written inline at the `off` call site is a different object and removes nothing. From 1.2.0, an exception thrown inside your own listener is caught and isolated β€” it cannot crash the SDK or stop other listeners from running. ### Call lifecycle β€” `onAgent` / `offAgent` [#call-lifecycle--onagent--offagent] ```kotlin class MainActivity : AppCompatActivity() { private val onConnected: AgentEventCallback = { _ -> Log.d("Revrag", "agent call started") } private val onDisconnected: AgentEventCallback = { payload -> val duration = (payload["metadata"] as? Map<*, *>) ?.get("callDuration") as? Int ?: 0 Log.d("Revrag", "call lasted ${duration}s") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED, onConnected) EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected) } override fun onDestroy() { super.onDestroy() EmbedSDK.offAgent(AgentEvent.AGENT_CONNECTED, onConnected) EmbedSDK.offAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected) } } ``` `AgentEvent`: `AGENT_CONNECTED`, `AGENT_DISCONNECTED`, `POPUP_MESSAGE_VISIBLE`. This is the one place the "never write teardown" rule does not apply. The **widget** needs no teardown; a **listener** you registered does, or it holds a destroyed Activity for the life of the process. ### What the SDK fires on its own [#what-the-sdk-fires-on-its-own] All arrive on `ANALYTICS_DATA` with `event_name` set to one of: | `EmbedAnalyticsEvents.` | Fires when | | ----------------------------- | -------------------------------------- | | `AGENT_TAP_TO_OPEN` | the user taps the collapsed button | | `AGENT_TAP_TO_CLOSE` | the user collapses the card | | `AGENT_VISIBLE` | the button finishes fading in | | `AGENT_CONVERSATION_STARTED` | the call connects | | `AGENT_CONVERSATION_ENDED` | the call ends | | `AVATAR_MODE_OPENED` | the full-screen avatar opens | | `POPUP_MESSAGE_VISIBLE` | an inactivity nudge appears | | `GEN_TOOL_TRIGGERED` | the first data-channel message arrives | | `MICROPHONE_PERMISSION_ALLOW` | mic permission is granted | | `RAGE_CLICK` | rapid repeated taps are detected | | `FORM_EVENT` | a form interaction is tracked | | `ERROR` | the SDK hits an error | ```kotlin EmbedSDK.on(EventKeys.ANALYTICS_DATA) { data -> when (data["event_name"]) { EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> myAnalytics.track("call_start") EmbedAnalyticsEvents.ERROR -> Log.e("Revrag", "sdk error: $data") } } ``` Compare against the constants, never against string literals β€” the wire values are not part of the public contract. ### Taps on your app [#taps-on-your-app] **New in 1.2.3.** While the button is visible, the SDK also reports taps on your own app as `ANALYTICS_DATA` events. It covers both Compose and classic Views. The event name says what the tap did: | Control | `event_name` | `data.type` | | --------------------------------------------- | ----------------- | ----------------- | | Button, tab, icon button | `_pressed` | `button_click` | | Any other clickable element (card, row, link) | `_pressed` | `element_click` | | Checkbox, switch | `_toggled` | `checkbox_toggle` | | Radio button, selectable option | `_selected` | `radio_select` | | Dropdown | `_opened` | `button_click` | `` is the first of these that exists: 1. Your own identifier, used as written: a Compose `Modifier.testTag`, a View's `String` tag, or its `android:id` name. 2. The control's `contentDescription`, then its visible text, cleaned up: lowercase, at most four words, words with digits and currency words left out. "Pay β‚Ή5,000 now" becomes `pay_now`. 3. The label next to an unlabelled control on the same row, so a "Dark Mode" switch is `dark_mode`. 4. The control's role or class name. Text that contains an email, UPI id or masked number is never used, and password fields are never named by their text. To give a control a stable name, set an identifier: ```kotlin Button(onClick = ::apply, modifier = Modifier.testTag("apply_loan")) { Text("Apply now") } // event_name = "apply_loan_pressed" ``` Each event's `data` carries `element_id`, `type`, `kind`, `role`, `checked` (for checkboxes, switches and radios: the state after the tap), `source`, `click_sequence`, `component`, `x` and `y`. Not reported: taps that only focus a text field, taps on disabled controls, scrolls and drags, and taps on the SDK's own button. The backend switch `action_config.flags.clickTracking` turns these events off. **New in 2.0.0.** The agent's own presses are reported the same way, with `source: "agent"`: a tap, an option it picks, a checkbox or switch it changes. Your users' taps keep `source: "user"`, and one `click_sequence` counts both. Reads, highlights, typing and sliders are not reported. If a dashboard or rule counts these events as user activity, filter on `source`. Two controls that differ only by a number get the same name, because digits are left out. Give such controls a `testTag` to tell them apart. ### Context on every event [#context-on-every-event] ```kotlin EmbedSDK.setCurrentFlow("loan_application") // attached to every subsequent event EmbedSDK.setAppVersion("4.2.0") // only if yours differs from PackageInfo ``` A version passed to `initialize(appVersion = …)` takes priority over `setAppVersion`. ### Microphone permission, on demand [#microphone-permission-on-demand] ```kotlin EmbedSDK.checkPermissions(activity) { granted -> if (!granted) showYourOwnRationale() } ``` Optional β€” the SDK requests it at call start anyway. Use this to ask earlier, at a moment that makes sense in your flow. *** ## 6E. A drop-in event logger [#6e-a-drop-in-event-logger] Paste this once and every SDK event prints with a readable label. It is the fastest way to see the widget working, and the hook where you forward events to your own analytics. ```kotlin import ai.revrag.embed.android.EmbedAnalyticsEvents import ai.revrag.embed.android.EmbedSDK import ai.revrag.embed.android.EventCallback import ai.revrag.embed.android.EventKeys import android.util.Log object SdkLifecycleListener { private const val TAG = "SdkLifecycle" private var callback: EventCallback? = null /** * @param onEvent optional β€” forward every SDK event to your own analytics. */ fun register(onEvent: ((name: String, data: Map) -> Unit)? = null) { if (callback != null) return // idempotent val cb: EventCallback = { data -> val name = data["event_name"] as? String ?: "unknown" Log.d(TAG, "${label(name)} β†’ $data") onEvent?.invoke(name, data) } callback = cb EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) } fun unregister() { callback?.let { EmbedSDK.off(EventKeys.ANALYTICS_DATA, it) } callback = null } private fun label(name: String): String = when (name) { EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> "Widget expanded" EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE -> "Widget collapsed" EmbedAnalyticsEvents.AGENT_VISIBLE -> "Widget visible" EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> "Call started" EmbedAnalyticsEvents.AGENT_CONVERSATION_ENDED -> "Call ended" EmbedAnalyticsEvents.AVATAR_MODE_OPENED -> "Avatar opened" EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE -> "Popup shown" EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW -> "Mic permission granted" EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED -> "Tool triggered" EmbedAnalyticsEvents.RAGE_CLICK -> "Rage click" EmbedAnalyticsEvents.FORM_EVENT -> "Form event" EmbedAnalyticsEvents.ERROR -> "SDK error" else -> name } } ``` ```kotlin // Application.onCreate, after initialize SdkLifecycleListener.register() // or forward to your analytics SdkLifecycleListener.register { name, data -> myAnalytics.track(name, data) } // on logout / teardown SdkLifecycleListener.unregister() ``` Because it holds the callback instance itself, `unregister()` actually unhooks β€” the mistake this helper exists to prevent. *** ## 7. How screens get their names [#7-how-screens-get-their-names] Four sources, highest wins: | | Source | You write | | - | ------------------------------------------------------------------ | --------------------------------- | | 1 | `attachOverlay(currentScreen = …)` or `handle.setCurrentScreen(…)` | a name, if you want prettier ones | | 2 | `NavController` destination `android:label` or route | pass the controller once | | 3 | Fragment class name | nothing | | 4 | **Activity class name** | nothing β€” the floor, never absent | On top of any of these, `handle.setSubScreen(…)` names a page *inside* the current screen ([5G](#5g--webview-with-several-pages-on-one-screen)). The backend sees the page name; visibility follows the screen underneath, and the next screen change clears it. Two rules worth knowing: * **Naming one screen by hand takes ownership.** Rung 3 stands down for the rest of the session. If you start naming, name them all. (`setSubScreen` does not take ownership.) * **Names are matched exactly** and are case-sensitive. `"Home"` β‰  `"home"`. *** ## 8. Verify it works [#8-verify-it-works] From 1.2.0, the SDK logs its lifecycle when **your app is a debug build** β€” initialization, screen changes, visibility decisions and call steps. **Release builds print nothing.** The logs never include on-screen text or user data. Run a debug build with logcat filtered to `RevragEmbed`: ```bash adb logcat | grep RevragEmbed ``` Every visibility decision prints its inputs, so you can watch the config work screen by screen: ``` [Screen] SplashActivity#1 β†’ LoginActivity#2 [Visibility] 'SplashActivity' NOT allowed β€” hidden [Visibility] 'LoginActivity' group='onboarding' delayMs=500 waitMs=500 … [Visibility] 'LoginActivity' delay elapsed β€” showing [Visibility] 'Plans' same CONTINUOUS group 'main_tabs' β€” stay visible ``` | Filter | Shows | | -------------- | ------------------------------------------------- | | `[Screen]` | every screen change, and the name the SDK has | | `[Visibility]` | every show/hide decision and why | | `[Init]` | the handshake and whether a widget config arrived | **A good first run** shows `[Screen]` changing as you navigate, `NOT allowed` on your excluded screens, and `delay elapsed β€” showing` on your allowed ones. *** ## 9. Troubleshooting [#9-troubleshooting] ### The button never appears [#the-button-never-appears] Check these in order β€” the first two account for most cases. **1. Did a widget config arrive?** Filter logcat for `Init`: ``` [Init] widgetConfig: null backend returned NO widget_config for this API key β€” the widget cannot render. ``` That is provisioning, not your code: the key has no widget configured for that environment. Confirm your key and `embedUrl` are for the same environment, and ask Revrag to provision the key. **2. Does the name in the config match the name the SDK has?** Filter for `[Visibility]` and read the quoted name. A name in `allowedScreens` that no screen ever reports hides the widget silently. Case matters. **3. Is the screen excluded?** `excludedScreens` beats `allowedScreens`. **4. Did `attachOverlay` run after `setContentView()`?** From `onResume` it always does. A call in `onCreate` before `setContentView` is removed by it. **5. Is there an entrance delay still running?** `delayMs` is real β€” a 2500ms group delay looks like "not working" for two and a half seconds. ### No logs at all [#no-logs-at-all] You're running a release build. The SDK only logs in debug builds of your app. ### The button disappears mid-flow [#the-button-disappears-mid-flow] Almost always teardown code. Remove any `detach()` in `onPause`/`onDestroy` and let the SDK own the hand-off. ### The button hides, or the call ends, on WebView pages [#the-button-hides-or-the-call-ends-on-webview-pages] You're naming web pages with `setCurrentScreen`, so each page counts as a new, unlisted screen. Switch to `setSubScreen` and list the WebView's screen in `allowedScreens` ([5G](#5g--webview-with-several-pages-on-one-screen)). ### The call ends when moving between two screens [#the-call-ends-when-moving-between-two-screens] Both screens are standalone entries in `allowedScreens`, so each is its own session. Put screens that form one journey in a `CONTINUOUS` group ([section 4](#4-decide-where-it-appears)). ### It appears on screens it should not [#it-appears-on-screens-it-should-not] An empty `allowedScreens` means **everywhere**. Either list your screens, or use `excludedScreens` for the ones to suppress. Not attaching on a screen is *not* a way to hide it β€” and it leaves a live call running. ### The call does not start [#the-call-does-not-start] `RECORD_AUDIO` missing from your manifest ([section 1](#1-install)). The SDK requests the runtime permission but cannot grant itself one you never declared. ### `Could not find com.github.davidliu:audioswitch` [#could-not-find-comgithubdavidliuaudioswitch] The JitPack repository is missing from `settings.gradle.kts` ([section 1](#1-install)). ### "Class was compiled with an incompatible version of Kotlin" [#class-was-compiled-with-an-incompatible-version-of-kotlin] ``` The binary version of its metadata is 2.0.0, expected version is 1.9.0 ``` Your project is on Kotlin 1.9.x. The SDK is compiled with 2.0.21 and its classes carry metadata 2.0, which older compilers refuse to read. Move your project to Kotlin 2.0 or newer; there is no flag that makes 1.9 accept it. Your own **Ktor** version is not part of this. The SDK's Ktor is relocated to `ai.revrag.shaded.ktor.*`, so it neither constrains nor conflicts with yours. ### Two widgets appear [#two-widgets-appear] Two mount mechanisms at once β€” usually `EmbedProviderComposable` *and* `attachOverlay`. Pick one. ### Lottie or SLF4J warnings in release logs [#lottie-or-slf4j-warnings-in-release-logs] `"You have N images…"` (Lottie, from the avatar animation) and three SLF4J lines at startup come from third-party libraries. They are not SDK faults and are safe to ignore. ### Something else [#something-else] Capture logcat from a debug build, filtered to `RevragEmbed`, from app start and send it to Revrag with your screen names β€” it records every decision the SDK made. *** ## 10. Checklist [#10-checklist] * [ ] `implementation("ai.revrag:embed-android:2.0.0")` * [ ] `mavenCentral()` **and** `maven("https://jitpack.io")` in your repositories * [ ] Kotlin 2.0+ and JDK 17 * [ ] `INTERNET` and `RECORD_AUDIO` in your manifest * [ ] `EmbedSDK.initialize(...)` in `Application.onCreate` * [ ] `Application` class registered in the manifest * [ ] One mount mechanism β€” `attachOverlay` from every resume, **or** `EmbedProviderComposable`, never both * [ ] `appUserId` set to your stable user id * [ ] Config names match what the SDK reports (Activity class names, nav labels, or Compose routes) * [ ] Splash and trampolines in `excludedScreens` β€” **not** simply un-attached * [ ] Screens that share a journey share a `CONTINUOUS` group * [ ] WebView pages named with `setSubScreen` (no personal data), and the hosting screen listed in `allowedScreens` * [ ] Dialogs and bottom sheets call `attachOverlay(dialog, activity)` in `onStart()` * [ ] Nothing in `onPause` / `onDestroy` **for the widget itself** β€” but do deregister any `onAgent` / `onAvatarState` listeners you registered * [ ] `EmbedSDK.clearStorageCache()` on logout * [ ] `USER_DATA` sent with a valid `app_user_id` before the first call * [ ] Any `on` / `onAgent` listener is deregistered with the **same instance** * [ ] `defaultInset` set if a bottom bar or sticky CTA would sit under the button * [ ] Tested on a **physical device** β€” emulators mishandle microphone and audio routing * [ ] Verified in logcat (debug build): `[Visibility] … delay elapsed β€” showing` on an allowed screen, `NOT allowed β€” hidden` on an excluded one *** ## 11. Upgrading [#11-upgrading] ### 1.2.3 β†’ 2.0.0 [#123--200] Drop-in: nothing was removed or changed in the public API, the dependencies are the same, and so are the requirements. No code changes are needed. What you gain: * **The agent's presses are reported as click events**, with `source: "agent"` ([Taps on your app](#taps-on-your-app)). If your dashboards count `*_pressed`, `*_toggled` or `*_selected` events as user activity, filter on `source` before you ship. * **The agent never presses or reads one number for another.** Amounts, dates and tenures are matched whole and in order: β‚Ή1,080 is not β‚Ή10,800, and "Due 03/04/2026" is not "04/03/2026". * **A value that just changed is read correctly.** Ask for the processing fee after changing the loan amount, and the agent reads the new fee. * **Bottom-bar tabs can be pressed**, and so can full-width buttons pinned to the bottom of the screen. * **Grid and list tiles that use `OnItemClickListener`** can be pressed in a `GridView` or `ListView`. * **A tap whose click handler throws is not repeated**, so a handler that failed halfway is never run twice. Full details are in the [release notes](/embed/integration/android/android-releases). ### 1.2.2 β†’ 1.2.3 [#122--123] Drop-in: nothing was removed or changed in the public API, the dependencies are the same, and so are the requirements. No code changes are needed. What you gain: * **Taps on your app are named by what they do** β€” `_pressed`, `_toggled`, `_selected`, `_opened` β€” and Compose taps are reported for the first time ([Taps on your app](#taps-on-your-app)). **Event names change**: tell whoever owns your analytics dashboards before you ship. * **The agent can always see sliders and inputs.** On busy screens the SDK used to leave unlabelled sliders out of what it sends the agent, so "set the loan amount" failed. * **A slider the user drags is read at its final value**, not the value from mid-drag. * **Scrolling to an element settles in one move** instead of bouncing for about 2 seconds. * **The call card follows more of your dashboard colors**, including multi-color gradients and the text color. Full details are in the [release notes](/embed/integration/android/android-releases). ### 1.2.1 β†’ 1.2.2 [#121--122] Drop-in: nothing was removed or changed in the public API, the dependencies are the same, and so are the requirements. No code changes are needed. What you gain: * **Floating dialogs keep their size.** The SDK no longer stretches an `AlertDialog`, a default `DialogFragment` or a small loader to full screen. The button now sits under floating dialogs; call `attachOverlay(dialog, activity)` to keep it on top of one you sized yourself ([section 6](#6-dialogs-and-bottom-sheets)). * **The agent scrolls before it acts.** It brings an element into view before tapping or selecting it, so the user sees the change. * **The agent stops when the user takes over.** A touch or scroll on your app cancels the running step and clears the highlight and edge glow. * **The edge glow follows your dashboard.** Its colors, on/off switch and size come from the Action config, as in React Native and Flutter. * **Muted calls** show `.....` in the call bar instead of "Listening". Full details are in the [release notes](/embed/integration/android/android-releases). ### 1.2.0 β†’ 1.2.1 [#120--121] Drop-in: nothing was removed or changed in the public API, the dependencies are the same, and so are the requirements. No code changes are needed. What you gain: * **Crash fix** β€” a `KotlinReflectionInternalError … requestFocus` crash when a call started, hit by R8-minified release builds that include `kotlin-reflect` and use Compose foundation 1.9+ (Compose BOM 2025.08.00 or later). * **Failures stay inside the SDK** β€” the SDK's navigation listener, lifecycle callbacks, tap tracking, background work, mounting and `detach()` can no longer throw into your app, and JVM errors such as `NoSuchMethodError` are contained too. Only that SDK feature stops; your app keeps running. * **Calls recover** β€” a failed connection resets the SDK instead of leaving it stuck in "connecting" until the app restarted. * **Sliders** β€” the on-screen agent can now set a slider by typing a number into it. * **Click analytics `element_id`** β€” a view's `tag` is used only when it is a `String`; other tag objects fall through to the resource ID name. Full details, including a known R8 issue with RecyclerView, are in the [release notes](/embed/integration/android/android-releases). ### 1.1.0 β†’ 1.2.0 [#110--120] Drop-in: nothing was removed or changed in the public API, and the requirements are the same. What you gain: * **WebView pages as separate screens** β€” `setSubScreen` on the overlay handle ([5G](#5g--webview-with-several-pages-on-one-screen)). * **App version at initialization** β€” `EmbedSDK.initialize(..., appVersion = "2.4.0")`. Optional; without it the SDK keeps reading your `versionName`. * **Logs in debug builds** β€” the SDK now prints `RevragEmbed` lifecycle logs when your app is a debug build ([section 8](#8-verify-it-works)). 1.1.0 printed nothing. Release builds are unchanged and stay silent. * **A smarter on-screen agent** β€” it no longer targets controls hidden behind a bottom sheet or dialog, skips disabled buttons and fields, sees custom views that expose themselves through accessibility, and links field labels to their fields. * **Stability** β€” bad configuration values from the backend can no longer crash your app, a failure inside the SDK removes the widget instead of taking your app down, and exceptions in your own listeners are isolated. * **UI** β€” the microphone-permission explainer uses its own floating dialog theme, in light and dark. If you named WebView pages with `setCurrentScreen`, switch to `setSubScreen`. `setCurrentScreen` replaces the screen, so an unlisted page name hid the widget and could end a live call. ### 1.0.8 β†’ 1.1.0 [#108--110] Drop-in: no code changes required. What you gain: * **The SDK follows Activity changes on its own** (`autoTrackActivities`, default on). Multi-Activity apps no longer need to name every Activity β€” see [5A](#5a--xml-one-activity-per-screen). Your existing explicit names still win. * **Automatic screen names** from Activity class and fragment class, so `allowedScreens` works on screens you never wired up. * Widget lifecycle fixes across Activity hand-overs, dialog windows and configuration changes. If you were relying on *not* calling `attachOverlay` on a screen as a way to hide the widget, that no longer hides it β€” the SDK finds the Activity by itself. Move those screens to `excludedScreens`, which is better anyway: it also ends a live call, which non-attachment never did. ### 1.0.7 β†’ 1.0.8 [#107--108] Adds call-control APIs, widget control and `EmbedAnalyticsEvents`. No changes required. ### 1.0.6 β†’ 1.0.7 [#106--107] Fixes Ktor class conflicts by shading. No changes required. *** ## Known limitations [#known-limitations] * `setSubScreen` is Android only; it is not on iOS yet. * The agent cannot read or fill content inside a WebView. * The agent cannot complete a PIN or OTP keypad that shows each entered digit only as a dot (β€’). It cannot see the dot appear, so it stops after the first digit. * A switch or checkbox without a label of its own, such as a `Switch` next to a `Text` in a `Row`, cannot be found by name. Give it a `contentDescription`, or make the whole row `toggleable` with the label inside. * Text longer than a field's `maxLength` is cut short by Android, and the agent reports the step as not applied. * Opening the tab that is already showing is reported as unconfirmed, so a multi-step command stops at that step. *** ## Reference [#reference] | | | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Artifact | `ai.revrag:embed-android:2.0.0` | | Maven Central | [https://central.sonatype.com/artifact/ai.revrag/embed-android](https://central.sonatype.com/artifact/ai.revrag/embed-android) | | Release notes | [Android SDK release notes](/embed/integration/android/android-releases) | *** ## Support [#support] * Issues: [GitHub Issues](https://github.com/revrag-ai/embed-android/issues) * Docs: [Revrag Documentation](https://docs.revrag.ai) * Dashboard: [app.revrag.ai](https://app.revrag.ai) --- # Android Agent Integration > Drop-in brief for coding agents (Claude Code, Cursor, Copilot) to integrate the Revrag Embed Android SDK - phases, hard rules, API reference, and definition of done URL: /embed/integration/android/android-agent-integration Markdown: /embed/integration/android/android-agent-integration.md # Revrag Embed β€” Android integration, for a coding agent [#revrag-embed--android-integration-for-a-coding-agent] **How to use this file:** drop it anywhere in your Android repo and tell your coding agent (Claude Code, Cursor, Copilot, Windsurf, …): > Read `AGENT_INTEGRATION.md` and integrate the Revrag Embed SDK into this app. > My API key is ``. Everything the agent needs is below. A typical integration is 3 files and a few minutes. *** ## AGENT: START HERE [#agent-start-here] You are integrating the Revrag Embed Android SDK β€” a floating AI voice-agent button that follows the user across screens. Work through the phases in order. Do not skip Phase 0. ### Phase 0 β€” Gather what you cannot guess [#phase-0--gather-what-you-cannot-guess] Ask the user for anything in this table you cannot determine from the repo. Ask for **all** of it in one message, then proceed. | Needed | Why | If unknown | | -------------------------------- | ------------------------------ | --------------------------------------------- | | **API key** | required to initialize | **must ask β€” never invent one** | | **Environment URL** | key and environment are a pair | default `null` (production) | | **Which screens show the agent** | drives `allowedScreens` | **must ask** β€” do not guess from screen names | | **User id expression** | attaches events to a user | ask; use `""` if the app has no auth yet | Then determine from the repo yourself, without asking: 1. **Does an `Application` subclass exist?** Search for `: Application()` and `android:name` in `AndroidManifest.xml`. If none, you will create one. 2. **Which shape is this app?** Count the Activities FIRST, then look inside. Answer both questions before choosing β€” the shapes differ on both axes, and picking on UI toolkit alone lands on the wrong one: | Activities | Screens inside them | Shape | | ----------- | ------------------------------------------------------------- | ----- | | exactly one | Compose `setContent {}` + `NavHost` | **D** | | exactly one | `NavHostFragment` / fragment transactions | **B** | | several | one screen each, no nav host anywhere | **A** | | several | at least one has a `NavHostFragment` / `BottomNavigationView` | **C** | | several | at least one is Compose with `setContent {}` | **E** | A single-Activity app is D or B and never anything else. A multi-Activity app is A, C or E β€” and E only when a Compose Activity is involved; a multi-Activity XML app with tabs is C, not E. 3. **What are the screen names?** Read them, do not invent them: * Shape A β†’ Activity **class names** (`LoginActivity`) * Shape B/C β†’ nav graph `android:label` values, or fragment class names * Shape D/E β†’ NavHost **route** strings Report the list you found back to the user with your plan. ### Phase 1 β€” Dependency and permissions [#phase-1--dependency-and-permissions] **1a.** Add to the app module's `build.gradle.kts` (or `.gradle`): ```kotlin implementation("ai.revrag:embed-android:2.0.0") ``` Then make sure `settings.gradle(.kts)` lists **JitPack** alongside `mavenCentral()`, merging into the existing block rather than replacing it: ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() // hosts ai.revrag:embed-android maven("https://jitpack.io") // required } } ``` ⚠ **JitPack is required.** The SDK's voice transport depends on `com.github.davidliu:audioswitch`, which is published only on JitPack. Without it, Gradle sync fails with `Could not find com.github.davidliu:audioswitch`. Do **not** add any Compose dependency or the Compose compiler plugin; the SDK carries its own UI and works in pure-XML apps. **1b.** Add to `app/src/main/AndroidManifest.xml`, inside ``: ```xml ``` ⚠ **The SDK ships no permissions of its own.** If you skip this, the widget appears and every call fails. Do not write a runtime-permission flow β€” the SDK requests `RECORD_AUDIO` itself when the user first starts a call. Do not add a Bluetooth permission either β€” headset routing is OS-managed. **1c.** Verify `minSdk >= 24`, `compileSdk >= 34`, **Kotlin 2.0 or newer**, and that the project builds on JDK 17. The SDK is compiled with Kotlin 2.0.21 and its classes carry metadata 2.0 β€” a 1.9.x compiler fails with "the binary version of its metadata is 2.0.0, expected version is 1.9.0", and no flag works around it. If the project is on 1.9.x, say so and stop rather than attempting the integration. Ktor is shaded into `ai.revrag.shaded.ktor.*` and will not clash with the app's own Ktor. LiveKit, Lottie, Coil and Coroutines arrive transitively β€” do not add them yourself. Do not add ProGuard/R8 rules β€” the AAR ships what R8 needs. ### Phase 2 β€” Initialize [#phase-2--initialize] Create the `Application` subclass if absent, register it in the manifest (``), and add: ```kotlin override fun onCreate() { super.onCreate() EmbedSDK.initialize(this, "") { result -> if (!result.success) Log.e("Embed", "init failed: ${result.error}") } EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "")) } ``` Import from `ai.revrag.embed.android.*` only. Never import `ai.revrag.embed.core.*` β€” it is internal and will break on upgrade. If the user gave an environment URL, pass it: `EmbedSDK.initialize(this, "", embedUrl = "") { … }` The SDK reports the app's `versionName` by default. Only if the user wants a different value reported, pass `appVersion = ""` to `initialize` (named arguments). ### Phase 3 β€” Visibility config [#phase-3--visibility-config] Put this in the `Application`'s `companion object`. Use the **real** screen names you found in Phase 0.3 and the screens the user named in Phase 0. ```kotlin val EMBED_VISIBILITY = EmbedButtonVisibilityConfig( allowedScreens = listOf(/* names the user chose */), excludedScreens = listOf(/* splash, trampolines, deep-link handlers */), groups = listOf( EmbedButtonGroupConfig( id = "main", screens = listOf(/* screens that form one journey */), continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ) ) ) ``` Rules you must follow: * **An empty `allowedScreens` means the widget shows EVERYWHERE.** Never leave it empty unless the user explicitly wants that. * Put splash/trampoline Activities in `excludedScreens`. Do **not** achieve this by skipping the attach there β€” that leaves a live call running. * Screens the user moves between within one task belong in one `CONTINUOUS` group, so the call survives navigation. * Names are matched **exactly** and are case-sensitive. ### Phase 4 β€” Mount [#phase-4--mount] Apply exactly one of the following, matching the shape from Phase 0.2. *** #### Shape A β€” XML, one Activity per screen [#shape-a--xml-one-activity-per-screen] In `Application.onCreate`, after `initialize`. **Touch no Activity file.** ```kotlin registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { EmbedProvider.attachOverlay( activity = activity as? ComponentActivity ?: return, appUserId = "", visibilityConfig = EMBED_VISIBILITY ) } override fun onActivityCreated(a: Activity, b: Bundle?) = Unit override fun onActivityStarted(a: Activity) = Unit override fun onActivityPaused(a: Activity) = Unit override fun onActivityStopped(a: Activity) = Unit override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit override fun onActivityDestroyed(a: Activity) = Unit }) ``` *** #### Shape B β€” XML, one Activity, many fragments [#shape-b--xml-one-activity-many-fragments] In that Activity's `onCreate`, **after `setContentView`**: ```kotlin val navHost = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHost.navController navController.addOnDestinationChangedListener { _, _, _ -> EmbedProvider.attachOverlay( activity = this, appUserId = "", visibilityConfig = MyApp.EMBED_VISIBILITY, navController = navController ) } ``` Use `findFragmentById(...) as NavHostFragment`, **not** `findNavController()` β€” the latter crashes when called from `onCreate` with a `FragmentContainerView`. If the app has no NavController, pass `currentScreen = ""` from wherever it swaps fragments instead, and omit `navController`. *** #### Shape C β€” XML, several Activities with several screens [#shape-c--xml-several-activities-with-several-screens] Both of the above. Create one helper so there is a single call site: ```kotlin // In the Application's companion object fun attachEmbed(activity: Activity) { val host = activity as? ComponentActivity ?: return EmbedProvider.attachOverlay( activity = host, appUserId = "", visibilityConfig = EMBED_VISIBILITY, navController = (activity as? MainActivity)?.embedNavController, currentScreen = null ) } ``` * `Application.onCreate` β†’ `registerActivityLifecycleCallbacks` with `onActivityResumed { attachEmbed(activity) }` (other overrides empty). * The tabbed Activity β†’ expose the controller and re-attach on destination change: ```kotlin val embedNavController: NavController? get() = if (::navController.isInitialized) navController else null navController.addOnDestinationChangedListener { _, _, _ -> MyApp.attachEmbed(this) } ``` `allowedScreens` will contain **both** Activity class names and nav labels. *** #### Shape D β€” Compose, single Activity [#shape-d--compose-single-activity] Wrap the existing content. Do not restructure the app. ```kotlin setContent { val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() EmbedProviderComposable( currentScreen = backStack?.destination?.route ?: "", appUserId = "", visibilityConfig = EMBED_VISIBILITY, navController = navController ) { // the app's existing NavHost / content, unchanged } } ``` Screen names are **routes**. `allowedScreens` must use route strings. *** #### Shape E β€” Compose, several Activities [#shape-e--compose-several-activities] Use **Shape A**'s `Application` mount β€” it works for Compose Activities too and one mount serves them all. Add Shape C's `navController` hand-over for any Activity that has its own. Do **not** also use `EmbedProviderComposable`. *** #### Any shape β€” WebView with several pages on one screen [#any-shape--webview-with-several-pages-on-one-screen] If a screen hosts a `WebView` that moves through several pages (KYC, DigiLocker, eSign, a partner page), name each page with `setSubScreen` on the handle `attachOverlay` returns. List the **hosting screen** in `allowedScreens`, never the page names: ```kotlin webViewClient = object : WebViewClient() { override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { if (!isReload) embedHandle?.setSubScreen(pageNameFor(url)) } } ``` * Derive page names from the URL **path** only β€” no query string, tokens, session ids, user ids or personal data. The name is sent to the backend. * Never use `setCurrentScreen` for web pages: it replaces the screen, so an unlisted page name hides the widget and ends a live call. * `setSubScreen` needs the `attachOverlay` mount, so do not use `EmbedProviderComposable` in an app that needs it. *** ### Phase 5 β€” Dialogs (only if the app has them) [#phase-5--dialogs-only-if-the-app-has-them] For any `DialogFragment` / `BottomSheetDialogFragment` that should not cover the widget, add to `onStart()`: ```kotlin EmbedProvider.attachOverlay(dialog!!, requireActivity()) ``` Write no teardown β€” the widget returns to the Activity on every dismissal path. ### Phase 5b β€” Logout and position (do these if they apply) [#phase-5b--logout-and-position-do-these-if-they-apply] **If the app has authentication**, find the sign-out path and add: ```kotlin EmbedSDK.clearStorageCache() ``` Without it, one user's identity and conversation context carry into the next user's session on a shared device. This is a correctness requirement, not a nicety. **If any allowed screen has a bottom navigation bar, FAB or sticky CTA**, set an inset so the widget does not sit under it: ```kotlin EmbedButtonVisibilityConfig( defaultInset = EmbedButtonInset(right = 24, bottom = 80), // dp // …or per group: EmbedButtonGroupConfig(inset = EmbedButtonInset(bottom = 160)) ) ``` **If the app uses one NavController per bottom tab**, pass the ACTIVE controller, and re-attach when the tab changes as well as on destination change. ### Phase 5c β€” Event observability (do this β€” it is how you verify) [#phase-5c--event-observability-do-this--it-is-how-you-verify] Add this helper file to the app. It logs every SDK event with a readable label, and is the hook the user forwards to their own analytics later. ```kotlin import ai.revrag.embed.android.EmbedAnalyticsEvents import ai.revrag.embed.android.EmbedSDK import ai.revrag.embed.android.EventCallback import ai.revrag.embed.android.EventKeys import android.util.Log object SdkLifecycleListener { private const val TAG = "SdkLifecycle" private var callback: EventCallback? = null fun register(onEvent: ((name: String, data: Map) -> Unit)? = null) { if (callback != null) return val cb: EventCallback = { data -> val name = data["event_name"] as? String ?: "unknown" Log.d(TAG, "${label(name)} β†’ $data") onEvent?.invoke(name, data) } callback = cb EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) } fun unregister() { callback?.let { EmbedSDK.off(EventKeys.ANALYTICS_DATA, it) } callback = null } private fun label(name: String): String = when (name) { EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> "Widget expanded" EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE -> "Widget collapsed" EmbedAnalyticsEvents.AGENT_VISIBLE -> "Widget visible" EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> "Call started" EmbedAnalyticsEvents.AGENT_CONVERSATION_ENDED -> "Call ended" EmbedAnalyticsEvents.AVATAR_MODE_OPENED -> "Avatar opened" EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE -> "Popup shown" EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW -> "Mic permission granted" EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED -> "Tool triggered" EmbedAnalyticsEvents.RAGE_CLICK -> "Rage click" EmbedAnalyticsEvents.FORM_EVENT -> "Form event" EmbedAnalyticsEvents.ERROR -> "SDK error" else -> name } } ``` Call `SdkLifecycleListener.register()` in `Application.onCreate` after `initialize`, and `unregister()` on logout. Use its `SdkLifecycle` log tag in Phase 6 to prove events flow. If the app registers `onAgent` / `onAvatarState` listeners anywhere, they must be deregistered in the matching `onDestroy` **with the same callback instance** β€” an inline lambda at the `off` call site removes nothing and leaks the Activity. ### Phase 6 β€” Verify, then report [#phase-6--verify-then-report] 1. **Build.** `./gradlew :app:assembleDebug`. Fix compile errors. 2. **Run a debug build and read logcat** (`adb logcat | grep RevragEmbed`). The SDK only logs in debug builds of the app; a release build prints nothing. You are looking for: ``` [Screen] SplashActivity#1 β†’ LoginActivity#2 [Visibility] 'SplashActivity' NOT allowed β€” hidden [Visibility] 'LoginActivity' delay elapsed β€” showing ``` 3. **Check `[Init]`:** * `widgetConfig: null` or `backend returned NO widget_config` β†’ the API key has no widget provisioned for that environment. This is **not** a code problem. Tell the user to confirm the key/environment pair with Revrag, and stop β€” do not try to work around it. 4. **Report to the user:** which shape you detected, the screen names you used, the files you changed, and what you saw in logcat. *** ## HARD RULES [#hard-rules] Violating these produces bugs that look like SDK faults and are hard to trace. 1. **Never write teardown.** No `detach()` in `onPause`/`onDestroy`/`onStop`. The next Activity claims the widget before the previous one dies; the SDK owns the hand-off. Teardown code is the single most common cause of "the widget disappears mid-flow". 2. **One mount mechanism per app.** `attachOverlay` **or** `EmbedProviderComposable`. Both = two widgets. 3. **Always use named arguments** on `attachOverlay` and `EmbedProviderComposable`. The parameter lists grow; positional calls break on upgrade. 4. **Import only `ai.revrag.embed.android.*`.** Never `ai.revrag.embed.core.*`. 5. **`attachOverlay` must run after `setContentView()`.** From `onResume` this is automatic; from `onCreate` it is your responsibility. 6. **Never invent an API key**, and never fall back to a placeholder to "make it build". Ask. 7. **Do not add `RECORD_AUDIO` handling yourself** β€” the SDK requests it. Just declare it in the manifest. 8. **Do not use an empty `allowedScreens`** to mean "the screens I listed". Empty means everywhere. 9. **Do not rename the user's screens** to match a config you wrote. Read the real names and write the config to match them. 10. **Do not turn off `autoTrackActivities`.** It defaults to `true`, and off means a forgotten Activity keeps a live microphone on a screen the product excluded. *** ## API REFERENCE [#api-reference] Everything below is `ai.revrag.embed.android.*`. ```kotlin // Initialize β€” once, in Application.onCreate EmbedSDK.initialize( context: Context, apiKey: String, embedUrl: String? = null, // null = production autoTrackActivities: Boolean = true, // leave true onResult: ((InitResult) -> Unit)? = null ) // Overload with appVersion (optional; omitted or blank = the app's versionName): EmbedSDK.initialize(context = this, apiKey = "", appVersion = "2.4.0") { result -> } // Identify the user EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123")) // Mount / move / rename β€” XML and Compose Activities alike EmbedProvider.attachOverlay( activity: ComponentActivity, // required appUserId: String = "", appVersion: String = "", accentColor: Int = 0xFF6C63FF.toInt(), visibilityConfig: EmbedButtonVisibilityConfig = EmbedButtonVisibilityConfig(), navController: NavController? = null, currentScreen: String? = null, chatPanelUrl: String? = null ): EmbedOverlayHandle? // Lift the widget into a dialog's window EmbedProvider.attachOverlay(dialog: Dialog, activity: ComponentActivity) EmbedProvider.attachOverlay(window: Window, activity: ComponentActivity) // Keep the widget out of dialogs (1.2.2) EmbedProvider.excludeFromWidget(dialog: Dialog) EmbedProvider.excludeFromWidget(window: Window) EmbedProvider.setAutoAttachDialogs(enabled: Boolean) // Compose hosts β€” single Activity @Composable fun EmbedProviderComposable( currentScreen: String, appUserId: String = "", accentColor: Color = Color(0xFF6C63FF), visibilityConfig: EmbedButtonVisibilityConfig = EmbedButtonVisibilityConfig(), navController: NavController? = null, chatPanelUrl: String? = null, content: (@Composable () -> Unit)? = null ) // Rename the current screen at any time handle.setCurrentScreen("Checkout") // Name a page INSIDE the current screen (WebView flows); null returns to the screen handle.setSubScreen("web/digilocker/consent") // ── Programmatic control (optional β€” the widget drives itself) ────────────── EmbedSDK.startCall(activity) // requests RECORD_AUDIO if needed EmbedSDK.startCall(activity, agentTriggerMode: AgentTriggerMode? = null) { started: Boolean -> } EmbedSDK.endCall() EmbedSDK.isCallActive(): Boolean // agentTriggerMode β€” WHO OWNS THE UI. A host decision, not the backend's. // AgentTriggerMode.CO_PILOT β€” your app's screens are the UI; the SDK draws // only its button. The usual choice. // AgentTriggerMode.WORKFLOW β€” Revrag draws its own panel (avatar screen with // Voice / Chat / Avatar tabs). // ⚠ Passed at startCall ONLY, never at initialize. A call the USER starts by // tapping the button carries no mode β€” only calls the host starts can carry // it. It does NOT enable video; that is media_mode in the backend's config. // ⚠ DEFAULT IS CO_PILOT. Omitting the argument, passing null, or a // user-initiated call all resolve to CO_PILOT + AUDIO. Do not pass // CO_PILOT explicitly to "make sure" β€” omit it. Pass WORKFLOW only when the // user has said Revrag should draw its own screen. // ⚠ startCall is refused on a screen where the widget is hidden. EmbedSDK.expandWidget() // open the card EmbedSDK.collapseWidget() // dismiss the card, keep the call EmbedSDK.sendText("…") // text into the running call; does not start one EmbedSDK.isWidgetExpanded // StateFlow EmbedSDK.minimizeAvatar() // leave full-screen avatar mode EmbedSDK.isAvatarOpen // StateFlow // ── Lifecycle callbacks ──────────────────────────────────────────────────── EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED) { } EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED) { data -> } // carries duration EmbedSDK.onAvatarState { isOpen -> } EmbedSDK.offAgent(...) / EmbedSDK.offAvatarState(...) // deregister! // AgentEvent: AGENT_CONNECTED, AGENT_DISCONNECTED, POPUP_MESSAGE_VISIBLE // ── Readiness ────────────────────────────────────────────────────────────── EmbedSDK.isInitialized(): Boolean // has credentials EmbedSDK.isInitializedFlow // StateFlow EmbedSDK.widgetConfig // StateFlow β€” non-null = can draw // ── Context on every event ───────────────────────────────────────────────── EmbedSDK.setCurrentFlow("loan_application") EmbedSDK.setAppVersion("4.2.0") // only if yours differs from PackageInfo; initialize(appVersion=) wins // EventKeys: USER_DATA, SCREEN_STATE, ANALYTICS_DATA, CUSTOM_EVENT // ── Listening to events ──────────────────────────────────────────────────── val cb: EventCallback = { data -> } EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) EmbedSDK.off(EventKeys.ANALYTICS_DATA, cb) // SAME instance, or it does not unhook // Everything the SDK fires arrives on ANALYTICS_DATA keyed by "event_name": // EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN / AGENT_TAP_TO_CLOSE / AGENT_VISIBLE // AGENT_CONVERSATION_STARTED / AGENT_CONVERSATION_ENDED / AVATAR_MODE_OPENED // POPUP_MESSAGE_VISIBLE / GEN_TOOL_TRIGGERED / MICROPHONE_PERMISSION_ALLOW // RAGE_CLICK / FORM_EVENT / ERROR // Compare against the constants, never string literals. // ── ⚠ On logout β€” REQUIRED if the app has auth ───────────────────────────── EmbedSDK.clearStorageCache() ``` **Button position** ```kotlin EmbedButtonInset(right = 24, bottom = 80, left = 24, top = 0) // dp from edges ``` Set `defaultInset` on the visibility config, or `inset` on a group to override it for that group's screens. Use it whenever a bottom navigation bar, FAB or sticky CTA would sit under the widget. **Config types** ```kotlin EmbedButtonVisibilityConfig( allowedScreens: List = emptyList(), // EMPTY = everywhere excludedScreens: List = emptyList(), // wins over allowedScreens showDelay: Long = 0L, groups: List = emptyList(), endCallWhenHiddenByVisibility: Boolean = true ) EmbedButtonGroupConfig( id: String, screens: List, continuity: EmbedButtonContinuity = EmbedButtonContinuity.PER_SCREEN, delayMs: Long = 0L, delayPolicy: EmbedButtonDelayPolicy = EmbedButtonDelayPolicy.PER_SCREEN ) enum EmbedButtonContinuity { PER_SCREEN, CONTINUOUS } enum EmbedButtonDelayPolicy { PER_SCREEN, ONCE_PER_GROUP_ENTRY, ONCE_PER_APP_SESSION } ``` **Screen-name resolution**, highest wins: 1. `currentScreen` / `handle.setCurrentScreen(...)` β€” explicit 2. `NavController` destination `android:label` or route 3. Fragment class name 4. Activity class name β€” the floor, never absent Naming one screen by hand disables rung 3 for the session: if you start naming, name them all. `handle.setSubScreen(...)` sits on top of any rung and does not take ownership; the next screen change clears it. *** ## TROUBLESHOOTING (for the agent) [#troubleshooting-for-the-agent] | Symptom | Cause | Fix | | ------------------------------------------------------------ | ---------------------------------------------- | --------------------------------------------------------------------- | | Button never appears; `[Init] widgetConfig: null` | key has no widget for that environment | not a code issue β€” tell the user to check key/environment with Revrag | | Button never appears; `[Visibility] 'X' NOT allowed` | `X` is not in `allowedScreens`, or is excluded | make the config match the reported name exactly, case included | | Button never appears; no `[Visibility]` line at all | mount never ran | `attachOverlay` missing, or called before `setContentView` | | Appears everywhere | `allowedScreens` empty | list the screens | | Disappears mid-flow | teardown code | remove `detach()` from lifecycle callbacks | | Two buttons | two mount mechanisms | keep one | | Call never starts | `RECORD_AUDIO` not declared | add it to the manifest | | Crash: `findNavController` in `onCreate` | `FragmentContainerView` timing | use `findFragmentById(...) as NavHostFragment` | | Unresolved reference on upgrade | positional args, or a `core` import | use named args; import only `…embed.android.*` | | Sync fails: `Could not find com.github.davidliu:audioswitch` | JitPack missing | add `maven("https://jitpack.io")` to `settings.gradle(.kts)` | | No `RevragEmbed` logs at all | release build | run a debug build; the SDK is silent in release | | Widget hides / call ends on WebView pages | pages named with `setCurrentScreen` | switch to `setSubScreen`; list the hosting screen | | Call ends moving between two allowed screens | they are standalone entries, not one group | put them in one `CONTINUOUS` group | *** ## DEFINITION OF DONE [#definition-of-done] * [ ] `ai.revrag:embed-android:2.0.0` added; JitPack in repositories; `minSdk >= 24`, `compileSdk >= 34` * [ ] `INTERNET` + `RECORD_AUDIO` in the manifest * [ ] `EmbedSDK.initialize` in `Application.onCreate`; `Application` registered * [ ] Exactly one mount mechanism, matching the detected shape * [ ] `allowedScreens` non-empty and matching real reported names * [ ] Splash/trampolines in `excludedScreens` * [ ] No teardown code anywhere for the widget β€” but any `onAgent` / `onAvatarState` listener you registered is deregistered with its owner * [ ] `EmbedSDK.clearStorageCache()` wired into logout, if the app has auth * [ ] Inset set if a bottom bar / sticky CTA overlaps an allowed screen * [ ] WebView pages named with `setSubScreen` (no personal data), if the app has them * [ ] `./gradlew :app:assembleDebug` succeeds * [ ] Logcat shows `delay elapsed β€” showing` on an allowed screen and `NOT allowed β€” hidden` on an excluded one * [ ] `SdkLifecycle` log tag shows events firing (`Widget visible` at minimum) * [ ] `USER_DATA` sent with a real `app_user_id` before any call * [ ] Reported to the user: shape, screen names, files changed, logcat evidence Full human-readable guide: [Android Integration Guide](/embed/integration/android) --- # Android App Size Optimization > Reduce APK size by enabling ABI splits when using LiveKit. URL: /embed/integration/android/android-app-size-optimization Markdown: /embed/integration/android/android-app-size-optimization.md # Android App Size Optimization [#android-app-size-optimization] Because this SDK relies on LiveKit (WebRTC) for real-time audio/video, it includes native C++ binaries. You may notice your Universal (Debug) APK size increase by approximately 70MB. This is normal. By default, Android bundles binaries for every architecture (older phones, modern phones, and PC emulators) into a single file. Your production users should not download this "dead weight." To ensure your users only download the \~20MB required for their specific device, add the following configuration to your `android/app/build.gradle` file: ```groovy android { // ... existing config splits { abi { enable true reset() // Includes only real device architectures (removes emulators like x86) include "armeabi-v7a", "arm64-v8a" // Prevents generating a giant "Universal" APK containing all binaries universalApk false } } } ``` ## Why is this necessary? [#why-is-this-necessary] This configuration enables ABI (Application Binary Interface) splitting. Here is exactly what it does: * `reset()`: Clears Android's default build list, allowing us to define a custom list of supported devices. * `include "armeabi-v7a", "arm64-v8a"`: Tells the build system to only generate binaries for physical Android devices (32-bit and 64-bit). It strips out x86 and x86\_64 binaries (used only for emulators), which saves \~30-40MB immediately. * `universalApk false`: Ensures the build system does not create a "fat APK" that contains every architecture. ## The Result [#the-result] Instead of one giant \~75MB file, the system generates separate, smaller slices (\~25MB) for each device type. ## Note for Play Store Deployment [#note-for-play-store-deployment] If you build using Android App Bundles (`.aab`), Google Play handles this splitting automatically. The configuration above is critical if you are distributing APKs manually or want to control the split logic explicitly. --- # Android Release Notes > Release notes for ai.revrag:embed-android β€” what changed in each release, with upgrade steps. URL: /embed/integration/android/android-releases Markdown: /embed/integration/android/android-releases.md # Android SDK β€” Release Notes [#android-sdk--release-notes] `ai.revrag:embed-android` Β· release history, newest first. Install the latest with `implementation("ai.revrag:embed-android:2.0.0")`; confirm the current version on [Maven Central](https://central.sonatype.com/artifact/ai.revrag/embed-android). *** ## 2.0.0: upgrading from 1.2.3 [#200-upgrading-from-123] ### Upgrade [#upgrade] ```kotlin implementation("ai.revrag:embed-android:2.0.0") ``` **Drop-in.** Nothing has been removed or changed in the public API, the dependencies are the same as 1.2.3, and so are the toolchain requirements (minSdk 24, compileSdk 34, Kotlin 2.0+, JDK 17). No code changes are needed. As before, your repositories must include JitPack (`maven("https://jitpack.io")`). **The agent's presses are now reported as click events.** If your dashboards, funnels or rules count `*_pressed`, `*_toggled` or `*_selected` events as user activity, read [The agent's presses are reported](#the-agents-presses-are-reported) and filter on `source` before you ship. *** ### Changed [#changed] #### The agent's presses are reported [#the-agents-presses-are-reported] When the agent presses something in your app, the SDK now sends the same `analytics_data` click event a person's tap produces, with `source: "agent"`. Your users' taps keep `source: "user"`. This covers a tap, an option the agent picks, and a checkbox or switch it changes. Reads, highlights, typing and sliders are not reported. Both sources share one `click_sequence`, repeats are de-duplicated per source, and the agent's press never counts as the user touching your app. React Native and web already report agent presses this way. *** ### Fixed [#fixed] #### The agent never presses or reads one number for another [#the-agent-never-presses-or-reads-one-number-for-another] Numbers are now matched as whole values, in order. The agent no longer takes β‚Ή1,080 for β‚Ή10,800, builds "Pay β‚Ή6,000" out of a card that reads "Pay in 6 EMIs" and "β‚Ή3,000 per month", or takes "Due 11/11/2026" for "11/12/2026" or "03/04/2026" for "04/03/2026". When the exact element is not on screen, the agent scrolls to find it or reports it as not found. #### A value that just changed is read correctly [#a-value-that-just-changed-is-read-correctly] Ask for the processing fee after changing the loan amount, and the agent reads the new fee, even though it planned the step from the screen before the change. This applies only to reading and highlighting, never to pressing or typing. In a list that loads rows as you scroll, the agent scrolls to the exact row instead of reading a similar one, and a different tenure, account number or rate is never taken for a changed value. #### Bottom-bar tabs can be pressed [#bottom-bar-tabs-can-be-pressed] The agent measured the tabs of a bottom bar as hidden behind the bar, scrolled for them and refused to press them. In 1.2.2 and 1.2.3, "go to Events" on a bottom tab bar failed, and so did a full-width button pinned to the bottom of the screen. The bar's own elements are now pressed where they are. Content behind the bar is still scrolled into view first. #### Grid and list tiles that use `OnItemClickListener` can be pressed [#grid-and-list-tiles-that-use-onitemclicklistener-can-be-pressed] In a `GridView` or `ListView` whose tiles are not clickable themselves, with the click on the list's `OnItemClickListener`, the agent pressed the list as a whole and nothing opened. It now presses the tile through `performItemClick`, as a finger does, and only where a finger would fire the item click: the list and the item are enabled, the row holds no button or field of its own, and nothing in between takes the touch. #### A tap whose click handler throws is not repeated [#a-tap-whose-click-handler-throws-is-not-repeated] If your click handler threw an exception, the SDK retried the step and ran the handler again, possibly after it had already started a payment. The step now fails once and is not retried. #### The agent's memory of a screen stays current [#the-agents-memory-of-a-screen-stays-current] A value that changed now replaces its old copy instead of sitting next to it, so the agent is no longer offered stale amounts from earlier on the screen. A row is forgotten only when the SDK sees the whole list without it, never because a sheet or dialog covers it. *** ### Known limitations [#known-limitations] * **PIN and OTP keypads that show each digit as a dot (β€’).** The agent cannot see the dot appear, so it stops after the first digit. Users enter these themselves. * **A switch or checkbox without a label of its own**, such as a `Switch` next to a `Text` in a `Row`, cannot be found by name. Give it a `contentDescription`, or make the whole row `toggleable` with the label inside. * **Text longer than a field's `maxLength`** is cut short by Android, and the agent reports the step as not applied. * **Opening the tab that is already showing** is reported as unconfirmed, so a multi-step command stops at that step. *** ## 1.2.3: upgrading from 1.2.2 [#123-upgrading-from-122] ### Upgrade [#upgrade-1] ```kotlin implementation("ai.revrag:embed-android:1.2.3") ``` **Drop-in.** Nothing has been removed or changed in the public API, the dependencies are the same as 1.2.2, and so are the toolchain requirements (minSdk 24, compileSdk 34, Kotlin 2.0+, JDK 17). No code changes are needed. As before, your repositories must include JitPack (`maven("https://jitpack.io")`). **Click analytics event names change in this release.** If your dashboards, funnels or rules use the SDK's `*_pressed` events, read [Taps on your app are named by what they do](#taps-on-your-app-are-named-by-what-they-do) before you ship. *** ### Changed [#changed-1] #### Taps on your app are named by what they do [#taps-on-your-app-are-named-by-what-they-do] While the button is visible, the SDK reports taps on your app as `analytics_data` events. It now follows the web SDK's naming, and reports Compose taps for the first time. | Control | 1.2.2 | 1.2.3 | | ------------------------------- | ---------------- | ----------------------------------------- | | Button, tab, icon button | `_pressed` | `_pressed`, `type: button_click` | | Checkbox, switch | `_pressed` | `_toggled`, `type: checkbox_toggle` | | Radio button, selectable option | `_pressed` | `_selected`, `type: radio_select` | | Dropdown | `_pressed` | `_opened`, `type: button_click` | | Any other clickable element | `_pressed` | `_pressed`, `type: element_click` | | Anything built in Compose | not reported | reported | The `` comes from your own identifier first, used as written: a Compose `testTag`, a View's `String` tag, or its `android:id`. An existing `btn_pay_pressed` stays `btn_pay_pressed`. Without one, the SDK uses the accessibility label or visible text, cleaned up to at most four words, with digits and currency words left out: | 1.2.2 | 1.2.3 | | -------------------------------------- | ------------------------ | | `Loan amount_pressed` | `loan_amount_pressed` | | `terms_checkbox_pressed` (a checkbox) | `terms_checkbox_toggled` | | nothing (a Compose "Apply now" button) | `apply_now_pressed` | When a name changes, the old one is sent alongside it as `legacy_event_name`, so dashboards can map old names to new. Each event's `data` also gains `type`, `kind`, `role`, `checked` (the state after the tap) and `source`. The existing `element_id`, `click_sequence`, `component`, `x` and `y` are unchanged. No longer reported: a scroll or drag that ends over a control, a tap that only focuses a text field, a tap on a disabled control, and a second identical event within 300 ms. Taps inside a dialog are now matched to the dialog, not the screen behind it. See [Taps on your app](/embed/integration/android#taps-on-your-app) for the full naming rules. #### The call card follows more of your dashboard colors [#the-call-card-follows-more-of-your-dashboard-colors] | Dashboard color | 1.2.2 | 1.2.3 | | -------------------------- | -------------------------------------- | -------------------------------------------------------- | | Gradient colors | two colors | any number of colors, in order | | Text color | not used; name and status always white | agent name, status line and captions icon | | Border color | outline around the card | outline around the Start Call button, as in React Native | | Button color, when not set | translucent white | black, as in React Native | The captions (CC) icon is filled when captions are on and outlined when off. If your card uses a light gradient, set a dark text color so the name and status stay readable. *** ### Fixed [#fixed-1] #### The agent can always see sliders and inputs [#the-agent-can-always-see-sliders-and-inputs] On a busy screen, the SDK trims what it sends the agent. It used to drop controls that have no text first, so an unlabelled slider such as a loan-amount slider disappeared. The agent then refused to set it, or typed the amount into nearby text instead. Inputs, buttons and controls such as sliders, switches and checkboxes now always come before plain text. The size limit has also risen from 15 KB to 25 KB, as in React Native, so most screens are sent whole. #### A slider the user drags is read at its final value [#a-slider-the-user-drags-is-read-at-its-final-value] After the user dragged a slider, the agent could read the value from the middle of the drag for several seconds. The SDK now also updates the agent when the finger lifts. #### Scrolling to an element settles in one move [#scrolling-to-an-element-settles-in-one-move] When the agent scrolled to an element to highlight or tap it, list items at the bottom of the screen were mistaken for a bottom bar. The element bounced up and down for about 2 seconds before settling. It now settles in one move. Pinned bars, such as a "Confirm and proceed" footer, are still kept clear. *** ## 1.2.2: upgrading from 1.2.1 [#122-upgrading-from-121] ### Upgrade [#upgrade-2] ```kotlin implementation("ai.revrag:embed-android:1.2.2") ``` **Drop-in.** Nothing has been removed or changed in the public API, the dependencies are the same as 1.2.1, and so are the toolchain requirements (minSdk 24, compileSdk 34, Kotlin 2.0+, JDK 17). No code changes are needed. As before, your repositories must include JitPack (`maven("https://jitpack.io")`). *** ### Fixed [#fixed-2] #### Floating dialogs are no longer stretched to full screen [#floating-dialogs-are-no-longer-stretched-to-full-screen] Since 1.1.0 the SDK moves its button into your dialogs automatically, so the button stays on top of them. In a floating dialog, such as an `AlertDialog`, a default `DialogFragment` or a small loader, that move stretched the dialog to the whole screen. It happened even on screens where the button was hidden. The SDK's own microphone-permission dialog was affected the same way. Automatic adoption now applies only to full-screen windows, such as bottom sheets and full-screen dialogs. Floating dialogs keep their size, and the button stays in the Activity window underneath them. If you added a workaround, such as pinning a dialog's size, you can remove it. To keep the button on top of a floating dialog that you have sized yourself, call `EmbedProvider.attachOverlay(dialog, activity)` as before. #### The agent scrolls an element into view before acting on it [#the-agent-scrolls-an-element-into-view-before-acting-on-it] Before the agent taps, selects, checks, types into or moves an element, it now makes sure the element is on screen. If less of it is showing than the auto-scroll threshold (60% by default, set on the Revrag dashboard), the agent scrolls the element into view, confirms it is visible, and only then acts. Before, an element further down a scrolling list could be selected without the screen moving, so the user never saw the change. If an element cannot be brought on screen, the step now fails instead of acting out of sight. #### Highlights disappear when the agent clears them [#highlights-disappear-when-the-agent-clears-them] When the agent cleared a highlight, the box used to stay on screen for its full duration of 6 to 9 seconds. It is now removed at once. *** ### Improved [#improved] #### The agent stops when the user takes over [#the-agent-stops-when-the-user-takes-over] If the user touches or scrolls your app while the agent is highlighting or acting on it, the agent now stops. The running step is cancelled, the highlight and the edge glow disappear, and the SDK reports the interruption to the agent. Taps on the agent's own button and call bar do not count. A touch while the agent is idle changes nothing. #### The busy edge glow follows your dashboard settings [#the-busy-edge-glow-follows-your-dashboard-settings] The glow around the screen edges while the agent works now uses the Action config from your Revrag dashboard. These are the same settings the React Native and Flutter SDKs read: | Setting | Controls | | ----------------------------- | ----------------------------------- | | `colors.activeBorderGradient` | The glow's colors | | `flags.activeBorderGlow` | Whether the glow shows at all | | `effects.activeBorder` | Its size, boldness and fade timings | The glow now stays on while the agent is working and fades a few seconds after it finishes. Before, it was a fixed 5-second flash in a built-in palette. Dashboard values that previously had no effect on Android now apply, so check that they look the way you want. #### The call bar no longer says "Listening" while the user is muted [#the-call-bar-no-longer-says-listening-while-the-user-is-muted] When the user's microphone is muted and the agent is not speaking, the status line now shows `.....` instead of "Listening". When the agent has just finished speaking, "Speaking" stays for 2 seconds first. *** ### New [#new] Two optional functions control which dialogs the button moves into: ```kotlin EmbedProvider.setAutoAttachDialogs(false) // turn automatic dialog adoption off entirely EmbedProvider.excludeFromWidget(dialog) // keep the button out of one dialog EmbedProvider.excludeFromWidget(window) // the same, for a Compose Dialog's window ``` *** ### Deprecated [#deprecated] `UIActionEffectOverlay()`. The SDK draws the edge glow itself, so nothing needs to call it. Existing calls keep working. *** ## 1.2.1: upgrading from 1.2.0 [#121-upgrading-from-120] ### Upgrade [#upgrade-3] ```kotlin implementation("ai.revrag:embed-android:1.2.1") ``` **Drop-in.** Nothing has been removed or changed in the public API, the dependencies are the same as 1.2.0, and so are the toolchain requirements (minSdk 24, compileSdk 34, Kotlin 2.0+, JDK 17). No code changes are needed. As before, your repositories must include JitPack (`maven("https://jitpack.io")`), which the SDK's LiveKit dependency needs for its audio-routing library. *** ### Fixed [#fixed-3] #### Crash when a call starts: `KotlinReflectionInternalError … requestFocus` [#crash-when-a-call-starts-kotlinreflectioninternalerror--requestfocus] Apps could crash when a call started, or when moving between screens during a call: ``` kotlin.reflect.jvm.internal.KotlinReflectionInternalError: Function 'requestFocus' (JVM signature: requestFocus()Z) not resolved in class androidx.compose.foundation.… at ai.revrag.embed.android.intelligence.UITreeCapture.extractStringValue ``` It needed all three of these: * a release build minified with R8 * an app that includes `kotlin-reflect` * Jetpack Compose foundation 1.9.0 or newer (Compose BOM 2025.08.00 or later) When the SDK read the screen for the agent, it converted Compose's accessibility actions to text. On Compose 1.9+, that conversion throws under `kotlin-reflect` and R8. The SDK no longer converts them. The bug has existed since 1.1.0 but only surfaced once an app moved to Compose 1.9. We verified the fix on a device: the same test app crashes on 1.2.0 and runs normally on 1.2.1. R8 may rename the exception, for example to `kotlinx.coroutines.CoroutinesInternalError` after retracing. The message `Function 'requestFocus' … not resolved` is what identifies this crash. *** ### Improved [#improved-1] **Stability: SDK failures stay inside the SDK** 1.2.0 kept failures inside the widget's own UI from reaching your app. 1.2.1 extends that to the other places where the SDK runs code on your app's threads. It also now catches JVM errors, not just exceptions, such as a `NoSuchMethodError` or `NoClassDefFoundError` from a library-version mismatch. When something fails, only that SDK feature stops; your app keeps running. * **Navigation:** the SDK's listener on your `NavController` can no longer throw into your `navigate()` or `popBackStack()` calls. * **Lifecycle callbacks:** the SDK's handling of these can no longer throw: * fragment start and resume * dialog dismissal * Activity destruction * the app going to the background * back presses * permission results * scroll listeners * **Taps:** the SDK's tap tracking can no longer throw into your touch handling. * **Background work:** failures in the SDK's network requests, events, call handling and on-screen agent missions are contained. * **Mounting the widget:** if `attachOverlay` or `EmbedProviderComposable` fails to mount, your app keeps running without the widget. `attachOverlay` still returns a handle, which safely does nothing. * **Detaching:** `detach()` can no longer throw. * **Initializing:** calling `EmbedSDK.initialize` off the main thread no longer throws. The SDK now registers its app-lifecycle observer on the main thread itself. * **Your listeners:** an `onAvatarState` listener that throws no longer stops your other listeners from being notified. **Calls** * If connecting fails with an unexpected error, the SDK now resets. Before, it could stay stuck in "connecting" and refuse every later call until the app restarted. * If disconnecting fails when a call ends, the call state still resets. * A failure while handling one in-call event no longer stops the SDK from handling the rest of the call's events, such as agent messages, captions and disconnects. **The on-screen agent** * **Sliders:** when the agent types a value into a slider, such as a loan-amount slider, the slider now moves to that value. It accepts `200000`, `2,00,000`, `β‚Ή2,00,000` and `1 500.50`. Words such as "2 lakh" or "max" are refused cleanly, so the agent can retry with a number. Before, these steps always failed. * **Message sending:** if sending one message fails, later messages are no longer held up. * **Screen snapshots:** if a snapshot fails, the SDK skips that snapshot instead of removing the widget. *** ### Changed behaviour [#changed-behaviour] * **Click analytics `element_id`.** * A view's `tag` is now used as its element ID only when the tag is a `String`. * Other tag objects, such as ViewHolders or lambdas, are skipped, and the next fallback (the view's resource ID name) is used. * Before, any tag object was converted to text. * Views with a content description are unaffected, because that is still checked first. * **Debug-build logs.** Each contained failure is logged in debug builds of your app, naming the SDK feature and the error's class, never its message or any user data. Filter with `adb logcat | grep RevragEmbed`. Release builds still print nothing. *** ### Known issue (also in earlier versions) [#known-issue-also-in-earlier-versions] If your release build does not use Android's default ProGuard files (`getDefaultProguardFile(...)`) and your app does not include RecyclerView, R8 can fail with `Missing class androidx.recyclerview.widget.RecyclerView`. Add this to your ProGuard rules: ``` -dontwarn androidx.recyclerview.** ``` *** ## 1.2.0: upgrading from 1.1.0 [#120-upgrading-from-110] ### Upgrade [#upgrade-4] ```kotlin implementation("ai.revrag:embed-android:1.2.0") ``` **Drop-in.** Nothing has been removed or changed in the public API, and the toolchain requirements are the same as 1.1.0 (minSdk 24, compileSdk 34, Kotlin 2.0+, JDK 17). If your app doesn't use the new APIs below, no code changes are needed. *** ### New [#new-1] #### WebView pages as separate screens: `setSubScreen` [#webview-pages-as-separate-screens-setsubscreen] For apps that show a WebView with several pages on one screen or nav route (KYC, DigiLocker, eSign, partner pages). Each web page reaches the backend as its own screen, while the widget follows the route that hosts the WebView. ```kotlin webViewClient = object : WebViewClient() { override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { if (!isReload) embedHandle?.setSubScreen(pageNameFor(url)) // no tokens, IDs or personal data } } ``` * List the **route** that hosts the WebView in `allowedScreens`, not the web pages. * Moving between pages doesn't flicker the widget or end a live call. * Leaving the route clears the page name automatically. * If you used `setCurrentScreen` for web pages, switch to `setSubScreen`: `setCurrentScreen` replaces the route, so an unlisted page name hid the widget. `embedHandle` is the `EmbedOverlayHandle` returned by `EmbedProvider.attachOverlay(...)`. #### App version at initialization [#app-version-at-initialization] ```kotlin EmbedSDK.initialize(context = this, apiKey = key, appVersion = "2.4.0") { result -> } ``` Optional. Without it, the SDK keeps detecting your app's `versionName`, as in 1.1.0. *** ### Improved [#improved-2] **The on-screen agent** * It no longer targets controls hidden behind a bottom sheet or dialog. * It detects disabled buttons and fields and won't tap or type into them. * Custom views that expose themselves through accessibility are now visible to it. * Field labels are linked to their fields, so it types into the right field. * Guided tours skip a missing element instead of stopping, and highlights are centred. * Highlights and taps on Compose bottom sheets and dialogs land in the right place. **Stability** * Bad configuration values from the backend can no longer crash your app (zero-length animations, oversized avatars, negative padding, empty charts). * A failure inside the SDK stays inside it: the widget is removed and your app keeps running. Exceptions thrown in your own SDK listeners are isolated too. * The SDK's screen cache has memory limits, and a leak that kept an Activity alive is fixed. **UI** * The microphone-permission explainer uses its own floating dialog theme, in light and dark. Under some app themes it used to open full-screen. *** ### Changed behaviour [#changed-behaviour-1] * **Logs in debug builds of your app.** The SDK now prints lifecycle logs (initialization, screen changes, visibility decisions, call steps) when your app is a debug build. In 1.1.0 the published SDK printed nothing at all, which made integration hard to diagnose. Filter with `adb logcat | grep RevragEmbed`. These logs never include on-screen text or user data. * **Release builds are unchanged:** the SDK prints nothing. *** ### Known limitations [#known-limitations-1] * `setSubScreen` is Android only; it isn't on iOS yet. * The agent still can't read or fill content inside a WebView. * Two library warnings can appear in release logs and aren't SDK faults: Lottie ("You have N images…", from the avatar animation) and three SLF4J lines at startup. *** More detail: the [Android Integration Guide](/embed/integration/android) (setup, screen naming and WebView steps) and the [Android Copilot Integration prompt](/embed/llmText/android-copilotintegration) (full 1.2.3 contract for AI coding agents). --- # Angular > Angular SDK Integration Guide - add the Revrag AI voice agent to Angular apps, standalone or NgModule (@revrag-ai/embed-angular 2.0.0) URL: /embed/integration/angular Markdown: /embed/integration/angular.md # Revrag Embed β€” Angular Integration Guide [#revrag-embed--angular-integration-guide] The complete guide for adding the Revrag AI voice agent to an Angular app. | | | | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | **What you get** | One floating button that follows your user across every route, and a voice agent that can see your page and act on it | | **Package** | `@revrag-ai/embed-angular` `2.0.0` | | **Angular** | 16 or newer β€” standalone components or NgModule | | **Router** | `@angular/router`, read automatically; apps without a router work too | | **SSR** | Angular Universal / `@angular/ssr` β€” the widget renders nothing on the server | | **TypeScript** | Types included | | **API key** | Get it from [https://app.revrag.ai](https://app.revrag.ai) | **This guide documents `2.0.0`.** Coming from `1.0.7`? One code change is required β€” pass `appVersion` to `initialize()` β€” and Angular 15 is no longer supported. Read the [upgrade steps](/embed/integration/angular/angular-releases#upgrade-steps-107--200) first. `1.0.7` predates most of this guide. It has: * no `appVersion` option * no Action Intelligence β€” the agent cannot see or act on your page * no call controls (`EmbedCallService`), no agent events and no `EmbedLogout()` * no `/diagnostics` entry **Staying on `1.0.7`** (for example on Angular 15)? Follow the [1.0.7 guide](/embed/integration/angular/v1.0.7) instead. **Every Angular app integrates in the same three steps** β€” standalone or NgModule, with or without the router: | Step | What you do | Details | | ----------------- | ----------------------------------------------------------------- | ----------------------------------------- | | 1. **Initialize** | Call `EmbedInitService.initialize()` once, in your root component | [Β§2](#2-initialize) | | 2. **Mount** | Wrap your app in one `` | [Β§3](#3-mount) | | 3. **Identify** | Send your user (`USER_DATA`) after login | [Identify your user](#identify-your-user) | **[Section 5](#5-your-apps-shape) shows the exact setup for your app.** Everything before it applies to every app. *** ## Table of contents [#table-of-contents] 1. [Install](#1-install) β€” package, stylesheet, animations, peers and CSP 2. [Initialize](#2-initialize) β€” one service call, once, at the root 3. [Mount](#3-mount) β€” one provider, and the SDK follows the user 4. [Decide where it appears](#4-decide-where-it-appears) β€” paths, groups, delays, and what the agent can see 5. [Your app's shape](#5-your-apps-shape) β€” **pick one**, complete code 6. [Control it from your own code](#6-control-it-from-your-own-code) β€” calls and the widget card 7. [Events and analytics](#7-events-and-analytics) β€” what you send, what you can listen to 8. [How paths become screen names](#8-how-paths-become-screen-names) 9. [Verify it works](#9-verify-it-works) 10. [Troubleshooting](#10-troubleshooting) 11. [Known limitations](#11-known-limitations) 12. [Checklist](#12-checklist) * [Reference](#reference) *** ## 1. Install [#1-install] Install the package and its peers, add the stylesheet, enable Angular animations, and check the requirements. ### Package [#package] If your app does not already have `@angular/animations` and `@angular/router`, install them **first**, at the same version as your `@angular/core`: ```bash npm ls @angular/core # e.g. 19.2.19 npm install @angular/animations@19.2.19 @angular/router@19.2.19 # same version as @angular/core ``` Then install the SDK and its two runtime peers: ```bash npm install @revrag-ai/embed-angular livekit-client lottie-web # or yarn add @revrag-ai/embed-angular livekit-client lottie-web pnpm add @revrag-ai/embed-angular livekit-client lottie-web ``` * **Why these first:** `@angular/animations` and `@angular/router` are peer dependencies. If one is missing, npm 7+ installs it for you at the newest version `>=16.0.0` allows. That can be a newer major than the rest of your Angular packages, and the install fails with `ERESOLVE`. Pinning both to your `@angular/core` version first avoids it. * **You need `@angular/router` installed even if your app has no router.** The SDK imports it. * **The core engine is bundled inside the package.** Never install or import `@revrag-ai/embed-core`. ### Stylesheet β€” required [#stylesheet--required] Add it to the `styles` array of your build target in `angular.json` (or `project.json` in Nx): ```json "styles": [ "src/styles.css", "node_modules/@revrag-ai/embed-angular/styles/widget.css" ] ``` * **It goes in `projects..architect.build.options.styles`.** * **Restart `ng serve` after changing `angular.json`.** A running dev server does not pick up a new `styles` entry. * **Without it, the button looks broken.** It gets only a minimal fallback style (position and layout). ### Animations β€” required [#animations--required] The widget uses Angular animations. Without the animations provider, the collapsed button still shows, but Angular throws `NG05105: Unexpected synthetic property` as soon as the widget opens (or shows its tooltip or agent panel). Only development builds throw; a production build shows no error, so a missing provider can go unnoticed there. ```ts // src/app/app.config.ts β€” standalone apps import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [provideRouter(routes), provideAnimations()], }; ``` **NgModule apps** import `BrowserAnimationsModule` in `AppModule` instead β€” see [5B](#5b--ngmodule-app). ### Peer dependencies [#peer-dependencies] | Package | Range | Notes | | ---------------------------------------------------------------------------- | ---------- | ------------------------------------------------- | | `@angular/core`, `@angular/common`, `@angular/animations`, `@angular/router` | `>=16.0.0` | The SDK uses signals, which arrived in Angular 16 | | `rxjs` | `^7.4.0` | Every Angular 16+ app already has it | | `livekit-client` | `^2.0.0` | Voice calls | | `lottie-web` | `^5.10.0` | The avatar animation | Your app also needs `@angular/platform-browser` (for the animations provider). Every Angular CLI app already has it. ### Requirements [#requirements] | | | | ---------- | --------------------------------------------------------------------------------------------------------------------- | | Angular | 16 or newer. **Angular 15 and older do not build** | | Browser | Any current browser with WebRTC and `getUserMedia` | | Page | A **secure context** β€” HTTPS, or `localhost` in development. Browsers refuse the microphone anywhere else | | Storage | `sessionStorage` must be available. The widget reads its configuration from it | | Rendering | Safe with SSR: the widget renders nothing on the server and appears in the browser ([5D](#5d--server-side-rendering)) | | TypeScript | Works with `"skipLibCheck": false` since `2.0.0` | | API key | From Revrag, per environment | **Your API key and environment are a pair.** * The SDK talks to `https://embed.revrag.ai` unless you pass `baseUrl`. * A development key used against production (or the reverse) fails initialization. * `EmbedInitService.error$` emits the reason, and the button never appears. ### Content-Security-Policy [#content-security-policy] If your app sends a CSP, merge these in (never replace your policy): | Directive | Allow | Why | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connect-src` | `https://embed.revrag.ai` (or your `baseUrl` host) | Initialization, events, call token, screen map | | `connect-src` | The call server, over **both** `wss:` and `https:` | Voice. Its host is the `server_url` in each call's token response (`/embedded-agent/token`); ask Revrag for it. On Revrag's cloud it is usually `wss://*.livekit.cloud` and `https://*.livekit.cloud` | | `connect-src` | Your avatar animation's host, if the dashboard sets one | The SDK fetches the animation file; if it cannot, it shows the bundled orb | | `img-src` | `data:`, plus your avatar image's host if the dashboard sets an image avatar | The call icons and the default avatar are bundled as `data:` images | | `style-src` | Nothing new if your app's Angular styles already pass your CSP (nonce or `'unsafe-inline'`). `'unsafe-inline'` **only** if `widget.css` is not loaded | The SDK's component styles are added like your own. A fallback `