#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 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 deTú construyes
La conversación como estado observable: messages, isTyping, statusCada pantalla: lista de mensajes, burbujas, compositor, indicador de escritura
El mensaje del usuario visible de inmediato con isPending, y después confirmadoDevolver el texto al campo cuando send lanza un error
Reconectar con backoff, hacer polling mientras tanto, traer lo perdido y deduplicar por idMostrar 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ónEl 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 estadoLa 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:

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:

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

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:

// 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:

@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:

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.

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 profundiza en las tools y los tokens.

#Continuidad de la conversación

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

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:

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:

#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, los mismos que en la web:

#Lo que el SDK no hace

#Publica

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. La versión web de esta misma configuración está en Un chat con IA para tu sitio web, y cada método, en la documentación del SDK de 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.

Actualizado: