<!-- https://vatio.ai/es/guias/agente-ia-en-tu-app-ios · Actualizado: 2026-10-11 -->

> Pon en tu app de iPhone el mismo agente de IA que tienes en WhatsApp y la web, con el paquete Swift de Vatio, autenticado como tu usuario y con tu UI.

# Un agente de IA en tu app iOS

Para poner un agente de IA dentro de tu app de iPhone, agregas el paquete Swift de Vatio, creas un token publicable que permita el bundle id de tu app y conectas un `VatioChat` a tus propias vistas de SwiftUI. El agente es el mismo que describes en un archivo `vatio.yml` y que responde en WhatsApp, Instagram y la web. En la app, además, puede saber quién es el usuario: tu backend firma un token, y las tools privadas del agente leen los datos de ese usuario.

Esta guía le agrega un asistente a la app de un gimnasio, desde el paquete hasta un socio autenticado que pregunta por sus reservas.

## Por qué poner el agente en la app

Un usuario que inició sesión en tu app es un visitante cuya identidad ya conoces. En WhatsApp e Instagram, el contacto tiene que [iniciar sesión en tu sitio](https://vatio.ai/docs/authentication/sessions#on-whatsapp) antes de que corra una tool privada. En la app, el mismo agente puede responder «¿a qué clases estoy inscrito?» con los datos de ese socio.

No es un segundo agente. Un workspace corre un solo agente, y la app es un canal más para él. Las instrucciones, las tools, el conocimiento, las reglas de derivación y el inbox son los mismos, así que una corrección que publicas llega a todos los canales a la vez.

## Qué te da el SDK y qué construyes tú

El SDK es un paquete Swift para iOS 15 o posterior, sin más dependencias que Foundation y Combine, y sin UI.

| El SDK se encarga de | Tú construyes |
|---|---|
| La conversación como estado observable: `messages`, `isTyping`, `status` | Cada pantalla: lista de mensajes, burbujas, compositor, indicador de escritura |
| El mensaje del usuario visible de inmediato con `isPending`, y después confirmado | Devolver el texto al campo cuando `send` lanza un error |
| Reconectar con backoff, hacer polling mientras tanto, traer lo perdido y deduplicar por id | Mostrar `content`, que es Markdown escrito por el agente: trátalo como no confiable |
| Guardar la conversación en `UserDefaults`, por workspace y por usuario con sesión | El selector de fotos y la grabadora que producen los archivos adjuntos |
| Los adjuntos, la transcripción de notas de voz y el momento de feedback, como estado | La tarjeta de calificación y cualquier efecto de texto progresivo |
| El nombre, avatar, color de acento, saludo y sugerencias del agente, con `vatio.config()` | Tu inicio de sesión y el endpoint de tu backend que firma el token del usuario |

## Permite tu app: un token publicable

La app habla con Vatio usando un token publicable (`vatpub_…`), que está pensado para ir dentro de ella. Un token corresponde a un workspace y un entorno, y solo funciona desde sus orígenes permitidos. El origen de una app iOS es `ios-app://` más su bundle id:

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

`create` imprime el token completo una sola vez. Para agregar la app a un token que ya existe, `vatio tokens origins PREFIX URL...` reemplaza la lista de ese token, así que repite los orígenes que ya tenía; la sección de tokens de **Canales → Widget web**, en el inbox, hace lo mismo. El SDK envía el bundle id de `Bundle.main` en minúsculas, y un token que no lo permite falla con `origin_not_allowed`.

El origen mantiene el token en tu app. No dice nada sobre quién es el usuario; eso lo dice tu backend, más abajo. Nunca pongas un token de desarrollador (`vat_…`) en una app: con él se puede hacer deploy, leer secrets y crear tokens.

## Agrega el paquete

En Xcode, elige File → Add Package Dependencies…, escribe `https://github.com/vatio-ai/ios` y agrega la librería `Vatio` al target de tu app. En un `Package.swift`:

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

Los tags siguen semver, así que `from:` nunca te pasa a una nueva versión mayor por sorpresa.

## Una primera conversación

```swift
import SwiftUI
import Vatio

struct AsistenteView: View {
    @ObservedObject var chat: VatioChat
    @State private var texto = ""

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(spacing: 12) {
                    ForEach(chat.messages) { message in
                        Text(LocalizedStringKey(message.content))   // Markdown básico
                            .frame(maxWidth: .infinity,
                                   alignment: message.isFromVisitor ? .trailing : .leading)
                            .opacity(message.isPending ? 0.6 : 1)
                    }
                    if chat.isTyping { ProgressView() }
                }
                .padding()
            }
            HStack {
                TextField("Pregunta lo que quieras", text: $texto)
                Button("Enviar", action: enviar)
            }
            .padding()
        }
        .task { await chat.resume() }
    }

    private func enviar() {
        let pregunta = texto
        texto = ""
        Task {
            do { try await chat.send(pregunta) }
            catch { texto = pregunta }   // send quitó el mensaje pendiente
        }
    }
}
```

Crea el chat una sola vez, por ejemplo como un `@StateObject` con `VatioChat(Vatio(workspace: "gimnasio-volcan", token: "vatpub_..."))`. Crearlo no abre nada: la conversación empieza con el primer `send`, así que un usuario que nunca escribe nunca se vuelve contacto. `resume()` reabre la conversación guardada si existe, y nunca empieza una nueva. `send` lanza un `VatioError` con un `code`; los errores fuera de un `send` quedan en `chat.lastError`.

## Autentica al usuario con un token que firma tu backend

Para decirle al agente quién es el usuario, tu backend firma un JWT y la app lo pasa. **La llave privada nunca va en la app**: un binario se puede desarmar, y con esa llave cualquiera podría hacerse pasar por cualquiera de tus usuarios. Vatio guarda solo la mitad pública, así que puede verificar una identidad pero nunca crearla.

Corre `vatio auth --new-key` una vez. `identity.pub` se queda en el workspace, referenciado en el bloque `auth:` de `vatio.yml`. `identity.pem` va a tu backend como secret, no a `vatio secrets` ni a la app. Después agrega un endpoint, detrás del login de tu app, que firme un token RS256 de corta duración:

```js
// Tu backend, por ejemplo POST /me/vatio-token, detrás de tu login actual
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: "gimnasio-volcan", expiresIn: "1h" }
);
```

`sub` pasa a ser `$auth.subject`, `aud` tiene que ser exactamente el slug del workspace, y `exp` se vuelve a revisar en cada llamada a una tool. Si agregas un claim `metadata` con un link `profile` y `badges`, el inbox los muestra en el encabezado de la conversación, para que quien la lea sepa de qué socio se trata.

`VatioChat` recibe el token al crearse, así que iniciar o cerrar sesión significa crear uno nuevo. Un store pequeño, como el de la app de ejemplo del SDK, se encarga:

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

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

    // visitorToken: lo que devolvió /me/vatio-token en tu backend
    func inicioSesion(visitorToken: String) async {
        chat.close()
        chat = VatioChat(vatio, visitorToken: visitorToken)
        await chat.resume()
    }

    func cierreSesion() {
        chat.close()
        chat = VatioChat(vatio)
    }
}
```

## Las tools privadas ahora corren para ese usuario

Con un token verificado, corren las tools marcadas `access: private`, y llegan a tu API con el token del propio usuario. `tools/mis_reservas.yml`:

```yaml
description: Las próximas reservas de clases del socio autenticado.
when_to_use: Un socio autenticado pregunta por sus propias reservas o clases.
access: private
request:
  method: GET
  base_url: "$env.VOLCAN_API"
  path: /me/reservas
  headers:
    Authorization: "Bearer $auth.token"
respond:
  data:
    reservas: "$.reservas"
  message:
    when_empty: Este socio no tiene reservas próximas.
    default: "Encontré {{count}} reservas próximas."
```

Tu endpoint verifica el JWT con la misma llave pública y limita la consulta a su `sub`. La tool no recibe un id de socio, y no puede: una tool privada que declara `member_id`, `user_id`, `customer_id`, `account_id`, `patient_id` o `subject` hace fallar el deploy, porque los parámetros los llena el modelo y se le puede convencer de poner el de otra persona. `reservar_clase` también es privada y recibe solo el id de la clase.

```yaml
workspace: gimnasio-volcan
agent:
  name: Volcán
  instructions: |
    Ayudas a los socios de Gimnasio Volcán con clases, horarios y sus
    propias reservas.

    Horarios: llama a horario_clases.
    Las reservas del socio: llama a mis_reservas, y a reservar_clase para
    reservar. Si no puedes ver sus reservas porque no inició sesión,
    pídele que entre a la app primero. Nunca inventes una reserva.
  tools: [horario_clases, mis_reservas, reservar_clase, remember, recall]
auth:
  public_key: identity.pub
```

`horario_clases` es pública, así que también responde en WhatsApp e Instagram a quien no ha iniciado sesión. [Conectar tu backend](/es/guias/conectar-tu-backend) profundiza en las tools y los tokens.

## Continuidad de la conversación

- **Reabrir.** La conversación y el id del visitante se guardan en `UserDefaults`, por workspace y por usuario con sesión. Llama a `resume()` cuando aparezca la vista.
- **Iniciar sesión conserva la conversación.** Una conversación anónima continúa cuando el usuario inicia sesión, así que no tiene que volver a escribir la pregunta que lo llevó a hacerlo. Un token nuevo para el mismo `sub` mantiene la misma conversación.
- **Otro usuario es otra conversación.** Un token con otro `sub` tiene su propia conversación e historial, así que cerrar sesión en tu app también la cierra en el chat, y un teléfono compartido nunca le muestra a un socio la conversación de otro.
- **Doce horas.** Las credenciales del chat duran 12 horas. Después, `resume()` no encuentra nada y el siguiente `send` empieza una conversación nueva; el historial se queda en el servidor.
- **Conversaciones anteriores.** `vatio.conversations(visitorToken:)` lista las conversaciones de este usuario en este dispositivo, la más reciente primero, y `chat.open(_:)` cambia a una de ellas. `chat.startOver()` olvida la guardada sin borrar nada en el servidor.
- **Memoria.** La memoria es del contacto: con `remember` y `recall` activadas, el agente ve las notas guardadas sobre un usuario autenticado, así que una conversación nueva puede partir de lo que dejaron establecido las anteriores.

## Fotos y notas de voz

`send(_:attachments:)` recibe hasta cuatro `VatioUpload`: imágenes (JPEG, PNG, WebP, GIF, HEIC), PDF, archivos de texto o audio (M4A, MP3, OGG, WAV, AAC, FLAC), de hasta 8 MB cada uno. Con archivos, el texto puede ir vacío:

```swift
try await chat.send("¿Este es el agarre correcto?", attachments: [
    VatioUpload(data: jpegData, filename: "foto.jpg", contentType: "image/jpeg")
])
try await chat.send(attachments: [try VatioUpload(fileURL: grabacionURL)])  // un .m4a de AVAudioRecorder
```

Una nota de voz se transcribe en segundo plano: el mensaje dice "Audio" al principio, su `content` pasa a ser la transcripción unos segundos después, y el agente responde después de eso. Cuando `isMediaLabel` es true, dibuja el adjunto en vez de la palabra. Grabar requiere `NSMicrophoneUsageDescription` en tu Info.plist.

## Prueba contra preview

El token decide con qué entorno habla la app, así que un build de desarrollo no debería llevar el de live. Crea uno para `preview`:

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

`vatio env` muestra los datos de preview. Elige el token según la configuración del build:

```swift
#if DEBUG
let vatio = Vatio(workspace: "gimnasio-volcan", token: "vatpub_TOKEN_PREVIEW")
#else
let vatio = Vatio(workspace: "gimnasio-volcan", token: "vatpub_TOKEN_LIVE")
#endif
```

`vatio chat` habla como tú, el desarrollador, no como un socio, así que las tools privadas se prueban desde la app. Inicia sesión en el simulador, pregunta por tus reservas y lee la conversación con `vatio chat show CHAT_ID` (`chat.conversationID` tiene el id). Muestra cada llamada a tools, lo que respondió tu backend y si el token fue aceptado: una llave equivocada, un `exp` vencido o un `aud` incorrecto dejan al usuario como anónimo en vez de fallar. `no_agent_deployed` significa que todavía no hay nada desplegado en el entorno del token. Después pide las reservas de otro socio: sin un parámetro de id no hay nada que el modelo pueda llenar, y tu endpoint responde solo por el `sub` del token.

## Límites

Según los [límites del SDK](https://vatio.ai/docs/sdk/limits), los mismos que en la web:

- **Mensajes:** hasta 4.000 caracteres (`content_too_long`), con hasta cuatro archivos de 8 MB cada uno.
- **El tipo de archivo** se lee de sus bytes, no del nombre; un tipo que el agente no puede leer se rechaza con `unsupported_file_type`.
- **Límites de uso,** por IP y workspace, por minuto: 10 conversaciones iniciadas, 30 mensajes enviados, 60 lecturas de configuración y 60 listados de conversaciones. Por encima, las peticiones responden HTTP 429 y el SDK lanza `rate_limited`: espera un poco en vez de reintentar de inmediato.
- **Las credenciales** duran 12 horas; una vencida aparece como `subscription_rejected`, y el siguiente `send` empieza una conversación nueva.
- **El estilo de respuesta** queda fijo al empezar la conversación: `.stream` (por defecto: una respuesta completa, no token por token), `.paced` (mensajes cortos con indicadores de escritura) o `.instant`. La app de ejemplo pinta la respuesta de forma progresiva en pantalla.

## Lo que el SDK no hace

- **No hay notificaciones push.** Nada en el SDK se registra para recibirlas. Las respuestas llegan mientras la app está conectada, y cuando vuelve del segundo plano el SDK se reconecta y trae lo que se perdió.
- **No hay llamadas de voz.** Notas de voz, sí; una conversación de voz en vivo, no.
- **El agente no envía archivos.** Lee imágenes, audio y PDF, pero sus respuestas son texto: no puede generar ni enviar archivos ni imágenes.
- **El agente no escribe primero.** Responde lo que el usuario envía; nunca empieza una conversación.

## Publica

```bash
vatio publish
```

Lanza el build con el token de live. Las conversaciones de la app llegan al mismo inbox que las de WhatsApp y la web, con cada llamada a tools. Un supervisor puede tomar una conversación o marcar con un flag una respuesta mala, y cada flag se vuelve un caso de eval desde el cual tu coding agent corrige: mira [Mejorar tu agente](/es/guias/mejorar-tu-agente-de-ia). La versión web de esta misma configuración está en [Un chat con IA para tu sitio web](/es/guias/chat-con-ia-para-tu-sitio-web), y cada método, en la [documentación del SDK de iOS](https://vatio.ai/docs/sdk/ios).

## Preguntas frecuentes

### ¿Cómo agrego un agente de IA con chat a mi app iOS?

Agrega el paquete Swift https://github.com/vatio-ai/ios, crea un token publicable cuyo origen permitido sea ios-app:// más el bundle id de tu app, y crea un VatioChat con él. El SDK mantiene la conversación como estado observable, así que una vista de SwiftUI que lo observa se vuelve a dibujar a medida que llegan las respuestas. El agente es el mismo vatio.yml que atiende tus otros canales.

### ¿Es seguro poner el token de Vatio dentro de una app iOS?

El token publicable vatpub_ está pensado para ir dentro de la app. Corresponde a un workspace y un entorno, y solo funciona desde sus orígenes permitidos, pero no prueba quién es el usuario. Nunca pongas en una app un token de desarrollador vat_ ni tu llave privada de identidad.

### ¿El agente puede leer los datos del usuario que inició sesión en mi app?

Sí. Tu backend firma un JWT RS256 de corta duración para el usuario, la app se lo pasa a VatioChat como visitor token, y las tools marcadas access: private corren para ese usuario. Tu endpoint verifica el mismo token y limita la consulta a su sub.

### ¿El SDK de iOS de Vatio trae una interfaz de chat?

No. Es un paquete Swift sin UI y sin dependencias: te da los mensajes, el indicador de escritura, el estado de conexión, los archivos y el momento de feedback como estado, y tú dibujas las pantallas. El README del paquete tiene un ejemplo completo en SwiftUI, y el repositorio incluye una app de ejemplo que se puede correr.

### ¿El SDK de iOS tiene notificaciones push o llamadas de voz?

No. Las respuestas llegan por un WebSocket mientras la app está conectada, y el SDK trae lo que se perdió cuando se reconecta. Los usuarios pueden enviar notas de voz, que se transcriben antes de que el agente responda, pero no hay llamadas de voz y el agente responde con texto.
