<!-- https://vatio.ai/guides/ai-agent-in-your-ios-app · Updated: 2026-10-11 -->

> Put the AI agent you run on WhatsApp and the web inside your iPhone app with Vatio's Swift package, signed in as your user, in a SwiftUI chat you design.

# An AI agent in your iOS app

To put an AI agent inside your iPhone app, add Vatio's Swift package, create a publishable token that allows your app's bundle id, and bind a `VatioChat` to your own SwiftUI views. The agent is the same one you describe in one `vatio.yml` and run on WhatsApp, Instagram and the web. 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 adds an assistant to a gym's app, from the package to a signed-in member asking about their bookings.

## 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 "which classes am I booked into?" from that member'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, handoff rules 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 Swift package for iOS 15 or later, with no dependencies beyond Foundation and Combine, and no UI.

| The SDK handles | You build |
|---|---|
| The conversation as observable state: `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 `UserDefaults`, 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, and any progressive "typewriter" effect |
| 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: a publishable token

The app talks to Vatio with a publishable token (`vatpub_…`), which is meant to ship inside it. A token selects one workspace and one environment, and works only from its allowed origins. An iOS app's origin is `ios-app://` plus its bundle id:

```bash
vatio tokens create --env live --label ios --origin ios-app://example.northpoint.app
```

`create` prints the full token once. To add the app to an existing token, `vatio tokens origins PREFIX URL...` replaces that token's list, so repeat the origins it already had; the tokens section of **Channels → Web widget** in the inbox does the same. The SDK sends your bundle id from `Bundle.main`, in lowercase, and a token that does not allow it fails with `origin_not_allowed`.

The origin keeps the token on your app. It says nothing about who the user is; that comes from your backend, below. Never put a developer token (`vat_…`) in an app: it can deploy, read secrets and mint tokens.

## Add the package

In Xcode, choose File → Add Package Dependencies…, enter `https://github.com/vatio-ai/ios` and add the `Vatio` library to your app target. In a `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/vatio-ai/ios", from: "0.1.0")
],
targets: [
    .target(name: "NorthpointKit", dependencies: [.product(name: "Vatio", package: "ios")])
]
```

Tags follow semver, so `from:` never moves you to a new major version by surprise.

## A first conversation

```swift
import SwiftUI
import Vatio

struct AssistantView: View {
    @ObservedObject var chat: VatioChat
    @State private var text = ""

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(spacing: 12) {
                    ForEach(chat.messages) { message in
                        Text(LocalizedStringKey(message.content))   // basic Markdown
                            .frame(maxWidth: .infinity,
                                   alignment: message.isFromVisitor ? .trailing : .leading)
                            .opacity(message.isPending ? 0.6 : 1)
                    }
                    if chat.isTyping { ProgressView() }
                }
                .padding()
            }
            HStack {
                TextField("Ask anything", text: $text)
                Button("Send", action: send)
            }
            .padding()
        }
        .task { await chat.resume() }
    }

    private func send() {
        let question = text
        text = ""
        Task {
            do { try await chat.send(question) }
            catch { text = question }   // send removed the pending message
        }
    }
}
```

Create the chat once, for example as a `@StateObject` holding `VatioChat(Vatio(workspace: "northpoint-fitness", token: "vatpub_..."))`. Creating it 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 `VatioError` with a `code`; failures outside a `send` land in `chat.lastError`.

## 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 app binary 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: "northpoint-fitness", expiresIn: "1h" }
);
```

`sub` becomes `$auth.subject`, `aud` must be the workspace slug exactly, and `exp` is re-checked on every tool call. Add a `metadata` claim with a `profile` link and `badges` and the inbox shows them in the conversation header, so whoever reads it knows which member it is.

`VatioChat` takes the token when it is created, so signing in or out means a new one. A small store, like the one in the SDK's example app, does it:

```swift
@MainActor
final class AssistantStore: ObservableObject {
    @Published private(set) var chat: VatioChat
    private let vatio: Vatio

    init(vatio: Vatio) {
        self.vatio = vatio
        chat = VatioChat(vatio)
    }

    // visitorToken: what your backend's /me/vatio-token returned
    func signedIn(visitorToken: String) async {
        chat.close()
        chat = VatioChat(vatio, visitorToken: visitorToken)
        await chat.resume()
    }

    func 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_bookings.yml`:

```yaml
description: The signed-in member's upcoming class bookings.
when_to_use: A signed-in member asks about their own bookings or classes.
access: private
request:
  method: GET
  base_url: "$env.NORTHPOINT_API"
  path: /me/bookings
  headers:
    Authorization: "Bearer $auth.token"
respond:
  data:
    bookings: "$.bookings"
  message:
    when_empty: This member has no upcoming bookings.
    default: "Found {{count}} upcoming bookings."
```

Your endpoint verifies the JWT with the same public key and scopes the query to its `sub`. The tool takes no member id, and it cannot: a private tool that declares `member_id`, `user_id`, `customer_id`, `account_id`, `patient_id` or `subject` fails the deploy, because the model fills parameters and can be talked into filling in someone else's. `book_class` is private too, and takes only the class id.

```yaml
workspace: northpoint-fitness
agent:
  name: Northpoint
  instructions: |
    You help members of Northpoint Fitness with classes, schedules and
    their own bookings.

    Schedules: call class_schedule.
    A member's own bookings: call my_bookings, and book_class to book.
    If you cannot see their bookings because they are not signed in,
    ask them to sign in to the app first. Never guess a booking.
  tools: [class_schedule, my_bookings, book_class, remember, recall]
auth:
  public_key: identity.pub
```

`class_schedule` is public, so it also answers on WhatsApp and Instagram for someone who has not signed in. [Connect your backend](/guides/connect-your-backend) covers tools and tokens in depth.

## Conversation continuity

- **Reopening.** The conversation and the visitor id are stored in `UserDefaults`, per workspace and per signed-in user. Call `resume()` when the view appears.
- **Signing in keeps the conversation.** An anonymous conversation carries over when the user signs in, so the question that prompted the sign-in does not need retyping. A fresh token for the same `sub` keeps the same conversation.
- **A different user is a different conversation.** A token with another `sub` gets its own conversation and history, so signing out of your app signs out of the chat, and a shared phone never shows one member another's.
- **Twelve hours.** Chat credentials last 12 hours. After that, `resume()` finds nothing and the next `send` starts a new conversation; the history stays on the server.
- **Past conversations.** `vatio.conversations(visitorToken:)` lists this user's conversations on this device, newest first, and `chat.open(_:)` switches to one. `chat.startOver()` forgets the stored one without deleting anything on the server.
- **Memory.** Memory belongs to the contact: with `remember` and `recall` enabled, the agent sees the notes kept about a signed-in user, so a new conversation can start from what earlier ones established.

## Photos and voice notes

`send(_:attachments:)` takes up to four `VatioUpload`s: images (JPEG, PNG, WebP, GIF, HEIC), PDFs, text files or audio (M4A, MP3, OGG, WAV, AAC, FLAC), up to 8 MB each. With files, the text may be empty:

```swift
try await chat.send("Is this the right grip?", attachments: [
    VatioUpload(data: jpegData, filename: "photo.jpg", contentType: "image/jpeg")
])
try await chat.send(attachments: [try VatioUpload(fileURL: recordingURL)])  // AVAudioRecorder .m4a
```

A voice note is transcribed in the background: the message reads "Audio" at first, its `content` becomes the transcript a few seconds later, and the agent answers after that. When `isMediaLabel` is true, draw the attachment instead of the word. Recording needs `NSMicrophoneUsageDescription` in your Info.plist.

## Test against preview

The token decides which environment the app talks to, so a development build should not carry the live one. Create one for `preview`:

```bash
vatio push
vatio tokens create --env preview --label ios-dev --origin ios-app://example.northpoint.app
```

`vatio env` shows preview's details. Pick the token per build configuration:

```swift
#if DEBUG
let vatio = Vatio(workspace: "northpoint-fitness", token: "vatpub_PREVIEW_TOKEN")
#else
let vatio = Vatio(workspace: "northpoint-fitness", token: "vatpub_LIVE_TOKEN")
#endif
```

`vatio chat` speaks as you, the developer, not as a member, so test private tools from the app itself. Sign in on the simulator, ask about your bookings, 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 for another member's bookings: with no id parameter there is nothing for the model to fill in, and your endpoint answers only for the token's `sub`.

## Limits

From the [SDK limits](https://vatio.ai/docs/sdk/limits), the same as on the web:

- **Messages:** up to 4,000 characters (`content_too_long`), with up to four files of 8 MB each.
- **File types** are read from the bytes, not the name; a type the agent cannot read is refused with `unsupported_file_type`.
- **Rate limits,** per IP and workspace, per minute: 10 conversations started, 30 messages sent, 60 configuration reads and 60 conversation lists. Over that, requests answer HTTP 429 and the SDK throws `rate_limited`: back off rather than retry at once.
- **Credentials** last 12 hours; an expired one shows up as `subscription_rejected`, and the next `send` starts a new conversation.
- **Reply style** is fixed when the conversation starts: `.stream` (the default: one complete reply, not token by token), `.paced` (short messages with typing indicators) or `.instant`. The example app paints a reply progressively on screen.

## 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

```bash
vatio publish
```

Ship the build with the live token. Conversations from the app land in the same inbox as WhatsApp and the web, with every tool call. A supervisor can take one over or flag a wrong reply, and each flag becomes an eval case your coding agent fixes from: see [Improve your agent](/guides/improve-your-ai-agent). The web version of this setup is [An AI chat for your website](/guides/ai-chat-widget-for-your-website), and every method is in the [iOS SDK docs](https://vatio.ai/docs/sdk/ios).

## Frequently asked questions

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

Add the Swift package at https://github.com/vatio-ai/ios, create a publishable token whose allowed origin is ios-app:// plus your bundle id, and create a VatioChat with it. The SDK keeps the conversation as observable state, so a SwiftUI view that observes it redraws as replies arrive. The agent is the same vatio.yml that serves your other channels.

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

The publishable vatpub_ token 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.

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

No. It is a Swift package with no UI and no dependencies: it gives you messages, typing, connection status, files and the feedback moment as state, and you draw the screens. The package README has a complete SwiftUI example, and the repository includes a runnable example app.

### Does the iOS 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.
