Enséñale a Talaya a hacer cosas nuevas. Un complemento es un fichero JSON con una o más habilidades que se añade sin actualizar la app. Antes de instalarse, enseña quién lo hace y qué permisos pide.
Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.
El botón «Añadir a Talaya» abre la app en el iPhone con la ficha del complemento: nombre, autor, habilidades y permisos. Nada se instala sin que lo aceptes.
Qué es un complemento
Un complemento es un paquete de una o más habilidades que cualquiera puede escribir y que se añade a Talaya sin volver a publicar la app. Es la misma máquina que ya usan las habilidades vivas (las que se monta ella misma hablando contigo), con tres cosas más:
un sobre con autor y versión,
permisos que el usuario ve y acepta antes de instalar,
y la opción de que las habilidades vivan en un servidor MCP en vez de dentro del teléfono.
Hay tres formas de escribir una habilidad: una receta (pasos sin código), un guion en JavaScript con una API pequeña y auditada, o un servidor MCP tuyo. Todo se ve y se edita en la app.
Tener muchos no cuesta nada: las habilidades de un complemento no van en la lista fija que se le manda al modelo en cada frase. Talaya las encuentra cuando hacen falta, por su descripción.
Cómo llega al teléfono
Un enlace del tipo talaya://instalar?url=https://…/algo.talaya.json (mejor con la URL codificada). Desde el iPhone abre la app; desde un ordenador, esta web lo enseña como código QR.
Un fichero abierto con Talaya: por AirDrop, desde Archivos o desde Mail.
La galería: el índice público que la app lee y enseña.
Siempre pasa por la misma ficha antes de instalarse: nombre, autor, qué habilidades trae y qué permisos pide, con un botón para aceptar. Nada se instala solo, ni se actualiza sin que el usuario lo vea.
JSON en UTF-8. Extensión .talaya.json (vale cualquier .json). Este es el ejemplo de la especificación:
divisas.talaya.json
{"formato": "talaya.complemento/1","id": "org.ejemplo.divisas","nombre": "Divisas","version": "1.0.0","autor": {"nombre": "Ejemplo","web": "https://ejemplo.org"},"descripcion": "Convierte entre monedas con el cambio del día del Banco Central Europeo.","icono": "dollarsign.circle","cobertura": null,"permisos": {"red": ["api.frankfurter.dev"],"ubicacion": false,"memoria": false,"pantalla": true},"habilidades": [{"nombre": "convertir_divisas","descripcion": "Convierte una cantidad entre dos monedas con el cambio de hoy. Para «¿cuánto son cien dólares en euros?».","parametros": {"cantidad": {"tipo": "decimal","descripcion": "La cantidad","obligatorio": true},"de": {"tipo": "texto","descripcion": "Código ISO de la moneda de origen, p. ej. USD","obligatorio": true},"a": {"tipo": "texto","descripcion": "Código ISO de la moneda de destino, p. ej. EUR","obligatorio": true}},"pasos": [{"http": {"url": "https://api.frankfurter.dev/v1/latest?amount={{cantidad}}&from={{de}}&to={{a}}"}},{"extraer": {"de": "$.rates.{{a}}","como": "resultado"}},{"decir": "{{cantidad}}{{de}} son {{resultado}}{{a}} al cambio de hoy."}]}]}
Campos del sobre
Campo
Obligatorio
Qué es
formato
sí
Siempre talaya.complemento/1
id
sí
Único, al revés de un dominio: org.ejemplo.divisas. Minúsculas, cifras, puntos y guiones bajos
nombre
sí
Lo que ve el usuario. Corto
version
sí
mayor.menor.parche. Una versión nueva sustituye a la vieja con el mismo id
autor
sí
nombre y, si quieres, web (https)
descripcion
sí
Para el usuario, una o dos frases
icono
no
Nombre de un símbolo SF. Por defecto puzzlepiece.extension
cobertura
no
Dónde tiene sentido (ver abajo). Fuera de sus zonas no se le ofrece al modelo. null es en todo el mundo
Lo que no está aquí, el complemento no lo puede hacer. Y el usuario lo lee antes de instalar.
Permiso
Qué deja
red
Lista de dominios a los que puede llamar, solo por https, hasta 30. Sin ruta, sin puerto y sin más comodín que *. al principio: *.dominio.com vale para sus subdominios, no para dominio.com a secas (si hace falta, se ponen los dos). Vacía es sin red
ubicacion
Leer la posición del teléfono: {{lat}}, {{lon}}, {{ciudad}}, api.posicion(), api.distancia(), api.ciudad. Sin este permiso, esas variables no existen y las funciones dan error
memoria
Guardar una frase en la memoria del usuario (paso recordar)
pantalla
Pintar en el reloj y en el móvil (paso reloj, api.reloj.*)
Siempre, sin permiso: decir el resultado en voz alta.
Nunca, con ningún permiso: contactos, agenda, fotos, cámara, micrófono, salud, mensajes, ni otras habilidades.
Límites
Tres peticiones por ejecución, un mega de respuesta, treinta segundos en total pase lo que pase dentro y una ejecución a la vez por complemento: la segunda espera a que acabe la primera. Un complemento que se los salta falla, y lo dice.
Cada petición se comprueba con la URL ya montada, con sus plantillas rellenas, y otra vez en cada redirección: si una redirección lleva a un dominio que no está en red, o que no es https, se para. El token de un servidor no viaja a otro dominio, y un complemento no puede poner las cabeceras que deciden a dónde va la petición o cómo viaja (Host, Content-Length, Connection, Transfer-Encoding, Proxy-* y parecidas).
Lo que contesta un servicio de fuera le llega al modelo marcado como información, no como órdenes. Y a una habilidad solo le llegan los parámetros que declara.
Una habilidad
Campo
Qué es
nombre
snake_case, de 3 a 41 caracteres, empieza por letra. Si choca con otra, la app le antepone la última parte del id (divisas_convertir)
descripcion
Para el modelo: cuándo usarla, con ejemplos de cómo lo diría una persona. De esto depende que se encuentre
parametros
{nombre: {tipo, descripcion, obligatorio}}, hasta 20. Tipos: texto, entero, decimal, si_no. No valen como nombre hora, fecha, idioma, lat, lon ni ciudad, que son de la app
Variables que siempre existen: los parámetros, y {{hora}}, {{fecha}} e {{idioma}}. Con permiso de ubicación, también {{lat}}, {{lon}} y {{ciudad}}.
Un ejemplo real de la galería, con si y siNoHay para decir la verdad tierra adentro:
mar.talaya.json
{"formato": "talaya.complemento/1","id": "dev.pages.talaya.mar","nombre": "Estado del mar","version": "1.0.0","autor": {"nombre": "Talaya","web": "https://heytalaya.com"},"descripcion": "Altura, periodo y dirección de las olas, y la temperatura del agua en la costa más cercana.","icono": "water.waves","cobertura": null,"permisos": {"red": ["marine-api.open-meteo.com"],"ubicacion": true,"memoria": false,"pantalla": false},"habilidades": [{"nombre": "estado_del_mar","descripcion": "Cómo está el mar ahora en la costa donde está el usuario: altura de las olas en metros, cada cuántos segundos llegan, de dónde vienen y temperatura del agua. Para «¿cómo está el mar?», «¿hay olas?», «¿está buena el agua?», «¿a cuánto está el agua?», «how are the waves?». Tierra adentro no hay dato, y lo dice.","parametros": {},"pasos": [{"si": {"valor": "{{lat}}","op": "vacio","entonces": [{"decir": "No sé dónde estás ahora mismo: sin ubicación no puedo mirar el mar."}],"siNo": [{"http": {"url": "https://marine-api.open-meteo.com/v1/marine?latitude={{lat}}&longitude={{lon}}¤t=wave_height,wave_period,wave_direction,sea_surface_temperature&timezone=auto"}},{"extraer": {"de": "$.current.wave_height","como": "olas","siNoHay": ""}},{"si": {"valor": "{{olas}}","op": "vacio","entonces": [{"decir": "Aquí no hay datos del mar: parece que estás tierra adentro."}],"siNo": [{"extraer": {"de": "$.current.wave_period","como": "periodo","siNoHay": "sin dato"}},{"extraer": {"de": "$.current.wave_direction","como": "rumbo","siNoHay": "sin dato"}},{"extraer": {"de": "$.current.sea_surface_temperature","como": "agua","siNoHay": "sin dato"}},{"decir": "Olas de {{olas}} metros cada {{periodo}} segundos, que llegan desde los {{rumbo}} grados. El agua está a {{agua}} grados."}]}}]}}]}]}
Guiones: JavaScript
Cuando una receta no basta: una función run(args, api) que devuelve texto. Lo que devuelve run es lo que se le cuenta al modelo, que lo dice con sus palabras.
Guion
asyncfunctionrun(args, api) {
const r = await api.http.get("https://api.ejemplo.org/cosas?cerca=" + args.que);
const datos = JSON.parse(r.cuerpo);
const cerca = datos.filter(p => api.distancia(p.lat, p.lon) < 800);
await api.reloj.marcas("Cerca", cerca.map(p => ({ etiqueta: p.nombre, lat: p.lat, lon: p.lon })));
return`Hay ${cerca.length} a menos de ochocientos metros.`;
}
El guion de la galería que busca terremotos cerca hace una sola petición, calcula distancias, pinta el mapa en el reloj y contesta en el idioma de la conversación:
terremotos.talaya.json · guion
// Terremotos cerca. Datos del EMSC (www.seismicportal.eu), sin clave.// Una sola petición: el servicio ya filtra por distancia, fecha y magnitud.asyncfunctionrun(args, api) {
const en = String(api.idioma || "").toLowerCase().indexOf("en") === 0;
const yo = api.posicion();
if (typeof yo.lat !== "number") {
return en
? "I don't know where you are right now, so I can't look for earthquakes nearby."
: "No sé dónde estás ahora mismo: sin ubicación no puedo mirar los terremotos cercanos.";
}
const radioKm = Number(args.radio_km) > 0 ? Number(args.radio_km) : 300;
const dias = Number(args.dias) > 0 ? Math.min(Number(args.dias), 30) : 7;
const minima = Number(args.magnitud_minima) > 0 ? Number(args.magnitud_minima) : 2;
const desde = newDate(Date.now() - dias * 86400000).toISOString().slice(0, 19);
const grados = Math.min(20, Math.max(0.5, radioKm / 111.2)).toFixed(2);
const url = "https://www.seismicportal.eu/fdsnws/event/1/query?format=json&orderby=time&limit=50"
+ "&lat=" + yo.lat.toFixed(3) + "&lon=" + yo.lon.toFixed(3)
+ "&maxradius=" + grados + "&minmag=" + minima + "&start=" + desde;
const nada = en
? "No earthquakes of magnitude " + minima + " or more within " + radioKm + " km in the last " + dias + " days."
: "No ha habido terremotos de magnitud " + minima + " o más a menos de " + radioKm + " km en los últimos " + dias + " días.";
const r = await api.http.get(url, { plazo: 15 });
// El servicio contesta 204, sin cuerpo, cuando no hay nada.if (r.codigo === 204 || !r.cuerpo) return nada;
if (r.codigo !== 200) thrownewError("el EMSC ha contestado con el error " + r.codigo);
const lista = (JSON.parse(r.cuerpo).features || []).map(function (f) {
const p = f.properties;
const metros = api.distancia(p.lat, p.lon);
return {
mag: Number(p.mag),
zona: String(p.flynn_region || "").toLowerCase(),
lat: p.lat,
lon: p.lon,
cuando: Date.parse(p.time),
km: metros >= 0 ? Math.round(metros / 1000) : null
};
});
if (!lista.length) return nada;
functionhace(ms) {
const min = Math.max(1, Math.round((Date.now() - ms) / 60000));
if (min < 60) return en ? min + " minutes ago" : "hace " + min + " minutos";
const h = Math.round(min / 60);
if (h < 48) return en ? h + " hours ago" : "hace " + h + " horas";
const d = Math.round(h / 24);
return en ? d + " days ago" : "hace " + d + " días";
}
functionuno(t) {
const lejos = t.km === null ? "" : (en ? ", " + t.km + " km away" : ", a " + t.km + " km");
return (en ? "magnitude " : "magnitud ") + t.mag.toFixed(1) + (en ? " in " : " en ") + t.zona + lejos + ", " + hace(t.cuando);
}
await api.reloj.marcas(en ? "Earthquakes" : "Terremotos", lista.slice(0, 20).map(function (t) {
return { etiqueta: "M" + t.mag.toFixed(1), lat: t.lat, lon: t.lon };
}));
const ultimo = lista[0];
const mayor = lista.reduce(function (a, b) { return b.mag > a.mag ? b : a; });
const cuantos = lista.length === 1
? (en ? "One earthquake" : "Un terremoto")
: lista.length + (en ? " earthquakes" : " terremotos");
let texto = en
? cuantos + " of magnitude " + minima + " or more within " + radioKm + " km in the last " + dias + " days (EMSC data). The latest: " + uno(ultimo) + "."
: cuantos + " de magnitud " + minima + " o más a menos de " + radioKm + " km en los últimos " + dias + " días (datos del EMSC). El último: " + uno(ultimo) + ".";
if (mayor !== ultimo) texto += en ? " The strongest: " + uno(mayor) + "." : " El mayor: " + uno(mayor) + ".";
if (lista.length === 50) texto += en ? " There may be more." : " Puede haber más.";
return texto;
}
MCP: habilidades en tu servidor
En vez de habilidades, un complemento puede traer un servidor MCP con transporte HTTP «streamable»:
mcp
"mcp": {"url": "https://mcp.ejemplo.org/mcp","autenticacion": "token","instrucciones": "Pídele a tu administrador el token de acceso."}
La app habla la revisión vigente del protocolo, la 2026-07-28, sin saludo ni sesión. Si el servidor no la entiende, cae sola al saludo de las anteriores (initialize y Mcp-Session-Id, de la 2024-11-05 a la 2025-11-25). Luego tools/list y, al usarlas, tools/call, en JSON o en SSE. Cada herramienta del servidor es una habilidad, y su description es lo que lee el modelo.
autenticacion: ninguna o token. El token lo pega el usuario al instalar, viaja como Authorization: Bearer …, se guarda en el llavero del iPhone y nunca se exporta. OAuth no está: un servidor que lo pide se rechaza con un aviso claro.
permisos.red debe incluir el dominio del servidor.
Es la vía para todo lo que no cabe en una receta: una casa domótica, la base de datos de tu empresa, tu propio agente.
El índice de la galería
La galería es un fichero público en /complementos/indice.json, en formato talaya.indice/1. Este es su primer elemento, tal como está publicado:
indice.json
{"id": "dev.pages.talaya.divisas","nombre": "Divisas","version": "1.0.0","autor": "Talaya","descripcion": "Convierte entre monedas con el cambio del día que publica el Banco Central Europeo.","icono": "dollarsign.circle","tipo": "receta","permisos": {"red": ["api.frankfurter.dev"],"ubicacion": false,"memoria": false,"pantalla": false},"url": "https://heytalaya.com/complementos/divisas.talaya.json"}
Publica el tuyo
Escribe el fichero y pruébalo: cada ruta de extraer tiene que existir en la respuesta real del servicio.
Súbelo a una web tuya con https. Con eso ya tiene una URL.
Comparte el enlace talaya://instalar?url= seguido de esa URL codificada, o un código QR con él. También vale mandar el fichero.
Cuando lo cambies, sube la version: la nueva sustituye a la vieja con el mismo id, y el usuario lo ve.
Por qué así
Nada nativo, nada escondido. La guía 2.5.2 de Apple prohíbe descargar código que cambie la app. La 4.7 permite complementos en HTML5 o JavaScript interpretado si la app lleva un índice de lo que se puede añadir, pide consentimiento para cada permiso y no le da al complemento acceso a APIs nativas por su cuenta.
Por eso hay tres formas y no más: recetas (datos, sin código), guiones en JavaScriptCore con una API pequeña y auditada, o servidores MCP, donde el código corre fuera del teléfono. Todo se ve y se edita en la app.
Esta página sigue la especificación talaya.complemento/1. Si la especificación cambia, esta página cambia con ella.
¿Contamos tu visita?
Con tu permiso, Google Analytics nos dice cuánta gente llega y desde dónde. Nada de publicidad. Cómo funciona.