Saltar al contenido
Beta EN

Desarrolladores

Complementos

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.

talaya.complemento/1 Galería indice.json El formato

Galería

Seis complementos que funcionan de verdad. Usan servicios públicos sin clave y están probados contra ellos, paso a paso, el 23 de septiembre de 2026.

Divisas

Receta · v1.0.0 · por Talaya

Convierte entre monedas con el cambio del día que publica el Banco Central Europeo.

Pídele: «¿Cuánto son cien dólares en euros?»

  • Redapi.frankfurter.dev
  • UbicaciónNo
  • PantallaNo
  • MemoriaNo

convertir_divisas. Datos: Frankfurter, con los cambios del Banco Central Europeo.

Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.

Luz del día

Receta · v1.0.0 · por Talaya

A qué hora sale y se pone el sol donde estás, y cuándo empiezan la hora dorada y la hora azul.

Pídele: «¿A qué hora empieza la hora dorada?»

  • Redapi.sunrisesunset.io
  • UbicaciónSí
  • PantallaSí
  • MemoriaNo

luz_del_dia. Datos: SunriseSunset.io.

Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.

Aire

Receta · v1.0.0 · por Talaya

La calidad del aire donde estás, con el índice europeo, las partículas finas y la radiación ultravioleta de ahora.

Pídele: «¿Está bien el aire para salir a correr?»

  • Redair-quality-api.open-meteo.com
  • UbicaciónSí
  • PantallaSí
  • MemoriaNo

calidad_del_aire. Datos: Open-Meteo (CC BY 4.0).

Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.

Estado del mar

Receta · v1.0.0 · por Talaya

Altura, periodo y dirección de las olas, y la temperatura del agua en la costa más cercana.

Pídele: «¿Cómo está el mar?»

  • Redmarine-api.open-meteo.com
  • UbicaciónSí
  • PantallaNo
  • MemoriaNo

estado_del_mar. Datos: Open-Meteo (CC BY 4.0).

Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.

Wikipedia

Receta · v1.0.0 · por Talaya

El resumen de Wikipedia de cualquier tema, en el idioma que prefieras.

Pídele: «¿Qué es la Torre del Oro?»

  • Red*.wikipedia.org
  • UbicaciónNo
  • PantallaSí
  • MemoriaNo

resumen_de_wikipedia. Textos de Wikipedia (CC BY-SA 4.0).

Desde el ordenador: apunta la cámara del iPhone a este código y se abre la ficha de instalación en Talaya.

Terremotos cerca

Guion · v1.0.0 · por Talaya

Los últimos terremotos alrededor de donde estás: magnitud, zona, a qué distancia y hace cuánto. Y su mapa en el reloj.

Pídele: «¿Ha habido algún terremoto por aquí?»

  • Redwww.seismicportal.eu
  • UbicaciónSí
  • PantallaSí
  • MemoriaNo

terremotos_cerca. Datos: EMSC, el Centro Sismológico Euromediterráneo.

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

  1. 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.
  2. Un fichero abierto con Talaya: por AirDrop, desde Archivos o desde Mail.
  3. 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.

Enlace de instalación
talaya://instalar?url=https%3A%2F%2Fheytalaya.com%2Fcomplementos%2Fdivisas.talaya.json

El fichero

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

CampoObligatorioQué es
formatosíSiempre talaya.complemento/1
idsíÚnico, al revés de un dominio: org.ejemplo.divisas. Minúsculas, cifras, puntos y guiones bajos
nombresíLo que ve el usuario. Corto
versionsímayor.menor.parche. Una versión nueva sustituye a la vieja con el mismo id
autorsínombre y, si quieres, web (https)
descripcionsíPara el usuario, una o dos frases
icononoNombre de un símbolo SF. Por defecto puzzlepiece.extension
coberturanoDónde tiene sentido (ver abajo). Fuera de sus zonas no se le ofrece al modelo. null es en todo el mundo
permisossíLo que puede hacer. Ver Permisos
habilidadesuna de las dosLista de habilidades (receta o guion)
mcpuna de las dosServidor MCP que aporta las habilidades
cobertura
"cobertura": {
  "zonas": [
    { "nombre": "Lisboa", "sur": 38.6, "oeste": -9.3, "norte": 38.85, "este": -9.05 }
  ]
}

Permisos

Lo que no está aquí, el complemento no lo puede hacer. Y el usuario lo lee antes de instalar.

PermisoQué deja
redLista 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
ubicacionLeer 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
memoriaGuardar una frase en la memoria del usuario (paso recordar)
pantallaPintar 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

CampoQué es
nombresnake_case, de 3 a 41 caracteres, empieza por letra. Si choca con otra, la app le antepone la última parte del id (divisas_convertir)
descripcionPara 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
pasosUna receta
guionO un guion en JavaScript. Una de las dos, no las dos
lentatrue si tarda más de unos segundos: ella avisa de que lo está mirando

Recetas: pasos sin código

Uno detrás de otro. Cada paso es un objeto con una sola clave.

PasoFormaQué hace
http{"metodo": "GET", "url": "https://…{{x}}…", "cabeceras": {}, "cuerpo": "…", "plazo": 15}Pide. Los valores en la URL van codificados solos
extraer{"de": "$.a.b[0].c", "como": "x"} o {"regex": "…(\\d+)…", "como": "x"}, con "siNoHay": "…" opcionalSaca un dato de la última respuesta a la variable x
decir"texto con {{x}}"Lo que contesta. Obligatorio al menos uno
recordar"texto"Lo guarda en su memoria (permiso memoria)
reloj{"tipo": "texto", "titulo": "…", "texto": "…"} o {"tipo": "lista" o "marcas", "titulo": "…", "lista": "$.ruta", "etiqueta": "{{nombre}}", "detalle": "…", "lat": "{{lat}}", "lon": "{{lon}}"}Lo pinta en el reloj y en el móvil (permiso pantalla)
si{"valor": "{{x}}", "op": "mayor", "que": "10", "entonces": [pasos], "siNo": [pasos]}Condición: existe, vacio, igual, distinto, mayor, menor, contiene

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}}&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."
                    }
                  ]
                }
              }
            ]
          }
        }
      ]
    }
  ]
}

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
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.DevuelvePermiso
http.get(url, {cabeceras, plazo})
http.post(url, cuerpo, {…})
Promesa de {codigo, cuerpo}red
posicion(){lat, lon, rumbo, velocidad}ubicacion
distancia(lat, lon)Metros desde el usuario (-1 si no se sabe)ubicacion
decir(texto)Promesaninguno
recordar(texto)Promesamemoria
reloj.texto(titulo, texto)
reloj.lista(titulo, filas)
reloj.marcas(titulo, puntos)
Promesapantalla
idioma, hora, fecha, varsValoresninguno
ciudadLa ciudad donde está el usuarioubicacion
console.log(texto)Al registro de la appninguno

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.
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: 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

  1. Escribe el fichero y pruébalo: cada ruta de extraer tiene que existir en la respuesta real del servicio.
  2. Súbelo a una web tuya con https. Con eso ya tiene una URL.
  3. Comparte el enlace talaya://instalar?url= seguido de esa URL codificada, o un código QR con él. También vale mandar el fichero.
  4. 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.