Teach Talaya new things. An add-on is a JSON file with one or more skills that installs without updating the app. Before it installs, it shows who made it and which permissions it wants.
On a computer: point your iPhone camera at this code to open the install sheet in Talaya.
“Add to Talaya” opens the app on your iPhone with the add-on's sheet: name, author, skills and permissions. Nothing installs unless you accept. The add-on files are written in Spanish; Talaya answers in your language.
What an add-on is
An add-on is a bundle of one or more skills that anyone can write and that installs in Talaya without a new release of the app. It runs on the same engine as the app's living skills (the ones it builds for itself while talking to you), plus three things:
an envelope with an author and a version,
permissions the user sees and accepts before installing,
and the option for the skills to live on an MCP server instead of inside the phone.
There are three ways to write a skill: a recipe (steps, no code), a JavaScript script with a small, audited API, or an MCP server of your own. Everything can be seen and edited in the app.
Having lots of them costs nothing: an add-on's skills aren't part of the fixed list sent to the model with every sentence. Talaya finds them when they're needed, by their description.
The format's keys are in Spanish (formato, nombre, pasos…). They're part of the format, so they stay the same in every language.
How an add-on gets to the phone
A link like talaya://instalar?url=https://…/something.talaya.json (ideally with the URL encoded). On the iPhone it opens the app; on a computer, this site shows it as a QR code.
A file opened with Talaya: from AirDrop, Files or Mail.
The gallery: the public index the app reads and shows.
It always goes through the same sheet before installing: name, author, which skills it brings and which permissions it wants, with a button to accept. Nothing installs on its own, and nothing updates without the user seeing it.
UTF-8 JSON. The extension is .talaya.json (any .json works). This is the example from the specification:
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."}]}]}
Envelope fields
Field
Required
What it is
formato
yes
Always talaya.complemento/1
id
yes
Unique, as a reversed domain: org.ejemplo.divisas. Lowercase letters, digits, dots and underscores
nombre
yes
What the user sees. Keep it short
version
yes
major.minor.patch. A new version replaces the old one with the same id
autor
yes
nombre and, optionally, web (https)
descripcion
yes
For the user, one or two sentences
icono
no
The name of an SF Symbol. Defaults to puzzlepiece.extension
cobertura
no
Where it makes sense (see below). Outside its areas it isn't offered to the model. null means worldwide
If it isn't listed here, the add-on can't do it. And the user reads this before installing.
Permission
What it allows
red
List of domains it may call, https only, up to 30. No path, no port and no wildcard other than a leading *.: *.domain.com covers its subdomains, not domain.com itself (list both if you need both). Empty means no network
ubicacion
Reading the phone's position: {{lat}}, {{lon}}, {{ciudad}}, api.posicion(), api.distancia(), api.ciudad. Without it, those variables don't exist and the functions throw
memoria
Saving a sentence to the user's memory (the recordar step)
pantalla
Drawing on the watch and the phone (the reloj step, api.reloj.*)
Always, without permission: saying the result out loud.
Never, with any permission: contacts, calendar, photos, camera, microphone, health, messages, or other skills.
Limits
Three requests per run, one megabyte of response, thirty seconds in total no matter what happens inside, and one run at a time per add-on: a second one waits for the first. An add-on that goes over them fails, and says so.
Every request is checked with its final URL, templates filled in, and again on every redirect: a redirect to a domain that isn't in red, or that isn't https, is stopped. A server's token never travels to another domain, and an add-on can't set the headers that decide where a request goes or how it travels (Host, Content-Length, Connection, Transfer-Encoding, Proxy-* and the like).
Whatever an outside service answers reaches the model marked as information, not as instructions. And a skill only receives the parameters it declares.
A skill
Field
What it is
nombre
snake_case, 3 to 41 characters, starting with a letter. If it clashes with another skill, the app prefixes the last part of the id (divisas_convertir)
descripcion
For the model: when to use it, with examples of how a person would ask. Whether it gets found depends on this
parametros
{name: {tipo, descripcion, obligatorio}}, up to 20. Types: texto, entero, decimal, si_no. hora, fecha, idioma, lat, lon and ciudad are taken by the app and can't be used as names
A condition: existe, vacio, igual, distinto, mayor, menor, contiene
Variables that always exist: the parameters, plus {{hora}}, {{fecha}} and {{idioma}}. With the location permission, also {{lat}}, {{lon}} and {{ciudad}}.
A real example from the gallery, using si and siNoHay to tell the truth when you're inland:
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."}]}}]}}]}]}
Scripts: JavaScript
For when a recipe isn't enough: a run(args, api) function that returns text. Whatever run returns is what the model is told, and it says it in its own words.
Script
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.`;
}
The gallery script that looks for nearby earthquakes makes a single request, works out distances, draws the map on the watch and answers in the language of the conversation:
terremotos.talaya.json · script
// 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: skills on your own server
Instead of habilidades, an add-on can bring an MCP server using the streamable HTTP transport:
mcp
"mcp": {"url": "https://mcp.ejemplo.org/mcp","autenticacion": "token","instrucciones": "Pídele a tu administrador el token de acceso."}
The app speaks the current protocol revision, 2026-07-28, with no handshake and no session. If the server doesn't understand it, the app falls back on its own to the handshake of earlier revisions (initialize and Mcp-Session-Id, from 2024-11-05 to 2025-11-25). Then tools/list and, when it uses them, tools/call, over JSON or SSE. Every tool on the server becomes a skill, and its description is what the model reads.
autenticacion: ninguna (none) or token. The user pastes the token when installing; it travels as Authorization: Bearer …, is stored in the iPhone's keychain and is never exported. There is no OAuth: a server that asks for it is turned down with a clear message.
permisos.red must include the server's domain.
It's the way to do everything that doesn't fit in a recipe: a smart home, your company's database, your own agent.
The gallery index
The gallery is a public file at /complementos/indice.json, in the talaya.indice/1 format. This is its first entry, exactly as published:
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"}
Publish yours
Write the file and test it: every extraer path has to exist in the service's real response.
Put it on a website of yours, over https. That gives it a URL.
Share the link talaya://instalar?url= followed by that URL, encoded, or a QR code of it. Sending the file works too.
When you change it, bump the version: the new one replaces the old one with the same id, and the user sees it.
Why it works this way
Nothing native, nothing hidden. Apple's guideline 2.5.2 forbids downloading code that changes the app. Guideline 4.7 allows HTML5 or interpreted JavaScript add-ons if the app includes an index of what can be added, asks for consent for each permission and doesn't give the add-on access to native APIs on its own.
That's why there are three ways and no more: recipes (data, no code), scripts in JavaScriptCore with a small, audited API, or MCP servers, where the code runs off the phone. Everything can be seen and edited in the app.
This page follows the talaya.complemento/1 specification. If the specification changes, this page changes with it.
Can we count your visit?
With your permission, Google Analytics tells us how many people come and where from. No ads, ever. How it works.