Skip to content
Beta ES

Developers

Add-ons

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.

talaya.complemento/1 Gallery indice.json The format

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

  1. 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.
  2. A file opened with Talaya: from AirDrop, Files or Mail.
  3. 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.

Install link
talaya://instalar?url=https%3A%2F%2Fheytalaya.com%2Fcomplementos%2Fdivisas.talaya.json

The file

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

FieldRequiredWhat it is
formatoyesAlways talaya.complemento/1
idyesUnique, as a reversed domain: org.ejemplo.divisas. Lowercase letters, digits, dots and underscores
nombreyesWhat the user sees. Keep it short
versionyesmajor.minor.patch. A new version replaces the old one with the same id
autoryesnombre and, optionally, web (https)
descripcionyesFor the user, one or two sentences
icononoThe name of an SF Symbol. Defaults to puzzlepiece.extension
coberturanoWhere it makes sense (see below). Outside its areas it isn't offered to the model. null means worldwide
permisosyesWhat it may do. See Permissions
habilidadesone of the twoList of skills (recipe or script)
mcpone of the twoAn MCP server that provides the skills
cobertura
"cobertura": {
  "zonas": [
    { "nombre": "Lisboa", "sur": 38.6, "oeste": -9.3, "norte": 38.85, "este": -9.05 }
  ]
}

Permissions

If it isn't listed here, the add-on can't do it. And the user reads this before installing.

PermissionWhat it allows
redList 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
ubicacionReading the phone's position: {{lat}}, {{lon}}, {{ciudad}}, api.posicion(), api.distancia(), api.ciudad. Without it, those variables don't exist and the functions throw
memoriaSaving a sentence to the user's memory (the recordar step)
pantallaDrawing 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

FieldWhat it is
nombresnake_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)
descripcionFor 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
pasosA recipe
guionOr a JavaScript script. One or the other, not both
lentatrue if it takes more than a few seconds: Talaya lets the user know it's looking

Recipes: steps, no code

One after another. Each step is an object with a single key.

StepShapeWhat it does
http{"metodo": "GET", "url": "https://…{{x}}…", "cabeceras": {}, "cuerpo": "…", "plazo": 15}Makes a request. Values in the URL are encoded automatically
extraer{"de": "$.a.b[0].c", "como": "x"} or {"regex": "…(\\d+)…", "como": "x"}, with an optional "siNoHay": "…"Pulls a value from the last response into the variable x
decir"text with {{x}}"The answer. At least one is required
recordar"text"Saves it to memory (memoria permission)
reloj{"tipo": "texto", "titulo": "…", "texto": "…"} or {"tipo": "lista" or "marcas", "titulo": "…", "lista": "$.path", "etiqueta": "{{nombre}}", "detalle": "…", "lat": "{{lat}}", "lon": "{{lon}}"}Draws it on the watch and the phone (pantalla permission)
si{"valor": "{{x}}", "op": "mayor", "que": "10", "entonces": [steps], "siNo": [steps]}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}}&current=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
async function run(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.`;
}
api.ReturnsPermission
http.get(url, {cabeceras, plazo})
http.post(url, cuerpo, {…})
A promise of {codigo, cuerpo}red
posicion(){lat, lon, rumbo, velocidad}ubicacion
distancia(lat, lon)Metres from the user (-1 if unknown)ubicacion
decir(texto)Promisenone
recordar(texto)Promisememoria
reloj.texto(titulo, texto)
reloj.lista(titulo, filas)
reloj.marcas(titulo, puntos)
Promisepantalla
idioma, hora, fecha, varsValuesnone
ciudadThe city the user is inubicacion
console.log(texto)To the app's lognone

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.
async function run(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 = new Date(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) throw new Error("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;

  function hace(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";
  }
  function uno(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

  1. Write the file and test it: every extraer path has to exist in the service's real response.
  2. Put it on a website of yours, over https. That gives it a URL.
  3. Share the link talaya://instalar?url= followed by that URL, encoded, or a QR code of it. Sending the file works too.
  4. 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.