# An AI agent in your Android app

To put an AI agent inside your Android app, add Vatio's Kotlin library, create a publishable key that allows your app's application id, and collect a `VatioChat` in your own Compose screen. The agent is the same one that answers in your web widget, WhatsApp and Instagram. In the app it can also know who the user is: your backend signs a token, and the agent's private tools read that user's own data.

This guide is the task path; every method and option is in the [Android SDK reference](https://vatio.ai/docs/sdk/android).

## Start from an agent

1. **Fastest, from any chat.** Add the remote MCP server `https://vatio.ai/mcp` as a connector in Claude, ChatGPT or Cursor, sign in with a Vatio account and ask for the agent. It is created in a preview to try; nothing reaches users until you publish ([Vatio in Claude and ChatGPT](https://vatio.ai/docs/mcp)).
2. **To build the agent into your app's code.** In the app's project run `npx @vatio-ai/skills`, then `/vatio-init` (in Cursor or Codex, ask it to use that skill), then ask your coding agent to add the library, the tokens and a chat screen in your app's style, and `/vatio-improve` daily. See [Build an AI agent](https://vatio.ai/docs/guides/build-an-ai-agent).

The steps below are what your coding agent adds, for you to review, using a pet-care app as the example. For iPhone, see the [iOS guide](https://vatio.ai/docs/guides/ai-agent-in-your-ios-app); for Flutter, the [Flutter guide](https://vatio.ai/docs/guides/ai-agent-in-your-flutter-app).

## Why put the agent in the app

A user signed in to your app is a visitor whose identity you already know. On WhatsApp and Instagram, a contact has to [sign in on your site](https://vatio.ai/docs/authentication/sessions#on-whatsapp) before a private tool runs. In the app, the same agent can answer "when is Luna's next appointment?" from that owner's own records.

It is not a second agent. A workspace runs one agent, and the app is one more channel for it. Instructions, tools, knowledge and the inbox are shared, so a fix you publish reaches every channel at once.

## What the SDK gives you, and what you build

The SDK is a Kotlin library for Android 8.0 (API 26) or later. Its only dependencies are OkHttp and kotlinx.coroutines, it declares the `INTERNET` permission itself, and it has no UI.

| The SDK handles | You build |
|---|---|
| The conversation as `StateFlow`s: `messages`, `isTyping`, `status` | Every screen: message list, bubbles, composer, typing indicator |
| The user's message shown at once with `isPending`, then confirmed | Putting the text back in the field when `send` throws |
| Reconnecting with backoff, polling meanwhile, fetching missed messages, deduplicating by id | Rendering `content`, which is Markdown the agent wrote: treat it as untrusted |
| Storing the conversation in `SharedPreferences`, per workspace and signed-in user | The photo picker and voice recorder that produce uploads |
| Uploads, voice-note transcription and the feedback moment, as state | The rating card |
| The agent's name, avatar, accent color, greeting and suggestions, from `vatio.config()` | Your sign-in, and the backend endpoint that signs the user's token |

## Allow your app and add the library

Create a publishable key for the app's package name (`android-app://` plus the package name) and add the library from JitPack. The exact commands and Gradle snippets, and the rule that a `vat_` developer token never ships in an app, are in [Setup](https://vatio.ai/docs/sdk/android#setup):

```bash
vatio keys create publishable --env live --label android --origin android-app://example.pawline.app
```

A key that does not allow your package name fails with `origin_not_allowed`. A debug build with an `applicationIdSuffix` has a different package name (`example.pawline.app.debug`), so it needs that origin too, usually on the preview key below. The origin keeps the key on your app; it says nothing about who the user is. That comes from your backend, below.

## A first conversation

Keep the chat in a `ViewModel`, so it survives rotation, and close it when the `ViewModel` goes away:

```kotlin
class AssistantViewModel(application: Application) : AndroidViewModel(application) {
    private val vatio = Vatio(application, workspace = "pawline", token = BuildConfig.VATIO_TOKEN)

    var chat by mutableStateOf(VatioChat(vatio))
        private set

    fun send(text: String, restore: (String) -> Unit) {
        viewModelScope.launch {
            try { chat.send(text) }
            catch (e: VatioException) { restore(text) }   // send removed the pending message
        }
    }

    override fun onCleared() = chat.close()
}
```

Then collect it in Compose:

```kotlin
@Composable
fun AssistantScreen(model: AssistantViewModel = viewModel()) {
    val chat = model.chat
    val messages by chat.messages.collectAsStateWithLifecycle()
    val typing by chat.isTyping.collectAsStateWithLifecycle()
    var text by remember { mutableStateOf("") }

    LaunchedEffect(chat) { chat.resume() }

    Column(Modifier.fillMaxSize()) {
        LazyColumn(
            Modifier.weight(1f).padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            items(messages) { message ->
                Text(
                    message.content,
                    Modifier.fillMaxWidth().alpha(if (message.isPending) 0.6f else 1f),
                    textAlign = if (message.isFromVisitor) TextAlign.End else TextAlign.Start,
                )
            }
            if (typing) item { CircularProgressIndicator() }
        }
        Row(Modifier.padding(16.dp)) {
            OutlinedTextField(text, { text = it }, Modifier.weight(1f), placeholder = { Text("Ask anything") })
            Button(onClick = { val question = text; text = ""; model.send(question) { text = it } }) {
                Text("Send")
            }
        }
    }
}
```

Creating a `VatioChat` opens nothing: the conversation starts on the first `send`, so a user who never writes never becomes a contact. `resume()` reopens the stored conversation if there is one and never starts a new one. `send` throws a `VatioException` with a `code`; failures outside a `send` land in `chat.lastError`. The [example app](https://github.com/vatio-ai/android/tree/main/example) in the repository is a complete version, with an ask bar on a home screen, a conversation tab, photos and voice notes. [Vatio Chat](https://github.com/vatio-ai/vatio-chat-android) goes further: a ChatGPT-style app with the conversation list, the feedback card and settings, each in a file of its own.

## Sign the user in with a token your backend signs

To tell the agent who the user is, your backend signs a JWT and the app passes it along. **The private key never goes in the app**: an APK can be unpacked, and with that key anyone could claim to be any of your users. Vatio holds only the public half, so it can check an identity but never mint one.

Run `vatio auth --new-key` once. `identity.pub` stays in the workspace, named by the `auth:` block in `vatio.yml`. `identity.pem` goes into your backend as a secret, not into `vatio secrets` and not into the app. Then add an endpoint behind your app's own login that signs a short-lived RS256 token:

```js
// Your backend, e.g. POST /me/vatio-token, behind your existing login
import jwt from "jsonwebtoken";

const visitorToken = jwt.sign(
  { sub: String(user.id), name: user.name, email: user.email },
  process.env.VATIO_IDENTITY_PRIVATE_KEY,
  { algorithm: "RS256", audience: "pawline", expiresIn: "1h" }
);
```

`sub` becomes `$auth.subject`, `aud` must be the workspace slug exactly, and `exp` is re-checked on every tool call. With Supabase Auth, an Edge Function can read the session and sign it ([Supabase apps](https://vatio.ai/docs/guides/ai-agent-for-supabase-apps)).

`VatioChat` takes the token when it is created, so signing in or out means a new one. Add this to the `ViewModel`; the `LaunchedEffect(chat)` above resumes whichever chat is current:

```kotlin
// visitorToken: what your backend's /me/vatio-token returned
fun signedIn(visitorToken: String) {
    chat.close()
    chat = VatioChat(vatio, visitorToken = visitorToken)
}

fun signedOut() {
    chat.close()
    chat = VatioChat(vatio)
}
```

## Private tools now run for that user

With a verified token, tools marked `access: private` run, and they reach your API with the user's own token. `tools/my_appointments.yml`:

```yaml
description: The signed-in owner's upcoming appointments, for each of their pets.
when_to_use: A signed-in owner asks about their own pets' appointments.
access: private
request:
  method: GET
  base_url: "$env.PAWLINE_API"
  path: /me/appointments
  headers:
    Authorization: "Bearer $auth.token"
respond:
  data:
    appointments: "$.appointments"
  message:
    when_empty: This owner has no upcoming appointments.
    default: "Found {{count}} upcoming appointments."
```

Your endpoint verifies the JWT with the same public key and scopes the query to its `sub`. The tool takes no owner id, and it cannot: a private tool that declares a parameter such as `user_id`, `customer_id` or `account_id` fails the deploy, because the model fills parameters and can be talked into filling in someone else's.

```yaml
workspace: pawline
agent:
  name: Pawline
  instructions: |
    You help Pawline customers with their pets' care: services, opening
    hours and their own appointments.

    Services and hours: answer from knowledge_lookup.
    An owner's own appointments: call my_appointments.
    If you cannot see their appointments because they are not signed in,
    ask them to sign in to the app first. Never guess an appointment.
  tools: [my_appointments, knowledge_lookup]
  knowledge: [services]
auth:
  public_key: identity.pub
```

The knowledge answers someone who has not signed in too, in the app or on any other channel. [Tools](https://vatio.ai/docs/tools) and [Authentication](https://vatio.ai/docs/authentication/) cover tools and tokens in depth.

## Conversation continuity

The SDK stores the conversation per workspace and signed-in user; call `resume()` when the screen appears. An anonymous conversation carries over when the user signs in, a different `sub` gets its own conversation, and chat credentials last 12 hours ([Delivery and storage](https://vatio.ai/docs/sdk/android#delivery-and-storage)).

## Photos and voice notes

`send(text, attachments)` takes up to four files, and a voice note is transcribed before the agent answers. The types, sizes and the `isMediaLabel` rule are in [Files and voice notes](https://vatio.ai/docs/sdk/android#files-and-voice-notes). Reading a picked file with `VatioUpload.fromUri` must happen off the main thread, and recording needs the `RECORD_AUDIO` permission, asked for at runtime.

## Test against preview

The key decides which environment the app talks to, so a debug build should not carry the live one. Create one for `preview`, with the debug build's package name:

```bash
vatio push
vatio keys create publishable --env preview --label android-dev --origin android-app://example.pawline.app.debug
```

Pick the key per build type:

```kotlin
// app/build.gradle.kts
android {
    buildFeatures { buildConfig = true }
    buildTypes {
        debug {
            applicationIdSuffix = ".debug"
            buildConfigField("String", "VATIO_TOKEN", "\"vatpub_PREVIEW_TOKEN\"")
        }
        release {
            buildConfigField("String", "VATIO_TOKEN", "\"vatpub_LIVE_TOKEN\"")
        }
    }
}
```

`vatio chat` speaks as you, the developer, not as an owner, so test private tools from the app itself. Sign in on the emulator, ask about your appointments, and read the conversation back with `vatio chat show CHAT_ID` (`chat.conversationId` holds the id). It shows every tool call, what your backend answered and whether the token was accepted: a wrong key, an expired `exp` or a wrong `aud` leaves the user anonymous instead of failing. `no_agent_deployed` means nothing is deployed to the token's environment yet. Then ask about another owner's pet: with no id parameter there is nothing for the model to fill in, and your endpoint answers only for the token's `sub`.

## Limits

Message length, file limits, rate limits and reply style are the same as on the web: see [SDK limits](https://vatio.ai/docs/sdk/limits). On rate limiting the SDK throws `rate_limited`: back off rather than retry at once.

## What the SDK does not do

- **No push notifications.** Nothing in the SDK registers for them. Replies arrive while the app is connected, and when it comes back from the background the SDK reconnects and fetches what it missed.
- **No voice calls.** Voice notes, yes; a live voice conversation, no.
- **No files from the agent.** It reads images, audio and PDFs, but its replies are text: it cannot generate or send files or images.
- **No first messages.** The agent answers what the user sends; it never starts a conversation.

## Ship it

When the preview answers well, publishing is your decision: ask to publish (`vatio publish`), which goes through the checks. The Free plan includes the owner's own WhatsApp number or Instagram account if you want those channels too; see [Pricing](https://vatio.ai/pricing).

Ship the release build with the live key. Conversations from the app land in the same inbox as the web and any other channel, with every tool call. A supervisor can take one over or report a wrong reply with **Report this reply**, and each report becomes a test: `/vatio-improve` fixes the cause and tests it on preview, and you approve publication. See [Improve](https://vatio.ai/docs/improve).

## Frequently asked questions

### How do I add an AI chat agent to my Android app?

Add the library com.github.vatio-ai:android from JitPack, create a publishable key whose allowed origin is android-app:// plus your application id, and create a VatioChat with it. The SDK keeps the conversation as StateFlows, so a Compose screen that collects them redraws as replies arrive. If you build the app with Claude Code, Cursor or Codex, ask your coding agent to do each of these steps and write the chat screen.

### Is it safe to ship the Vatio token inside an Android app?

The publishable vatpub_ key is meant to ship in the app. It selects one workspace and environment and only works from its allowed origins, but it does not prove who the user is. Never put a vat_ developer token or your identity private key in an app.

### Can the agent read the signed-in user's own data in my app?

Yes. Your backend signs a short-lived RS256 JWT for the user, the app passes it to VatioChat as the visitor token, and tools marked access: private run for that user. Your endpoint verifies the same token and scopes the query to its sub. With Supabase Auth, an Edge Function signs it.

### Does the Vatio Android SDK include a chat UI?

No. It is a Kotlin library with no UI: it gives you messages, typing, connection status, files and the feedback moment as StateFlows, and you draw the screens in Compose or Views. The repository includes a runnable Compose example app.

### Does the Android SDK support push notifications or voice calls?

No. Replies arrive over a WebSocket while the app is connected, and the SDK fetches anything it missed when it reconnects. Users can send voice notes, which are transcribed before the agent answers, but there are no voice calls and the agent replies in text.

## Related guides

- [An AI agent in your iOS app](https://vatio.ai/docs/guides/ai-agent-in-your-ios-app): The same agent inside your iPhone app with the iOS SDK.
- [An AI chat for your web app or site](https://vatio.ai/docs/guides/ai-chat-widget-for-your-website): One script tag puts the agent in your web app or site.
- [An AI agent on WhatsApp](https://vatio.ai/docs/guides/whatsapp-ai-agent): Add WhatsApp to your app's agent.
- [Build an AI agent for your app](https://vatio.ai/docs/guides/build-an-ai-agent): The two-step entry and trying the preview before publishing.
