wandres.dev
WIDGETS Y APP INTENTS · fuera de la app

App Intents: exponer tu app a Siri, Atajos y Spotlight

App Intents no es una integración con Siri: es la declaración del vocabulario de tu app —qué acciones existen, sobre qué cosas operan y qué parámetros necesitan— en una forma que el sistema puede leer, indexar, componer y ejecutar sin abrirla. Esta lección recorre el modelo completo de acciones, entidades y consultas, el protocolo de resolución de parámetros con diálogo y confirmación, las superficies donde todo eso aparece, y las reglas que impiden romper los atajos que la gente ya construyó.

⏱ 19 min

Hay una forma equivocada de entender App Intents que consiste en verlo como un adaptador para Siri, y produce implementaciones tristes: dos o tres acciones sueltas, mal nombradas, que abren la app y ya. La forma exacta es otra. App Intents es el mecanismo por el que una app publica su modelo de dominio en un formato que el sistema entiende: los verbos que sabe ejecutar, los sustantivos sobre los que opera, cómo se buscan esos sustantivos y qué información falta para actuar. Una vez publicado, ese vocabulario no lo consume un cliente sino muchos —el asistente, la app de Atajos, la búsqueda, el botón de acción, los widgets, los controles del sistema, la automatización— y ninguno de ellos necesita saber que tu app existe como interfaz.

🎯 Al terminar esta lección sabrás
  • Modelar un dominio con acciones, entidades, enumeraciones y consultas coherentes entre sí.
  • Resolver parámetros con petición de valor, desambiguación, confirmación y diálogo hablado.
  • Situar cada superficie del sistema y decidir qué acciones merecen frase, atajo o indexación.
  • Aplicar las reglas de estabilidad, localización e idempotencia que evitan romper atajos existentes.

Acciones, entidades y consultas

El modelo tiene tres piezas y confundirlas produce integraciones que funcionan en la demo y fallan con datos reales. Una acción es un tipo que conforma AppIntent: tiene título, parámetros y un método que hace el trabajo. Una entidad es un tipo que conforma AppEntity: representa una cosa de tu dominio con identidad estable y una representación mostrable. Y una consulta es el tipo que conforma EntityQuery y sabe encontrar entidades por identificador, por texto o devolviendo las sugeridas. Sin consulta, el sistema puede ejecutar tu acción pero no puede ayudar a la persona a elegir sobre qué.

struct Nota: AppEntity {
    let id: UUID
    let titulo: String

    static let typeDisplayRepresentation: TypeDisplayRepresentation = "Nota"
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(titulo)")
    }
    static let defaultQuery = ConsultaNotas()
}

struct ConsultaNotas: EntityStringQuery {
    func entities(for ids: [UUID]) async throws -> [Nota] {
        await Almacen.compartido.notas(ids: ids)
    }
    func entities(matching cadena: String) async throws -> [Nota] {
        await Almacen.compartido.buscarNotas(cadena)
    }
    func suggestedEntities() async throws -> [Nota] {
        await Almacen.compartido.notasRecientes(limite: 5)
    }
}

El identificador es la pieza crítica y la que más veces se elige mal. Debe ser estable en el tiempo y único, porque el sistema lo persiste dentro de los atajos que la gente construye: un atajo que dice archivar la nota de la reunión guarda ese identificador, no el título. Usar el título, un índice de posición o un identificador de objeto de base de datos que cambie al migrar significa que los atajos del usuario dejarán de funcionar sin aviso alguno.

Las enumeraciones de dominio tienen su propia conformidad, AppEnum, y merecen un comentario porque son la vía más barata de subir la calidad de una integración. Un parámetro de tipo cadena obliga a la persona a escribir; un parámetro con enumeración le ofrece una lista, se traduce solo y admite sinónimos hablados. Casi siempre que aparece una cadena en la firma de un intent hay una enumeración mal modelada debajo.

Un intent no se ejecuta hasta que todos sus parámetros requeridos tienen valor, y el proceso de conseguirlos es un protocolo, no una comprobación. Si falta un valor, el sistema lo pide. Si hay varios candidatos, desambigua. Si la acción es destructiva o costosa, conviene exigir confirmación explícita. Y todo eso ocurre sin tu interfaz, de modo que los textos que escribes en los parámetros son literalmente lo que la persona verá y oirá.

struct ArchivarNota: AppIntent {
    static let title: LocalizedStringResource = "Archivar nota"
    static let description = IntentDescription("Mueve una nota al archivo.")
    static let isDiscoverable = true

    @Parameter(title: "Nota")
    var nota: Nota

    @Parameter(title: "Avisar al terminar", default: false)
    var avisar: Bool

    static var parameterSummary: some ParameterSummary {
        Summary("Archivar \(\.$nota)") {
            \.$avisar
        }
    }

    func perform() async throws -> some IntentResult & ProvidesDialog {
        try await Almacen.compartido.archivar(nota.id)
        return .result(dialog: "He archivado \(nota.titulo)")
    }
}

El resumen de parámetros define cómo se lee la acción como una frase dentro de la app de Atajos: lo esencial va en la línea principal y lo accesorio queda plegado. Es la diferencia entre una tarjeta que se entiende de un vistazo y un formulario con seis campos. Y el diálogo devuelto es lo que el sistema dice en voz alta o muestra al terminar; puede acompañarse de una vista breve como fragmento visual cuando el resultado se entiende mejor viéndolo que oyéndolo.

💡
Pedir, desambiguar y confirmar son tres cosas distintas

Pedir un valor que falta se hace con la solicitud de valor sobre el parámetro. Desambiguar entre candidatos se hace ofreciendo opciones y dejando que el sistema presente la lista. Confirmar se reserva a lo irreversible: borrar, pagar, enviar. Mezclarlas produce conversaciones interminables. La regla práctica es que una acción bien modelada debería completarse sin ninguna pregunta en el caso frecuente y preguntar como mucho una vez en el resto.

Dónde aparece todo esto

La misma declaración alimenta superficies muy distintas y cada una impone matices. En la app de Atajos, cada intent descubrible es un bloque componible cuyo resultado puede alimentar al siguiente, lo que exige devolver valores tipados y no solo efectos. En Siri y en la búsqueda del sistema, lo que se invoca son atajos con frase, declarados en un proveedor específico, y ahí la calidad de la frase decide si funciona o no. En el botón de acción y en los controles del sistema, la acción debe poder ejecutarse sin ninguna pregunta. Y en la configuración de widgets, el intent deja de ser una acción para convertirse en la descripción tipada de las opciones del usuario.

struct Atajos: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: ArchivarNota(),
            phrases: ["Archiva una nota en \(.applicationName)",
                      "Guarda esto en \(.applicationName)"],
            shortTitle: "Archivar nota",
            systemImageName: "archivebox"
        )
    }
}

Las frases tienen dos reglas que no admiten excepción: deben incluir el marcador del nombre de la aplicación, porque sin él el sistema no sabe a quién dirigir la petición, y deben estar traducidas en cada idioma que soportes mediante el catálogo de cadenas específico de atajos. Una frase traducida literalmente del inglés suele ser justo la que nadie diría en voz alta; conviene escribir varias variantes por idioma, incluidas las coloquiales.

flowchart TB
a[Modelo de dominio de tu app] --> b[Acciones que conforman AppIntent]
a --> c[Entidades con identidad estable]
c --> d[Consultas por id texto y sugerencias]
b --> e[App de Atajos y automatizaciones]
b --> f[Siri y frases del proveedor]
b --> g[Spotlight y boton de accion]
b --> h[Botones y toggles del widget]
b --> i[Controles del sistema]
d --> e
d --> f
🧩

Sin abrir la app

Lo normal. El intent se ejecuta en segundo plano, escribe estado y devuelve un diálogo. Debe ser rápido y no depender de la interfaz.

🚪

Abriendo la app

Se declara explícitamente cuando el flujo continúa en pantalla. Deja el destino en estado compartido y no supongas que la app ya estaba viva.

🔐

Continuando en primer plano

Para lo que exige autenticación o una decisión visual a mitad de camino. El sistema pide traer la app al frente y luego retoma la ejecución.

Reglas que evitan romper lo que la gente construyó

La primera es de estabilidad. El nombre del tipo, los nombres de sus parámetros y los identificadores de las entidades forman parte del contrato persistido en los atajos de los usuarios. Renombrar un tipo de Swift es una refactorización inocua en cualquier otro contexto y aquí equivale a borrar una API pública: los atajos que lo usaban aparecen rotos. Cuando haya que evolucionar, se añade en lugar de sustituir, y se marca lo viejo como no descubrible antes de retirarlo.

La segunda es de granularidad. Un intent debe representar una intención completa del usuario, no un paso interno de tu implementación. Exponer diez acciones que solo tienen sentido encadenadas en un orden concreto produce una integración inservible; exponer tres que cada una resuelve algo por sí sola produce una que la gente compone de formas que no habías previsto, que es justo el objetivo.

La tercera es de comportamiento. Todo intent puede ejecutarse desde una automatización, a las tres de la mañana, sin nadie mirando, y puede repetirse. Debe ser idempotente donde tenga sentido, debe fallar con errores descriptivos —los que conforman el protocolo de error mostrable llegan traducidos a la persona— y no debe asumir jamás que hay interfaz disponible, ni red, ni sesión iniciada.

enum ErrorNotas: Error, CustomLocalizedStringResourceConvertible {
    case sinSesion
    var localizedStringResource: LocalizedStringResource {
        "Inicia sesion en la app para archivar notas"
    }
}

Queda una cuarta regla, más difícil de cumplir que las tres anteriores: los textos son producto. El título de la acción, el título de cada parámetro, la descripción, el diálogo de respuesta y las frases no son cadenas de configuración sino la interfaz completa cuando no hay pantalla. Escribirlos con la misma exigencia que se aplica a la interfaz visible es lo que distingue una integración que la gente usa a diario de una que se prueba una vez y se olvida.

Declarar tus intents es escribir la API pública que tu app no sabía que tenía

Conviene ver este tema por lo que realmente hace, porque su alcance excede con mucho a Siri. Durante décadas el software de consumo se escribió bajo un supuesto tácito: que el único cliente de la lógica era la interfaz gráfica y que, por tanto, la lógica podía ser tan implícita como quisiera, apoyarse en el orden de las pantallas, en variables de sesión y en lo que el usuario acababa de ver. App Intents retira ese supuesto. Al declarar acciones con parámetros tipados, entidades con identidad persistente y consultas capaces de encontrarlas, estás afirmando que existe un modelo de dominio separable de sus pantallas, y esa afirmación es verificable: si no puedes expresar una acción sin referirte a la vista que la lanzaba, es que no tenías una acción, tenías un manejador. La sorpresa habitual de los equipos que hacen este ejercicio en serio no es que Siri funcione, sino que la arquitectura de la app mejora antes de que Siri entre en escena, porque el trabajo consiste en nombrar verbos, dar identidad a sustantivos y explicitar qué hace falta para actuar. Y hay un horizonte que ya no es especulativo: el consumidor de ese vocabulario dejó de ser solo una persona hablando. Widgets, controles, automatizaciones y agentes automáticos leen esa misma declaración. Quien la escribe con rigor no está integrando un asistente: está publicando la primera interfaz de su producto que no necesita ojos.

📝
Lo esencial

Modela acciones, entidades y consultas como un vocabulario, no como un adaptador. Los identificadores de entidad y los nombres de tipos y parámetros son contrato persistido: cambiarlos rompe los atajos de la gente. Resuelve parámetros pidiendo, desambiguando y confirmando solo lo irreversible, y cuida el resumen y el diálogo porque son la interfaz cuando no hay pantalla. Declara frases con el marcador del nombre de la app y tradúcelas de verdad. Y asume que todo intent puede ejecutarse solo, de noche, repetido y sin sesión.

⚔️ Publicar el vocabulario de tu app
  1. Escribe los cinco verbos y los tres sustantivos centrales de tu dominio antes de tocar código; descarta los verbos que sean pasos internos y no intenciones completas.
  2. Modela un sustantivo como entidad con identidad estable y su consulta correspondiente, incluidas las sugerencias para la lista inicial.
  3. Implementa dos acciones sobre esa entidad, una consultiva y otra destructiva, con confirmación solo en la segunda y diálogo de respuesta en ambas.
  4. Declara frases para una de ellas en al menos dos idiomas y pruébalas en voz alta; corrige las que nadie diría espontáneamente.
  5. Renombra a propósito un parámetro en una copia del proyecto, reconstruye un atajo que lo usaba y observa exactamente cómo se rompe.