#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 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:
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
- Reabrir. La conversación y el id del visitante se guardan en
UserDefaults, por workspace y por usuario con sesión. Llama aresume()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
submantiene la misma conversación. - Otro usuario es otra conversación. Un token con otro
subtiene 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 siguientesendempieza 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, ychat.open(_:)cambia a una de ellas.chat.startOver()olvida la guardada sin borrar nada en el servidor. - Memoria. La memoria es del contacto: con
rememberyrecallactivadas, 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:
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:
- 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 siguientesendempieza 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
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: