← Todos los artículos

9 min de lectura

Cómo dejar que un asistente actúe en tu app sin darle las llaves

En resumen

Dale al asistente una lista corta de funciones con nombre, no tu DOM, tu API ni un token de administrador. El modelo elige una función y sus argumentos, tu propio código la ejecuta en la página con la sesión del usuario, y el usuario pulsa el botón que guarda, envía o borra.

Dale al asistente una lista corta de funciones con nombre que ya tienes, no tu DOM, tu API ni un token de administrador. El modelo elige una función y sus argumentos; tu propio código la ejecuta en la página, con la sesión del usuario. La función prepara el cambio donde el usuario puede verlo, y el usuario pulsa el botón que guarda, envía o borra.

Ese es todo el modelo de seguridad. Este tutorial explica cómo construirlo bien: qué funciones exponer, cómo nombrarlas y describirlas, cómo limitar sus argumentos y qué no entregar nunca. El código usa el SDK de aside, pero las reglas valen para cualquier asistente.

¿Por qué no dejar que una IA maneje la interfaz de tu app?

Porque cada paso se convierte en una suposición: lee la página y decide dónde hacer clic y qué escribir. Una suposición equivocada hace clic en lo que no era.

Lo aprendí construyendo aside. Sus primeras versiones funcionaban justo así: miraban la pantalla y decidían sobre la marcha dónde hacer clic o qué resaltar. Fallaban demasiado, y ajustarlas no servía, porque cuando algo falla por cómo está planteado, ajustarlo no lo arregla. Lo reescribí alrededor de la división que explico abajo.

La forma segura es dividir el trabajo. El modelo nunca lee tu DOM ni elige dónde hacer clic. Ve una lista de acciones, cada una con un nombre, una descripción y un esquema para sus argumentos. Cuando un usuario pide algo, el modelo elige una acción y completa los argumentos. Después, tu función, escrita por ti y revisada como cualquier otro código, hace el trabajo.

Es la misma frontera que describe OWASP en su entrada sobre Excessive Agency (agencia excesiva) en aplicaciones con LLM. Nombra tres causas: funcionalidad excesiva, permisos excesivos y autonomía excesiva. Cada regla de este artículo elimina una. Una lista corta de acciones limita la funcionalidad. Ejecutar en la página con la sesión del usuario limita los permisos. Y el clic del propio usuario en el botón final limita la autonomía; la mitigación que propone OWASP es justamente que una persona apruebe las acciones de alto impacto antes de que ocurran.

Paso 1: elige las funciones a partir de tu bandeja, no de tu API

No empieces por las rutas de tu API, sino por lo que los clientes te piden que hagas por ellos. Abre las conversaciones de soporte del último mes, sepáralas en preguntas y tareas (en 20 minutos) y anota las tareas. En un producto B2B típico verás cosas así:

  • invitar a un compañero con un rol concreto
  • cambiar el plan o el email de facturación
  • configurar una integración
  • crear un reporte con algunos filtros
  • añadir un monitor o una alerta

Cada una es un ticket que hoy alguien resuelve a mano, y en un equipo pequeño ese alguien suele ser el founder: lo que cuesta que el founder haga soporte le pone una cifra.

Entre cinco y quince acciones es un buen primer conjunto. El SDK acepta hasta 40 por página, pero diez acciones precisas valen más que cuarenta vagas: el modelo elige a partir de nombres y descripciones, y cada acción de más es otra que puede confundir con la correcta.

Convertir esas tareas repetidas en código merece su propio tutorial: Del artículo de ayuda a la acción.

Paso 2: nombra la intención, no el gesto

Una acción es una intención del usuario, no un gesto en la interfaz. Es la regla más importante, así que aquí va como tabla para revisar tu lista:

BienMalPor qué
invite_teammate({email, role})click_button({label})El modelo asocia “añade a Ana como admin” a una intención, no a un botón
change_plan({plan})fill_input({selector, value})Un relleno genérico obliga al modelo a adivinar selectores, que es justo el diseño poco fiable que quieres evitar
create_report({project, period})go_to_page({url}) como única acciónNavegar no basta: el trabajo sigue quedando del lado del usuario

Los nombres van en snake_case, empiezan por una letra y solo usan a-z, 0-9 y _, con un máximo de 64 caracteres. Una acción con un nombre no válido se descarta sin aviso, así que cuídalo.

Paso 3: descríbela para el modelo, incluido lo que no hace

La descripción es un prompt. Escribe una o dos frases, hasta 500 caracteres, que digan qué hace la acción, cuándo usarla y qué no hace. Esa última parte es la que más se olvida, y la que mantiene al modelo honesto con el usuario:

Abre Configuración → Equipo y rellena el formulario de invitación con un email y un rol. No envía la invitación: el usuario la revisa y pulsa Enviar.

Con esa frase, el modelo le dice al usuario “ya rellené la invitación, pulsa Enviar cuando quieras” en lugar de “listo, Ana está invitada”.

Paso 4: limita los argumentos con inputSchema

inputSchema es un objeto JSON Schema con type: 'object', properties y required. Dos hábitos lo convierten en una barrera real:

  • Usa enum para las opciones cerradas. Roles, planes, periodos. Así el modelo no puede inventar un valor que tu formulario no acepta.
  • Describe los formatos en la descripción de cada propiedad. “Fecha ISO, AAAA-MM-DD” es más claro que confiar en la suerte.

El esquema reduce lo que el modelo puede enviar. No sustituye la validación: trata los argumentos como cualquier otra entrada del usuario, porque eso es lo que son.

Paso 5: el handler prepara, el usuario pulsa

Aquí tienes una acción completa, hecha solo con el SDK documentado de aside. Supón que tienes una página de configuración del equipo donde un diálogo de invitación pide un email y un rol:

const actions = [{
  name: 'invite_teammate',
  description: 'Abre Configuración → Equipo y rellena el formulario de invitación con un email y un rol. '
    + 'No envía la invitación: el usuario la revisa y pulsa Enviar.',
  inputSchema: {
    type: 'object',
    properties: {
      email: { type: 'string', description: 'Email de la persona a la que se invita' },
      role: { type: 'string', enum: ['admin', 'member', 'viewer'], description: 'Su rol en el espacio de trabajo' },
    },
    required: ['email', 'role'],
  },
  label: 'invitar a un compañero',
  run: async ({ email, role }, aside) => {
    aside.navigate('/settings/team');
    await aside.press(await aside.waitFor('[data-aside="team-invite-open"]'));
    await aside.fill(await aside.waitFor('[data-aside="invite-email"]'), email);
    await aside.fill('[data-aside="invite-role"]', role);
    aside.highlight('[data-aside="invite-send"]', 8000);
    return { filled: { email, role }, next: 'el usuario la revisa y pulsa Enviar' };
  },
}];

if (window.aside) window.aside.register(actions);
else window.addEventListener('aside:ready', () => window.aside.register(actions), { once: true });

Si lees el handler línea por línea, ahí están las reglas:

  • Navega y después espera. Tras aside.navigate(...) la pantalla todavía no está renderizada, así que el primer contacto con una pantalla nueva pasa por aside.waitFor(...).
  • press solo para pasos inofensivos. Abrir el diálogo de invitación es inofensivo. Pulsar Enviar no lo es, así que el handler nunca lo hace.
  • Rellena a la vista. aside.fill lleva la pantalla hasta el campo y escribe el valor donde el usuario lo ve. Funciona con inputs, textareas y selects nativos, por eso invite-role aquí es un <select> nativo.
  • Señala el botón final. aside.highlight marca Enviar y ahí se detiene. El usuario revisa el formulario y lo pulsa.
  • Devuelve lo que pasó, en breve. El modelo lee el valor de retorno: qué se rellenó y qué le queda al usuario.

Cada control que toca el handler lleva un atributo data-aside. Nunca selecciones por clase, texto o posición: las clases cambian con cada rediseño y el texto con las traducciones, y un selector que coincide en silencio con otro elemento hace que el asistente rellene el campo equivocado. Con data-aside, un elemento que falta falla de forma visible.

La única excepción a “el usuario pulsa” es un formulario de configuración que se guarda solo al cambiar, sin botón de enviar. Ahí rellenar es el cambio, así que dilo en la descripción. Por qué vale la pena mantener esta regla aunque cueste un clic se explica en Preparar, no confirmar: la regla para asistentes que tocan datos de clientes.

Paso 6: falla con una frase que el modelo pueda repetir

Cuando un argumento nombra algo que existe (un proyecto, un compañero, un plan), busca la coincidencia exacta, ignorando solo mayúsculas (y acentos, si tus datos los tienen). Si no coincide nada, o coincide más de una cosa, lanza un error que enumere las opciones. El mensaje le llega al modelo como error de la herramienta, así que escríbelo para el modelo:

// dentro de run, en una acción create_report; `projects` son los datos de tu propia app
const matches = projects.filter((p) => p.name.toLowerCase() === project.toLowerCase());
if (matches.length !== 1) {
  throw new Error(`No hay un único proyecto llamado "${project}". `
    + `Pide al usuario que elija uno: ${projects.map((p) => p.name).join(', ')}`);
}

No recurras a “contiene” o “empieza por”: una coincidencia parcial convierte “Ops” en “Ops Archivo”, y el reporte sale del proyecto equivocado. Un error claro se convierte en una pregunta clara al usuario.

¿Qué no debes exponer nunca a un asistente?

Nunca expongas nada que confirme por el usuario, que obligue al modelo a adivinar o que le dé permisos que la persona no tiene. Usa esta tabla como plantilla para la revisión antes de publicar: si una acción de tu lista encaja en una fila, cámbiala o elimínala.

Nunca expongasPor quéEn su lugar
Un handler que pulsa Guardar, Enviar, Pagar, Borrar o PublicarEl usuario pierde el momento de revisarRellena, resalta el botón y devuelve
Gestos genéricos (click, fill_input, run_query)El modelo vuelve a adivinarUna acción por intención
Llamadas que el usuario no podría hacer a manoEl asistente obtiene permisos que la persona no tieneUsa el mismo cliente y la misma sesión que tu interfaz
Cambios sin pantallaNo hay nada que el usuario pueda revisarAbre la pantalla a la que pertenece el cambio
Selectores por clase o textoTras un rediseño apuntan al elemento equivocadodata-aside en cada control
Una acción que adivina a qué registro te refieresRegistro equivocado, resultado con buena pintaCoincidencia exacta o un error con los candidatos

El handler es tu código: puede llamar a tu store o a tu cliente de API para búsquedas o para precargar estado. Para lo que el usuario va a revisar, mejor aside.fill: ver cómo se rellena el formulario genera confianza.

¿Qué debes comprobar antes de publicar una acción?

  1. Cada acción tiene un nombre en snake_case, una descripción que dice lo que no hace y un esquema con type: 'object'.
  2. Cada selector de un handler tiene su data-aside correspondiente en tus plantillas.
  3. Ningún handler pulsa un control de enviar, guardar, pagar o borrar.
  4. Cada handler termina en unos 15 segundos como máximo; las llamadas de red lentas no se esperan dentro.
  5. Ejecutaste cada handler desde la consola del navegador con argumentos de prueba y después pediste lo mismo con palabras.

El snippet que carga el panel es una sola etiqueta script, y con ella, y tu documentación subida al panel, aside responde a las preguntas con ella el mismo día. Las acciones necesitan a alguien que sepa escribir una función, porque lo que se ejecuta es tu código. La guía de integración completa, con dónde colocarlo en cada framework, selects personalizados y flujos de onboarding, está en aside.pro/skill.

· Fundador de aside

Software engineer en Barcelona. De día lleva las integraciones de ecommerce de un SaaS, donde un fallo de sincronización acaba siendo un problema contable para el cliente; de noche construye aside. Escribe sobre construir producto en 0311b.com.