# Meteor Developers — documentación completa > Infraestructura agéntica como servicio: ejecuta Mets (agentes), gestiona el CRM (contactos + WhatsApp), integraciones MCP, colecciones de datos, webhooks y tareas — todo por API REST, SDK (TypeScript / Python) o MCP. Server-side: te autenticas con una API key `met_` en `Authorization: Bearer`. Base de la API: https://api.met.meteor.com.co/api/v1. --- # Cómo construir un agente de IA Un **agente de IA** es un programa que recibe una instrucción en lenguaje natural, decide qué herramientas usar para cumplirla, las ejecuta y responde. Lo que lo separa de una integración normal con un modelo de lenguaje es la palabra *decide*: nadie escribió por adelantado la secuencia de pasos. En Meteor un agente se llama **Met**, y son tres cosas: un **prompt** que define qué hace, un conjunto de **herramientas** que puede usar, y **acceso a datos** del negocio. El modelo de lenguaje es el cuarto componente y el más intercambiable — no es lo que determina si el agente sirve. ## Agente o flujo: la decisión que hay que tomar primero Es la confusión más costosa, porque se paga en producción y no en desarrollo. | | agente | flujo | |---|---|---| | decide la secuencia | el modelo, en cada ejecución | tú, al construirlo | | bueno para | lo que no se puede prever | lo que no puede improvisarse | | ejemplo | responder una consulta que nadie anticipó | cobrar, despachar, notificar | | falla como | contesta algo razonable pero equivocado | se queda quieto ante lo inesperado | La respuesta correcta casi nunca es una de las dos: **el agente atiende la conversación y llama a un flujo cuando hay que ejecutar algo que no admite improvisación.** Un agente que cobra decidiendo el monto es un incidente esperando su turno; un flujo que intenta atender una queja es un formulario disfrazado. En Meteor los dos existen y se llaman entre sí — un Met puede disparar un flujo como una de sus herramientas. ## Los cinco pasos ### 1. Consigue una API key Desde el panel, en Ajustes → Desarrolladores. Empieza con una de prueba (`met_test_`): ejecuta gratis, con tope diario, y los scopes que producen efectos reales quedan restringidos — no hay forma de mandarle un mensaje a un cliente de verdad por accidente. ```bash npm install @meteor.ia/sdk ``` Detalle completo en [Autenticación](autenticacion.html). ### 2. Define qué hace el agente El prompt es la definición del agente, no un adorno. Lo que decide la calidad no es la extensión sino la precisión sobre **qué no debe hacer** y **cuándo pasarle el turno a un humano**. Un Met se crea desde el panel o por API, y ahí mismo se le asignan sus habilidades. Ver [Mets y sus herramientas](mets-y-herramientas.html). ### 3. Dale acceso a los datos del negocio Un agente sin datos improvisa. En Meteor los datos viven en **colecciones** — tablas del workspace que el agente lee y escribe sin que programes un endpoint por cada una: ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: 7 }); const cols = await met.collections.list(); await met.items.create(cols[0].id, { data: { titulo: 'Consulta nueva' } }); ``` Si los datos están en otro sistema, se conectan como [integraciones MCP](integraciones-mcp.html) y el agente las usa como herramientas. ### 4. Ejecútalo ```ts const run = await met.runs.create({ input: 'Resume las consultas de hoy' }); console.log(run.output); ``` Con `stream: true` la respuesta llega token por token por SSE, que es lo que quieres en una interfaz de chat. Ver [Ejecutar Mets](ejecutar-agentes.html) y [Streaming](streaming.html). ### 5. Conéctalo a un canal Un agente que solo responde por API no atiende a nadie. Conectando WhatsApp, Messenger o Instagram al workspace, el Met contesta en el hilo del contacto: ```ts await met.contacts.sendMessage(42, 'Ya quedó agendada la visita'); ``` Aquí hay un límite que conviene saber **antes** de escribir código: fuera de la ventana de 24 horas desde el último mensaje de la persona, WhatsApp solo permite escribir con **plantillas aprobadas por Meta**. No es un límite de Meteor y no se puede saltear. Ver [CRM y contactos](contactos.html). ## Lo que casi siempre se olvida **Un agente necesita poder rendirse.** El caso que decide si la gente vuelve a escribirle no es el que resuelve bien, sino el que no puede resolver: si no hay una salida hacia un humano, la conversación muere ahí. En Meteor eso es el autopiloto — se pausa por minutos y se reanuda solo, en vez de apagarse hasta que alguien se acuerde. Ver [Operación del workspace](operacion-del-workspace.html). **Las tareas ejecutadas se cobran, así que hay que poder mirarlas.** `GET /billing/executions` da una fila por tarea ejecutada con su costo. Cuando el total no cuadra, la respuesta casi siempre es un agente que se llama a sí mismo en un bucle. **Todo lo que un agente hizo queda auditable.** Cada tarea ejecutada tiene su traza, y el `request_id` de la respuesta HTTP la une con lo que ves en el panel. Ver [Eventos en vivo](eventos.html). ## Usar Meteor desde tu propio agente Si ya tienes un agente —en Claude, en Cursor, o uno propio— no necesitas la API: Meteor expone un **servidor MCP remoto** con un catálogo de herramientas `met_*`. Tu agente ejecuta Mets, consulta el CRM, dispara tareas y lee datos como herramientas nativas: ``` https://api.met.meteor.com.co/api/v1/mcp ``` El catálogo se filtra por los scopes de tu API key: una herramienta cuyo scope no tienes simplemente no aparece. Ver [Servidor MCP](../mcp.html). ## Preguntas frecuentes ### ¿Necesito entrenar un modelo? No. Un agente se define con prompt, herramientas y datos; el modelo es un componente intercambiable. Entrenar tiene sentido cuando el problema es que el modelo no *sabe* algo del dominio, y casi siempre el problema real es que no *puede* hacer algo — y eso se arregla con una herramienta, no con entrenamiento. ### ¿Cuánto tarda tener uno funcionando? Un Met contestando con acceso a una colección son unas horas. Lo que toma tiempo es lo otro: definir cuándo se rinde, qué no debe decir, y qué pasa cuando la herramienta que necesita está caída. ### ¿Puedo probar sin afectar datos reales? Puedes probar la ejecución sin costo y con los efectos externos bloqueados usando una key `met_test_`. Las tareas ejecutadas quedan con `livemode: false` y no debitan Energía. Pero no es un sandbox aislado: los scopes no restringidos siguen operando sobre el workspace real. Si das `items:write`, por ejemplo, tus pruebas escriben ítems reales y no se revierten. Para datos descartables, crea un workspace dedicado y usa allí la key de test. ### ¿Qué lenguajes tienen SDK? TypeScript (`@meteor.ia/sdk`) y Python (`meteor-ia`) tienen paquetes oficiales. Consulta la versión publicada y sus ejemplos antes de elegir tu cliente. También hay un CLI (`met`) y la API REST con su OpenAPI publicado. ### ¿Y si mi caso no es conversacional? Buena parte de lo que la gente construye no es un chat: es un procedimiento que corre solo. Eso son [tareas](tareas.html) — se disparan por API, por horario o por un evento, y sus pasos pueden ejecutar Mets. --- # Autenticación La API de Meteor se autentica con una **API key** secreta. Cada request lleva la key en el header `Authorization`, y la key define **a qué workspace** pertenece y **qué puede hacer** (sus scopes). ## Formato de la key Todas las keys empiezan con un prefijo que indica su tipo: - `met_live_…` — key de producción. Ejecuta acciones reales y consume Energía. - `met_test_…` — key de **modo test**: runs sin costo, ideal para desarrollar. Los efectos externos (mandar WhatsApp, ejecutar integraciones) quedan bloqueados. **No es un sandbox aislado:** los scopes no restringidos siguen leyendo y escribiendo el workspace real. > La key es un secreto de servidor. Nunca la pongas en el navegador, en una app móvil, ni la subas al control de versiones. Si se filtra, revócala desde el panel y genera una nueva. ## Crear una key Desde tu panel de Meteor: **Ajustes → Desarrolladores → Crear API key**. Eliges los scopes que necesita y el entorno (live o test). La key se muestra **una sola vez** — guárdala apenas la creas. Puedes crear y administrar keys aunque el workspace todavía no tenga plan. Para **usarlas**, el workspace necesita un plan mensual vigente de Meteor; un trial o un plan bonificado también habilitan la operación. Los Tech Partners aprobados pueden recibir un plan especial de USD 0 de cargo fijo mensual, habilitado únicamente por Meteor, con la Energía a tarifa premium. ## Usar la key Guárdala en una variable de entorno, nunca hardcodeada: ```bash export MET_API_KEY=met_live_tu_key_aqui ``` Con el SDK: ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY, { workspaceId: 7 }); ``` ```python import os from meteor_ia import Met met = Met(os.environ["MET_API_KEY"], workspace_id=7) ``` Con `curl`, la key va como Bearer token: ```bash curl https://api.met.meteor.com.co/api/v1/workspaces/7/runs \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":"Hola"}' ``` ## Saber con qué identidad estás entrando Una key no dice de quién es con solo mirarla. Para preguntárselo al servidor está `GET /me`: devuelve la identidad que la API le reconoce a la key con la que llamas — quién es el dueño, qué workspace opera, con qué scopes y en qué entorno. ```bash curl https://api.met.meteor.com.co/api/v1/me \ -H "Authorization: Bearer $MET_API_KEY" ``` ```ts const yo = await met.me.retrieve(); console.log(yo.workspace?.id, yo.api_key.scopes, yo.livemode); ``` ```python yo = met.me.retrieve() print(yo["workspace"]["id"], yo["api_key"]["scopes"], yo["livemode"]) ``` La respuesta, tal como llega por el cable. Toda respuesta correcta de la API viaja dentro de un sobre y el recurso está en `data`: ```json { "success": true, "data": { "object": "identity", "livemode": true, "api_key": { "id": "key_01HXYZ", "object": "api_key", "owner_type": "workspace", "env": "live", "scopes": ["items:read", "runs:execute", "runs:read"], "rate_limit_rpm": 120, "monthly_quota": null }, "workspace": { "object": "workspace", "id": 7, "slug": "acme" }, "partner": null } } ``` Los SDK oficiales te entregan directamente el contenido de `data`, por eso en los ejemplos de arriba `yo` es el objeto de adentro. Si llamas con `curl` o con un cliente propio, lee `.data`. Un cliente generado del contrato ya lo sabe: el OpenAPI declara el sobre. Ver [Errores e idempotencia](errores-e-idempotencia.html). Qué hacer con cada campo: | Campo | Para qué te sirve | |---|---| | `api_key.id` | Identifica la fila en el panel y en los logs de request. **No es el secreto** y no autentica nada: puedes pegarlo en un ticket de soporte sin riesgo. | | `api_key.owner_type` | `workspace` si la key es de un cliente, `partner` si es de un Tech Partner. | | `api_key.scopes` | Lo que la key puede hacer, según el servidor. Si una llamada te responde `403 missing_scope`, esta es la lista contra la cual compararla. | | `livemode` / `api_key.env` | Si lo que hagas tiene efecto real. Míralo aquí y no en el prefijo de la key: es el mismo criterio que aplica el servidor. | | `workspace.id` | El workspace que este request opera. Con una key de workspace es siempre el suyo; con una de partner, el cliente atribuido que estás operando. | | `partner.id` | El partner dueño de la key, o `null`. | | `rate_limit_rpm` · `monthly_quota` | Los topes que se te aplican de verdad: el más estricto entre el de la key y el de tu plan. `null` es sin tope. Son los números detrás de un `429`. | El endpoint **nunca devuelve el secreto de la key**, ni su prefijo, ni sus últimos caracteres, ni su longitud. Si perdiste la key, no la recuperas aquí: se crea una nueva en el panel. Hoy `GET /me` requiere el scope `runs:read`. Si tu key no lo tiene, responde `403 missing_scope` — la key es válida, simplemente no alcanza este endpoint. ### Desde la terminal `met whoami` imprime lo mismo, y con `--save-workspace` guarda en tu config local el workspace que el servidor resolvió, para que no tengas que pasar `--workspace` en cada comando: ```bash met whoami met whoami --save-workspace met whoami --json ``` Si tu config local apunta a un workspace y la key opera otro, `met whoami` te lo dice: es el error que más tiempo hace perder, porque todo responde `200` y los datos simplemente no son los que esperabas. ## Scopes Una key solo puede hacer aquello para lo que tiene **scope**. Los scopes siguen el patrón `dominio:acción`, donde `:write` cubre crear, editar y borrar (no existe un `:update` aparte). Pide solo los que tu integración necesita: es más seguro. Estas tablas salen del catálogo de scopes de la API y del contrato OpenAPI. La columna **Endpoints** dice cuántas operaciones REST piden ese scope hoy. Un `—` en **Endpoints** significa que hoy ninguna operación REST pide ese scope. Sigue siendo válido: una key que ya lo tenga no deja de funcionar. Ojo con `conversions:read`: sin endpoints REST, pero **con eventos de webhook detrás**. Para suscribir un endpoint a esos eventos, el scope hace falta. `rag:read`, `rag:write` y `sites:read` ya **no se ofrecen** al emitir una key nueva ni al registrar una app OAuth. Las keys que los tengan guardados siguen igual; simplemente no habilitan ninguna llamada. ### Ejecución de Mets | Scope | Permite | Endpoints | |---|---|---| | `agents:read` | Ver Mets | 6 | | `agents:write` | Crear y configurar Mets | 9 | | `runs:execute` | Ejecutar Mets — **consume Energía** | 1 | | `runs:read` | Leer runs e historial | 3 | | `skills:read` | Ver skills | 9 | | `skills:manage` | Crear, editar y quitar skills | 11 | | `functions:read` | Ver Funciones IA (código y tools del Met) | 3 | | `functions:write` | Crear y editar Funciones IA | 5 | | `rag:read` | Consultar colecciones indexadas | — | | `rag:write` | Indexar contenido para consultarlo después | — | | `flows:write` | Crear, editar, duplicar y archivar flujos | 5 | | `automations:read` | Ver disparadores | 14 | | `automations:write` | Crear, editar y borrar disparadores | 7 | | `automations:execute` | Disparar automatizaciones | 4 | ### Datos | Scope | Permite | Endpoints | |---|---|---| | `collections:read` | Leer colecciones | 13 | | `collections:write` | Crear, editar y borrar colecciones | 22 | | `items:read` | Leer ítems | 9 | | `items:write` | Crear, editar y borrar ítems | 27 | | `contacts:read` | Leer contactos del CRM | 11 | | `contacts:write` | Crear, editar y borrar contactos | 16 | | `tasks:read` | Leer tareas agénticas (los pasos que ejecuta un Met) | 10 | | `tasks:write` | Crear, editar y borrar tareas | 25 | | `variables:read` | Leer variables del workspace | 5 | | `variables:write` | Crear, editar y borrar variables | 6 | | `files:read` | Leer archivos y mediateca | 2 | | `files:write` | Subir y borrar archivos | 7 | ### Conversaciones y canales | Scope | Permite | Endpoints | |---|---|---| | `conversations:read` | Leer conversaciones de WhatsApp y del CRM | 7 | | `handoff:manage` | Piloto automático, asignación y mensaje de operador | 3 | | `channels:read` | Estado de canales y difusiones | 3 | | `channels:send` | Enviar por WhatsApp y difusiones | 13 | ### Integraciones y plataforma | Scope | Permite | Endpoints | |---|---|---| | `integrations:read` | Ver integraciones MCP | 3 | | `integrations:manage` | Activar integraciones y guardar sus credenciales | 3 | | `integrations:execute` | Ejecutar tools de integración | 4 | | `mcp:use` | Abrir sesión en el servidor MCP | 2 | | `webhooks:manage` | Webhooks entrantes y suscripciones a eventos salientes | 18 | | `events:read` | Feed de actividad del workspace | 4 | | `reminders:read` | Ver recordatorios | 1 | | `reminders:write` | Crear, editar y borrar recordatorios | 3 | | `billing:read` | Plan y consumo (no existe un `billing:write`) | 10 | | `conversions:read` | Estado y configuración de conversiones, y los eventos `conversion.sent` y `conversion.discarded` | — | | `conversions:write` | Reportar una conversión a Meta (CAPI) | 2 | | `sites:read` | Ver sitios web | — | | `snapshots:read` | Navegar el catálogo de plantillas y ver las propias | 2 | | `snapshots:install` | Instalar una plantilla en el workspace | 2 | ### Solo para keys de partner Estos scopes **no se pueden emitir en una key de workspace**: el servidor lo re-verifica en cada request y responde `403`. Ver la [API de Partners](../partners.html). | Scope | Permite | Endpoints | |---|---|---| | `snapshots:publish` | Publicar una plantilla al marketplace | 1 | | `workspaces:provision` | Crear cuentas de cliente | 3 | | `workspaces:recharge` | Cargar energía a una cuenta que aprovisionaste | 1 | | `partner:billing:manage` | El medio de pago del partner, con el que paga las cuentas que gestiona | 2 | | `partner:clients:read` | Clientes atribuidos | 1 | | `partner:leads:read` | Ver oportunidades | 4 | | `partner:leads:write` | Crear, editar, mover y borrar oportunidades | 7 | | `partner:projects:read` | Ver proyectos de implementación | 5 | | `partner:projects:write` | Crear y editar proyectos, tareas, hitos y avance de fase | 10 | | `partner:commissions:read` | Comisiones (siempre solo lectura) | 1 | | `partner:payouts:read` | Liquidaciones (siempre solo lectura) | 2 | | `partner:support:read` | Tickets propios y de los clientes de su cartera | 3 | | `partner:support:write` | Abrir tickets, responder y cambiar de estado | 3 | ### Restringidos en modo de prueba Una key `met_test_` no puede ejercer scopes con efecto externo real: `integrations:execute`, `channels:send`, `conversions:write`, `workspaces:provision`, `workspaces:recharge`, `partner:billing:manage` y `snapshots:install` responden `403` con `test_mode_restricted`. El resto de la superficie funciona igual, incluido el CRUD autorizado: `items:write`, por ejemplo, modifica ítems reales del workspace. El modo test separa la atribución de ejecución (`livemode: false`) y el costo de los runs; **no crea una copia ni revierte datos**. Para pruebas descartables, usa un workspace dedicado y una key de test de ese workspace. Si una key intenta algo fuera de sus scopes, la API responde `403` con el código `missing_scope` (el campo `param` trae el scope faltante). No reintentes: pide al dueño de la key que amplíe los scopes. ## Revocar Una key revocada deja de funcionar en **menos de 60 segundos**. Revoca y rota keys ante cualquier sospecha de filtración, y usa keys distintas por entorno (una para test, otra para producción). ## Apps que operan varios workspaces Si construyes una app que deben conectar otros workspaces, no les pidas una API key. Registra una **app OAuth** en tu panel: **Ajustes → Desarrolladores → Apps OAuth**. El usuario ve los scopes y autoriza (o revoca) el acceso de tu app. Sigue la guía de [OAuth para apps de terceros](oauth-apps.html): usa Authorization Code con PKCE S256, callbacks exactos y refresh tokens rotativos. --- # Ejecutar Mets (Runs) Un **Run** es una ejecución del Met de tu workspace sobre un `input`. Es el corazón de "infraestructura agéntica como servicio": el mismo Met que responde en el chat, ahora disparado por código. ## Ejecución básica ```ts const run = await met.runs.create({ input: 'Resume los leads de hoy' }); console.log(run.status); // 'completed' console.log(run.output); // el resultado del Met ``` ```python run = met.runs.create("Resume los leads de hoy") print(run["status"]) # 'completed' print(run["output"]) # el resultado del Met ``` ```bash curl -X POST https://api.met.meteor.com.co/api/v1/workspaces/7/runs \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":"Resume los leads de hoy"}' ``` El request es `POST /workspaces/:id/runs`. La respuesta es un recurso `run` con id opaco (`run_…`), `status`, `output`, `livemode` y `metadata`. ## Elegir el Met (bind determinístico) Pasa `met` (nombre o id de un Met del workspace) para ejecutar **ese** Met directo, con sus Funciones y tools, sin pasar por el orquestador: ```ts await met.runs.create({ met: 'Valeria', input: '¿Cuántos leads cerré?' }); ``` ```python met.runs.create("¿Cuántos leads cerré?", met="Valeria") ``` El nombre se resuelve sin distinguir mayúsculas (coincidencia exacta y luego por prefijo). Si omites `met`, el **orquestador** del workspace auto-rutea al Met adecuado (igual que en el chat). ## Memoria: conversaciones Sin `conversation_id`, el run corre en una **conversación efímera** (sin memoria previa). Para darle continuidad, pasa una conversación existente: ```ts await met.runs.create({ input: 'Y el mes pasado?', conversation_id: 1234 }); ``` Con `conversation_id`, el primer `met` que envías queda **fijado como default de la conversación**: los runs siguientes que omitan `met` corren ese mismo Met. Un `met` explícito siempre gana sobre el default. ## Visión: imágenes y archivos Pasa adjuntos en `attachments` para que el Met "vea" una imagen o lea un documento (con OCR automático para el texto de una imagen): ```ts await met.runs.create({ met: 'Valeria', input: '¿Este rótulo instalado coincide con el diseño aprobado?', attachments: [{ url: 'https://…/foto.jpg', kind: 'image' }], }); ``` Cada adjunto es `{ url, mime_type?, name?, kind? }`, con `kind` = `image` · `document` · `audio` · `video` · `file`. Una URL suelta dentro del `input` **no** se "ve": tiene que ir en `attachments`. ## Leer runs ```ts const run = await met.runs.retrieve('run_01J8…'); // estado + salida for await (const r of met.runs.iterate()) { // historial completo console.log(r.id, r.status); } ``` ```python run = met.runs.retrieve("run_01J8…") # estado + salida for r in met.runs.iterate(): # historial completo print(r["id"], r["status"]) ``` `iterate()` recorre todas las páginas por ti (ver [Errores y paginación](errores-e-idempotencia.html)). ## Costo Cada run debita **Energía** por el mismo ledger de siempre, marcado `execution_type='api'` y atribuido a la key. En tu dashboard, el consumo por API aparece separado del chat interno. > ¿Streaming? Si quieres procesar el resultado a medida que se genera, mira la guía de [Streaming](streaming.html). --- # Apps OAuth de terceros OAuth sirve cuando tu aplicación debe operar un workspace que pertenece a otra persona. La persona autoriza los scopes que ve en Meteor; tu app recibe un token de acceso temporal. **Nunca** le pidas ni captures su API key. Meteor implementa OAuth 2.0 Authorization Code con **PKCE S256 obligatorio**. El access token se usa como Bearer para la misma API pública; dura una hora. El refresh token dura 30 días y rota en cada uso. ## Antes de redirigir En el workspace dueño de tu integración, abre **Ajustes → Desarrolladores → Apps OAuth** y registra la app. Guarda el `client_id`. Si eliges una app confidencial, guarda también el `client_secret`: Meteor lo muestra solo al crearla o al rotarlo. - Registra cada `redirect_uri` completo. Meteor compara la URL de retorno **exactamente**; no acepta comodines ni fragmentos (`#`). - El callback debe usar HTTPS, salvo `localhost`, `127.0.0.1` o `::1` durante desarrollo. - Declara solo los scopes mínimos. Los scopes de partner no se pueden asignar a una app OAuth. - Elige **pública** para una SPA o app nativa que no puede proteger un secreto. Elige **confidencial** solo si el canje ocurre en tu servidor. ## Redirigir al consentimiento Genera por usuario un `state` impredecible y un `code_verifier` PKCE de 43 a 128 caracteres. Guarda ambos en la sesión de tu app. Calcula `code_challenge = base64url(SHA-256(code_verifier))` y redirige el navegador a la pantalla de Meteor: ```ts import { createHash, randomBytes } from 'node:crypto'; import { OAuth } from '@meteor.ia/sdk'; const verifier = randomBytes(48).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); const state = randomBytes(24).toString('base64url'); // Guarda verifier y state en la sesión antes de redirigir. const oauth = new OAuth(); const url = oauth.authorizeUrl({ clientId: process.env.MET_OAUTH_CLIENT_ID!, redirectUri: 'https://tu-app.example.com/oauth/callback', scope: ['runs:execute', 'contacts:read'], state, codeChallenge: challenge, }); res.redirect(url); ``` La persona inicia sesión en Meteor si hace falta y ve el nombre de tu app, su sitio y los scopes solicitados. Si acepta, Meteor redirige a tu `redirect_uri` con `code` y el mismo `state`. Si rechaza, vuelve con `error=access_denied`. ## Canjear el código en tu servidor En el callback, primero compara `state` con el que guardaste y luego canjea el código. Envía siempre el mismo `redirect_uri` y el `code_verifier` original. El código es de un solo uso y vence en 60 segundos. ```ts const { access_token, refresh_token, expires_in, scope } = await oauth.exchangeCode({ clientId: process.env.MET_OAUTH_CLIENT_ID!, clientSecret: process.env.MET_OAUTH_CLIENT_SECRET, // solo apps confidenciales code: String(req.query.code), redirectUri: 'https://tu-app.example.com/oauth/callback', codeVerifier: session.pkceVerifier, }); // Cifra refresh_token al guardarlo. access_token es una key met_ temporal. ``` No envíes el `client_secret`, el `code_verifier`, el código ni el refresh token al navegador, registros o analítica. El token no incorpora un `workspace_id`: conserva en tu propio vínculo de instalación el workspace al que operará cada conexión si la ruta que usas lo requiere. ## Llamar la API y renovar El `access_token` es un Bearer token para los scopes aprobados. Úsalo con la API REST o el SDK; para rutas que lo necesitan, configura el `workspaceId` de esa instalación. ```ts import Met from '@meteor.ia/sdk'; const met = new Met(access_token, { workspaceId: installation.workspaceId }); const run = await met.runs.create({ input: 'Resume los leads de hoy' }); ``` Antes de que venza, o tras una respuesta de token expirado, renueva desde tu servidor. Cada refresh emite **un refresh token nuevo**: reemplaza el anterior de forma atómica. Reusar un refresh ya consumido revoca todo el grant por seguridad. ```ts const renewed = await oauth.refresh({ clientId: process.env.MET_OAUTH_CLIENT_ID!, clientSecret: process.env.MET_OAUTH_CLIENT_SECRET, refreshToken: installation.refreshToken, }); // Guarda renewed.access_token y renewed.refresh_token juntos. ``` ## Revocar y rotar Una persona puede revocar tu app desde **Ajustes → Desarrolladores → Apps OAuth**. Tu app también puede revocar un access o refresh token; revocar un refresh token invalida el grant completo: ```ts await oauth.revoke(installation.refreshToken, { clientId: process.env.MET_OAUTH_CLIENT_ID!, clientSecret: process.env.MET_OAUTH_CLIENT_SECRET, }); ``` Para apps confidenciales, rota el `client_secret` desde la misma pantalla. La rotación normal conserva el secreto anterior por 24 horas para desplegar el nuevo; usa la invalidación inmediata solo si se filtró. Si tu workspace dueño ya usa [webhooks salientes](webhooks.html), puedes suscribirte a `app.authorized` y `app.revoked` con `integrations:read`. Esos eventos llegan al workspace que registró la app e incluyen `client_id`, `workspace_id`, scopes y `grant_id`, sin datos personales. ## Límites actuales El servidor de autorización no publica todavía metadata OAuth en `/.well-known/oauth-authorization-server` ni registro dinámico de clientes. Configura manualmente las URLs que documenta esta guía; no intentes descubrirlas desde el dominio. --- # Streaming (SSE) En vez de esperar el resultado completo, puedes recibir el run **en vivo** a medida que el Met piensa y responde. Meteor lo entrega como un stream de **Server-Sent Events (SSE)**. ## Con el SDK ```ts for await (const ev of met.runs.stream({ input: '¿Qué puedo automatizar?' })) { switch (ev.type) { case 'run.output': process.stdout.write(ev.data); // texto incremental break; case 'run.completed': console.log('\nlisto'); break; } } ``` El SDK parsea el stream y te entrega objetos `{ type, data }` tipados. Bajo el capó es el mismo `POST /workspaces/:id/runs` con `stream: true` y `Accept: text/event-stream`. ## Esquema de eventos El stream emite un conjunto **cerrado y versionado** de eventos: | Evento | Cuándo | |---|---| | `run.started` | El run arrancó | | `run.step` | Un paso del Met (proyección curada del trace) | | `run.output` | Fragmento de la respuesta | | `run.completed` | Terminó bien | | `run.failed` | Terminó con error | `run.step` es una vista **curada**: expone el tipo de paso, la iteración, el nombre de la tool y el actor — **nunca** prompts internos, inputs crudos de tools, costos de proveedor ni conteo de tokens. ## Con curl ```bash curl -N https://api.met.meteor.com.co/api/v1/workspaces/7/runs \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{"input":"Hola","stream":true}' ``` El flag `-N` desactiva el buffering para ver los eventos llegar. > El streaming no reintenta automáticamente: un stream cortado a la mitad no es reanudable de forma transparente. Para lógica crítica, combina el stream con un `retrieve()` final del run. --- # Tareas Una **tarea** es un procedimiento con pasos que un Met ejecuta. A diferencia de un Run —que es una conversación puntual— la tarea se guarda, se versiona, se dispara sola y deja historial de cada corrida. Usa los scopes `tasks:read` y `tasks:write`. ## El modelo en una frase **La tarea es la receta; la ejecución es el plato.** Editas la tarea una vez; la ejecutas todas las veces que quieras, y cada ejecución tiene su propio estado, su propio historial y sus propias pausas. ```ts const tarea = await met.tasks.create({ title: 'Calificar leads nuevos', description: 'Revisa los contactos sin etapa y proponme una calificación.', agent_id: 12, }); const ejecucion = await met.tasks.execute(tarea.id); // → { id, status: 'running', ... } ``` ```python tarea = met.tasks.create( title="Calificar leads nuevos", description="Revisa los contactos sin etapa y proponme una calificación.", agent_id=12, ) ejecucion = met.tasks.execute(tarea["id"]) # → { "id": ..., "status": "running", ... } ``` ```bash met tasks run tsk_123 --wait ``` La ejecución arranca en `running` y los pasos corren en segundo plano: lee `met.tasks.execution(id)` para saber en qué quedó. Desde la terminal, `--wait` lo espera por ti y refleja el resultado en el código de salida (ver [CLI](cli.html)). ## Pasos Los pasos son lo que el Met hace, en orden. Se editan por separado de la tarea para que reordenar no reescriba todo. ```ts await met.tasks.steps.create(tarea.id, { title: 'Traer contactos sin etapa', type: 'agent', }); await met.tasks.steps.reorder(tarea.id, [pasoB.id, pasoA.id]); ``` `steps.updateResult()` te deja escribir el resultado de un paso desde afuera — útil cuando el trabajo real lo hizo tu sistema y solo quieres dejarlo registrado. ## Pausas para una persona Es lo que distingue a una tarea de un script. Un paso puede detener la ejecución y esperar a alguien: hay dos formas y **no son la misma**. **Aprobación** — el Met hizo el trabajo y pide permiso para seguir: ```ts await met.tasks.approveStep(ejecucion.id, paso.id, 'Va, los montos cuadran'); await met.tasks.rejectStep(ejecucion.id, paso.id, 'El descuento no está autorizado'); ``` Aprobar continúa hacia el siguiente paso de forma asíncrona. Rechazar detiene esa rama. **Paso humano** — el trabajo lo hace la persona, no el Met: ```ts await met.tasks.completeHumanStep(ejecucion.id, paso.id, 'Contrato firmado y archivado'); ``` Úsalo cuando tu propio sistema hizo lo que el paso pedía: el executor avanza al siguiente. > Las tres operan sobre el **id de la ejecución**, no el de la tarea. Es el error más común al integrar: el paso pertenece a la receta, pero la pausa pertenece a la corrida. ## Seguir una ejecución ```ts const historial = await met.tasks.executions(tarea.id); const estado = await met.tasks.execution(ejecucion.id); await met.tasks.cancelExecution(ejecucion.id); ``` `cancelExecution()` no lleva `Idempotency-Key`: cancelar dos veces es inofensivo y no queremos que un reintento se coma la segunda cancelación. Para reaccionar en vivo en vez de sondear, escucha los eventos de tarea por SSE — mira [Eventos en vivo](eventos.html). ## Disparadores Un disparador ejecuta la tarea sin que nadie la llame: por horario, por evento del workspace o por webhook entrante. ```ts await met.tasks.triggers.create(tarea.id, { type: 'schedule', cron: '0 9 * * 1', // lunes 9am }); await met.tasks.triggers.updatePrimary(tarea.id, { enabled: false }); ``` `updatePrimary()` toca el disparador principal de la tarea sin que tengas que buscar su id. ## Comentarios y adjuntos El hilo de la tarea acepta archivos. Se sube primero y se manda la metadata después: ```ts const adjunto = await met.tasks.comments.upload(tarea.id, archivo, { filename: 'reporte.pdf', contentType: 'application/pdf', }); await met.tasks.comments.create(tarea.id, { body: 'Adjunto el reporte del cierre.', attachments: [adjunto], }); ``` ## Contexto y actividad ```ts const actividad = await met.tasks.activity(tarea.id); // de una tarea const todo = await met.tasks.workspaceActivity({ limit: 50 }); // de todo el workspace const vinculados = await met.tasks.linkedItems(tarea.id); // ítems de colección ``` `linkedItems()` devuelve los ítems de colección atados a la tarea: es cómo una tarea trabaja sobre datos concretos en vez de sobre el vacío. Mira [Colecciones e ítems](colecciones-e-items.html). ## Vistas guardadas Las vistas son del **workspace**, no de una tarea: filtros guardados sobre el listado. ```ts await met.tasks.views.create({ name: 'Bloqueadas', filters: { status: 'blocked' } }); await met.tasks.views.reorder([vistaA.id, vistaB.id]); ``` ## Estado ```ts await met.tasks.setStatus(tarea.id, 'paused'); ``` Pausar una tarea no detiene las ejecuciones en curso: impide que nazcan nuevas. Para cortar una corrida, `cancelExecution()`. --- # Funciones y flujos Una **Función IA** es UNA herramienta que el Met puede llamar (scopes `functions:read` / `functions:write`). Cuando el Met decide usarla, recolecta los argumentos según tus `parameters` y dispara el handler que definiste. Así conectas tu propio backend a la conversación. ## Parámetros: el schema de la tool Cada `FunctionParameter` entra al JSON schema que ve el LLM. Solo `name` es obligatorio: ```ts const parameters = [ { name: 'sku', description: 'Código de producto', required: true }, { name: 'moneda', description: 'Moneda del precio', enum_values: ['COP', 'USD'] }, ]; ``` Campos disponibles: `name`, `description`, `required`, `type` (`'string' | 'number' | 'boolean'`), `enum_values` (lista cerrada; `null`/`[]` = libre) y `save_to` (el `field_key` del contacto donde persistir el valor recolectado, o `null` para no guardar). Una Función sin handler queda **inerte** (no invocable). Tienes dos caminos para dárselo. ## A) Handler HTTP directo El camino más corto para pegarle a tu endpoint: pasa `http`. Los argumentos que el Met recolecta viajan como body JSON a tu URL. ```ts const fn = await met.functions.create({ name: 'consultar_precio', description: 'Consulta el precio vigente de un SKU en nuestro backend', prompt: 'Úsala cuando el cliente pregunte por el precio de un producto.', parameters, http: { url: 'https://api.tu-empresa.com/precios', method: 'POST', auth_workspace_variable: 'api_token', sign_secret_variable: 'firma_precios', timeout_ms: 15000, }, }); ``` > Los secretos NO van en crudo. `auth_workspace_variable` (Bearer token, se envía como `Authorization`) y `sign_secret_variable` (secreto de firma) se referencian por **nombre de variable del workspace**. Créalas antes con `met.variables.set('api_token', 'sk_live_…', { encrypted: true })` — ver [Variables del workspace](variables.html). Meteor firma cada llamada con `X-Met-Signature` (mismo HMAC que los webhooks). Verifícala en tu endpoint sin escribir HMAC a mano: ```ts const handle = met.tools.createHandler(process.env.FIRMA_PRECIOS); app.post('/precios', (req, res) => { let call; try { call = handle(req.rawBody, req.headers['x-met-signature']); } catch { return res.status(400).end(); } res.json({ result: buscarPrecio(call.arguments) }); }); ``` Los headers estáticos NO sensibles van en `headers`. `timeout_ms` es opcional (default 30s, tope 60s). ## B) Flujo (`flow_id`) Cuando necesitas lógica multi-paso, ramas o varios nodos, apunta la Función a un Meteor Flow (scopes `flows:write` / `automations:execute`). El patrón es `create` → `update` con el lienzo → `publish`: ```ts const flow = await met.flows.create({ name: 'llamar-mi-api' }); await met.flows.update(flow.id, { draft_definition: { nodes: [{ id: 'call', type: 'http.request', url: 'https://api.tu-empresa.com/precios' }], edges: [], }, }); await met.flows.publish(flow.id); // el Met solo invoca la versión publicada const fn = await met.functions.create({ name: 'consultar_precio', parameters, flow_id: flow.id }); ``` El nodo `http.request` puede firmar la petición con `X-Met-Signature` igual que el handler directo. Prueba el flujo antes de publicar con `met.flows.run(flow.id, { use_draft: true })`. ## Vincular al Met Crear la Función no la activa en ningún Met: hay que vincularla. ```ts await met.functions.link(agentId, fn.id); // idempotente await met.functions.listForAgent(agentId); // → [12, 34] (ids vinculados) await met.functions.unlink(agentId, fn.id); // desvincular ``` > Para confirmar que tu Función quedó disponible como tool de un Met, usa `met.agents.tools(agentId)`: devuelve cada herramienta con su `source` (`'function'` para las tuyas) y si está `disabled`. Ver [Mets y herramientas](mets-y-herramientas.html). ## End-to-end ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY, { workspaceId: 130 }); // 1. Secreto por variable (no en crudo) await met.variables.set('api_token', process.env.API_TOKEN, { encrypted: true }); // 2. Función con handler HTTP const fn = await met.functions.create({ name: 'consultar_precio', description: 'Consulta el precio vigente de un SKU', parameters: [{ name: 'sku', description: 'Código de producto', required: true }], http: { url: 'https://api.tu-empresa.com/precios', method: 'POST', auth_workspace_variable: 'api_token' }, }); // 3. Vincular al Met y probar con un run que la use await met.functions.link(agentId, fn.id); const run = await met.runs.create({ met: 'Valeria', input: '¿Cuánto vale el SKU AB-12?' }); console.log(run.output); // el Met llamó a tu endpoint y respondió con el precio ``` Ver [Ejecutar Mets (Runs)](ejecutar-agentes.html) para el detalle de `runs.create`. --- # Automatizaciones Una **automatización** une dos cosas: un `trigger_type` —el evento que la despierta— y un `action_kind` —lo que hace cuando eso pasa—. Es el *cuándo* del plano agéntico: [Funciones y flujos](funciones-y-flujos.html) pone la lógica, [Tareas](tareas.html) pone el procedimiento, y esto decide qué los enciende. Usa los scopes `automations:read`, `automations:write` y, para disparar a mano, `automations:execute`. ## El modelo en una frase **Un tipo del catálogo, unas condiciones y una acción.** Nada más: ```ts const auto = await met.automations.create({ name: 'Publicar cuando el deal se gana', trigger_type: 'item.field_changed', conditions: { collection_id: 42, field: 'estado', to: 'ganado' }, action_kind: 'flow', flow_id: 'f1e2d3c4-0000-4000-8000-123456789abc', }); console.log(auto.id, auto.enabled); // → '…', true ``` ```python auto = met.automations.create( name="Publicar cuando el deal se gana", trigger_type="item.field_changed", conditions={"collection_id": 42, "field": "estado", "to": "ganado"}, action_kind="flow", flow_id="f1e2d3c4-0000-4000-8000-123456789abc", ) print(auto["id"], auto["enabled"]) ``` ```bash curl -X POST https://api.met.meteor.com.co/api/v1/workspaces/7/automations \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Publicar cuando el deal se gana", "trigger_type": "item.field_changed", "conditions": { "collection_id": 42, "field": "estado", "to": "ganado" }, "action_kind": "flow", "flow_id": "f1e2d3c4-0000-4000-8000-123456789abc" }' ``` Nace encendida salvo que mandes `enabled: false`. Los campos opcionales son `description`, `icon`, `collection_id`, `action_config` y `enabled`. ## El catálogo manda `trigger_type` no es texto libre: sale del catálogo. Léelo antes de construir nada, porque es también la lista de lo que puedes ofrecerle a tu usuario. ```ts const catalogo = await met.automations.catalog(); ``` ```python catalogo = met.automations.catalog() ``` ```bash curl https://api.met.meteor.com.co/api/v1/trigger-catalog \ -H "Authorization: Bearer $MET_API_KEY" ``` Cada entrada trae `type` (lo que va en `trigger_type`), `category`, `label`, `description`, `icon`, `conditions_schema` —qué hay que llenarle—, `exposes` —qué variables recibe la acción cuando se dispara— y `enabled`. > **`enabled: false` no es un permiso que te falte.** Son tipos publicados en el catálogo a los que el despachador todavía no reacciona. Se leen; crearlos responde `400`. Filtra por `enabled` antes de pintar opciones en tu propia interfaz. Los tipos que hoy disparan, con sus condiciones obligatorias: | `trigger_type` | Se dispara cuando | Condiciones obligatorias | |---|---|---| | `item.created` | Entra un ítem nuevo a una colección. | `collection_id` | | `item.field_changed` | Un campo del ítem pasa a un valor concreto. | `collection_id`, `field`, `to` | | `item.updated` | El ítem cambia, sin importar qué campo. | `collection_id` | | `item.deleted` | Se borra un ítem de la colección. | `collection_id` | | `contact.created` | Entra un contacto al CRM (manual, por canal o por API). | — | | `contact.field_updated` | Cambia un campo del contacto. | `field`, `operator` | | `date_time` | Llega un instante exacto. Una sola vez. | `scheduled_date` (ISO 8601) | | `recurrent` | Toca el patrón cron. | `recurrence_pattern` | | `field_datetime` | Un tiempo antes, en, o después de un campo de fecha. | `source`, `field_id`, `direction` | | `webhook.received` | Llega un `POST` a un webhook entrante tuyo. | `endpoint_slug` | | `manual` | Solo a pedido. Ver la nota más abajo. | — | Al crear se verifica que las condiciones **obligatorias** estén presentes; el resto del objeto viaja tal cual y lo interpreta cada tipo. Si te falta una, la respuesta te dice cuál: `Condición "collection_id" requerida para trigger_type "item.created"`. `recurrence_pattern` acepta un cron de cinco partes (`0 9 * * 1`) o uno de estos alias: `hourly` (en punto, cada hora), `daily` (9:00), `weekly` (lunes 9:00) y `monthly` (día 1, 9:00). El patrón se evalúa en la zona horaria de tu workspace, y si no tiene una configurada, en hora de Colombia. > **`manual` no tiene disparo por API.** Se puede crear, pero `run-now` cubre únicamente procesos administrados y flujos recurrentes (más abajo). Si necesitas arrancar algo desde tu código cuando tú decidas, ejecuta el flujo directo con `met.flows.run(flowId)` o la tarea con `met.tasks.execute(taskId)`. ## Qué ejecuta: `action_kind` | `action_kind` | Qué hace | Qué le tienes que dar | |---|---|---| | `task` | Ejecuta una tarea del workspace. | `task_id` | | `flow` | Ejecuta un flujo **publicado**. | `flow_id` | | `ai_field` | Rellena un campo de la colección con IA. | `collection_id` y `action_config.field` | Si omites `action_kind`, se asume `task` — y entonces `task_id` pasa a ser obligatorio. > El flujo tiene que estar **publicado**. Conectar uno que solo tiene borrador responde `400`: publícalo antes con `met.flows.publish(flowId)` — ver [Funciones y flujos](funciones-y-flujos.html). La tarea o el flujo, además, tienen que ser de tu mismo workspace. ## Leer, encender, apagar y borrar ```ts const todas = await met.automations.list(); const deUnaTarea = await met.automations.list({ taskId: 'd4c3b2a1-…' }); const una = await met.automations.retrieve(auto.id); await met.automations.setEnabled(auto.id, false); // apagar await met.automations.update(auto.id, { conditions: { collection_id: 42, field: 'estado', to: 'perdido' } }); await met.automations.remove(auto.id); ``` ```python todas = met.automations.list() de_una_tarea = met.automations.list(task_id="d4c3b2a1-…") una = met.automations.retrieve(auto["id"]) met.automations.set_enabled(auto["id"], False) met.automations.update(auto["id"], conditions={"collection_id": 42, "field": "estado", "to": "perdido"}) met.automations.remove(auto["id"]) ``` ```bash curl https://api.met.meteor.com.co/api/v1/workspaces/7/automations \ -H "Authorization: Bearer $MET_API_KEY" curl -X PATCH https://api.met.meteor.com.co/api/v1/workspaces/7/automations/$AUTO_ID \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"enabled":false}' ``` `setEnabled()` es un atajo de `update()`: manda solo `enabled` y no toca el resto de la configuración. Dos cosas que el listado devuelve y conviene mirar antes de creer que algo no anda: - **`name` e `icon` siempre vienen resueltos.** Si no le pusiste nombre, cae al de la tarea o el flujo destino, y en último caso a `(sin nombre)`; el ícono cae al del tipo de disparador. - **`task_name` y `flow_name`** te ahorran la segunda llamada para saber qué ejecuta cada fila. ## Cuando la pausa no la pusiste tú Una automatización puede volver apagada sin que nadie la haya tocado. Pasa con las que ejecutan un flujo: si una sola se desboca y consume por sí misma el techo de corridas del día del workspace, Meteor **pausa esa automatización** y deja el motivo escrito. ```ts const a = await met.automations.retrieve(id); if (!a.enabled && a.paused_reason) { // la frenó el sistema; a.paused_reason dice por qué } ``` `paused_reason: null` con `enabled: false` significa lo contrario: la apagó una persona. Volver a encenderla con `enabled: true` limpia el motivo y le da cuenta nueva. Lo importante para tu integración: se pausa **la automatización culpable, no el workspace**. Las demás siguen corriendo. ## Procesos administrados Algunas filas del listado llegan con `is_system_managed: true`. Son procesos que **publica y versiona Meteor** —reportes, recordatorios, herramientas que un Met usa dentro de una conversación—: su definición no se edita por `PATCH`, y `update()` con cualquier campo que no sea `enabled` responde `400`. Tampoco se borran. A cambio, tienen una superficie propia. Antes de usarla, mira lo que la propia fila declara: ```ts const p = await met.automations.retrieve(id); p.is_system_managed; // true p.capabilities.editable_fields; // ['schedule', 'flow'] p.capabilities.locked_fields; // ['handler', 'tool_name', 'templates', 'recipients'] p.capabilities.can_restore; // true p.managed_revision; // 2 p.manual_run; // ¿admite ejecución a pedido? p.last_run; // la última corrida, ya resuelta ``` > Los seis métodos que siguen son **solo** para estas filas. Sobre una automatización que creaste tú responden `400`, y eso es el contrato, no un problema de tu key. ### Historial de corridas ```ts const corridas = await met.automations.runs(id, { limit: 50 }); // [{ id, source: 'scheduled' | 'manual', status, run_key, result, error_message, started_at, finished_at }, …] ``` ```python corridas = met.automations.runs(automation_id, limit=50) ``` ```bash curl "https://api.met.meteor.com.co/api/v1/workspaces/7/automations/$AUTO_ID/runs?limit=50" \ -H "Authorization: Bearer $MET_API_KEY" ``` De la más reciente hacia atrás. `limit` va entre 1 y 100; sin él, 20. `run_key` es la llave de la ocurrencia (por ejemplo `2026-08-14`): es lo que hace que el horario no corra dos veces lo mismo. Para el historial de un flujo tuyo, la ruta es otra: `met.flows.listRuns(flowId)`. ### Ejecutar a pedido ```ts const { ok, already_processed, run } = await met.automations.runNow(id, { input: { periodo: '2026-08-14' }, }); ``` ```python res = met.automations.run_now(automation_id, input={"periodo": "2026-08-14"}) ``` ```bash curl -X POST https://api.met.meteor.com.co/api/v1/workspaces/7/automations/$AUTO_ID/run-now \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":{"periodo":"2026-08-14"}}' ``` `already_processed: true` significa que esa ocurrencia ya estaba corrida y tu pedido no duplicó nada. **La respuesta tiene dos formas y confundirlas cuesta una tarde:** - Si el proceso ejecuta un trabajo del sistema (`action_kind: 'system_job'`), `run` es la corrida real: el mismo `id` que vas a ver después en `runs()`. - Si ejecuta un flujo (`action_kind: 'flow'`), `run` viene con `status: 'processing'` y un `id` que es el del **flow run**, no el de una corrida administrada. Ese `id` no aparece nunca en `runs()`. Para seguirlo: ```ts const flowRun = await met.flows.findRun(run.id); ``` Responde `400` si el proceso está pausado, si no admite ejecución a pedido (`manual_run` en `false`) o si es un flujo que no es recurrente. ### Cambiar el horario ```ts await met.automations.updateSchedule(id, '0 12 * * 1-6'); // 12:00, de lunes a sábado ``` ```python met.automations.update_schedule(automation_id, "0 12 * * 1-6") ``` ```bash curl -X PATCH https://api.met.meteor.com.co/api/v1/workspaces/7/automations/$AUTO_ID/schedule \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"recurrence_pattern":"0 12 * * 1-6"}' ``` El cron es **acotado a propósito**: cinco partes, con una hora fija y días de la semana. El día del mes y el mes tienen que ir en `*`; cualquier otra cosa responde `400`. Y solo aplica si `capabilities.editable_fields` incluye `schedule` — un proceso que se activa por un evento no tiene horario que cambiar. ### Editar sin pisar a nadie ```ts const p = await met.automations.retrieve(id); await met.automations.updateConfiguration(id, { expected_revision: p.managed_revision, recurrence_pattern: '0 7 * * 1-5', flow_id: null, // desconecta el flujo posterior }); ``` ```python p = met.automations.retrieve(automation_id) met.automations.update_configuration( automation_id, expected_revision=p["managed_revision"], recurrence_pattern="0 7 * * 1-5", flow_id=None, ) ``` ```bash curl -X PATCH https://api.met.meteor.com.co/api/v1/workspaces/7/automations/$AUTO_ID/configuration \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"expected_revision":2,"recurrence_pattern":"0 7 * * 1-5","flow_id":null}' ``` `expected_revision` es la revisión que traía la fila cuando la leíste. Si alguien la cambió en el medio, la llamada falla con **`409`** en vez de pisar el cambio ajeno; en los SDK llega como `MetInvalidRequestError` con `status` 409. Relee el proceso y vuelve a intentar con la revisión nueva. Manda al menos uno de los dos campos configurables: sin ninguno responde `400`. `flow_id: null` es explícito y sí se envía — desconecta el flujo. ### Deshacer y ver quién tocó qué ```ts await met.automations.restoreConfiguration(id, { expected_revision: p.managed_revision }); const cambios = await met.automations.configurationHistory(id, { limit: 20 }); // [{ revision, operation: 'update' | 'restore', changed_by_user_id, before_config, after_config, created_at }, …] ``` ```python met.automations.restore_configuration(automation_id, expected_revision=p["managed_revision"]) cambios = met.automations.configuration_history(automation_id, limit=20) ``` `restoreConfiguration()` devuelve el proceso a la configuración que publicó Meteor y solo aplica si `capabilities.can_restore` es `true`. El historial va de lo más reciente hacia atrás, con `limit` entre 1 y 100 (sin él, 20). ## Programar una tarea: usa esta API, no la de la tarea Existe una ruta anterior para colgarle un disparador a una tarea desde la tarea misma (`POST /tasks/{taskId}/triggers`). Sigue publicada por compatibilidad, pero lo que crea **no queda atado a un workspace**, así que no aparece en `GET /workspaces/{workspaceId}/automations` ni pasa por la validación del catálogo. Para programar una tarea hoy, crea una automatización que apunte a ella: ```ts await met.automations.create({ name: 'Calificar leads cada lunes', trigger_type: 'recurrent', conditions: { recurrence_pattern: '0 9 * * 1' }, action_kind: 'task', task_id: tarea.id, }); ``` ## Errores que vas a ver | Respuesta | Qué la causa | |---|---| | `400` `trigger_type "…" no existe en el catálogo` | El tipo no está en `GET /trigger-catalog`. | | `400` `…todavía no está habilitado en producción` | El tipo existe pero llegó con `enabled: false`. | | `400` `Condición "…" requerida para trigger_type "…"` | Falta una condición obligatoria. | | `400` `El flow no ha sido publicado` | Conectaste un flujo que solo tiene borrador. | | `400` `La task no pertenece a este workspace` | La tarea o el flujo son de otro workspace. | | `400` `Esta automatización no tiene historial administrado` | Llamaste a `runs()` sobre una automatización tuya. | | `400` `Los procesos administrados solo permiten activar o pausar` | Un `update()` con campos que no son `enabled` sobre un proceso administrado. | | `409` `La configuración cambió en otra sesión` | Tu `expected_revision` quedó viejo. | El resto —`401` por key o workspace equivocado, `429` por límites— se comporta igual que en toda la API: ver [Errores, idempotencia y paginación](errores-e-idempotencia.html). --- # Mets y sus herramientas Un **Met** es un agente de tu workspace: tiene una misión, instrucciones, Funciones IA, skills y acceso a los datos del workspace (colecciones y contactos). Con `met.agents` los creas y ajustas por código; con la introspección de tools sabes exactamente qué ve cada uno. ## Crear y ajustar Mets ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: 40 }); const agente = await met.agents.create({ name: 'Valeria', title: 'Asesora comercial', mission: 'Calificar y responder leads entrantes', instructions: 'Sé concisa. Confirma presupuesto antes de agendar.', }); await met.agents.list(); // todos los Mets del workspace await met.agents.retrieve(agente.id); await met.agents.update(agente.id, { instructions: 'Nuevo guion.', active: true }); ``` Los campos editables son `name`, `title`, `mission`, `instructions` y `active`. Para darle una skill del workspace: `met.agents.linkSkill(agentId, skillId)` (y `unlinkSkill` para quitarla). ## Ver qué herramientas tiene un Met Este es el diagnóstico #1 cuando una Función no aflora o cuando no sabes qué apagar: ```ts const tools = await met.agents.tools(agente.id); // [{ name, source: 'collection'|'skill'|'function'|'internal', description, disabled }] ``` Cada tool trae su `source` (de dónde viene) y `disabled` (si ya está apagada para este Met). Si tu Función no aparece con `source: 'function'`, el problema está en la Función misma (sin handler, `active: false`), no en el Met. Los nombres que devuelve este método son los **únicos válidos** para `disabled_tools`. ## Apagar herramientas por Met ```ts await met.agents.update(agente.id, { disabled_tools: ['mcp_meteor_list_items', 'mcp_meteor_get_item'], }); ``` Deshabilitar una tool no la desvincula: la deja fuera del set que el LLM puede llamar (se oculta del catálogo y se rechaza en runtime). Toma los nombres de `agents.tools()`. > Las tools `mcp_meteor_*` (source `internal`) son **genéricas**: operan sobre todas las colecciones del workspace, no sobre una sola. Si apagas `mcp_meteor_list_items`, se lo quitas al Met para **todas** las colecciones a la vez. Para cerrar una colección puntual, usa `expose_to_agent` (abajo). ## Ocultar una colección al Met Para que ninguna vía —API, chat o canal— exponga una colección al LLM, márcala como no visible, sin borrar datos: ```ts await met.collections.update(collectionId, { expose_to_agent: false }); ``` `expose_to_agent` viene en `true` por defecto. Ponla en `false` para datos internos que el Met no debe consultar, y vuelve a `true` para reexponerla. Más en [Colecciones e items](colecciones-e-items.html). > Qué Met responde un run se decide por ruteo determinístico (el bind `met`), no por estas tools. Ver [Ejecutar Mets](ejecutar-agentes.html). ## Ejemplo: dejar un Met con solo su Función ```ts const bot = await met.agents.create({ name: 'Cotizador', mission: 'Cotiza por API' }); // 1. Mira qué tiene disponible const tools = await met.agents.tools(bot.id); // 2. Apaga todo lo interno (colecciones genéricas) y deja pasar solo su Función const internas = tools.filter((t) => t.source === 'internal').map((t) => t.name); await met.agents.update(bot.id, { disabled_tools: internas }); // 3. Cierra además una colección sensible en todos los caminos await met.collections.update(preciosInternosId, { expose_to_agent: false }); ``` Así el Met queda acotado a su Función IA, sin acceso indiscriminado a las colecciones del workspace. --- # Skills Una **skill** es una capacidad empaquetada que un Met puede usar: hablar con un sistema externo, ejecutar un procedimiento, leer una fuente de datos. En vez de cablear cada integración a mano, activas la skill y el Met ya sabe usarla. Usa los scopes `skills:read` y `skills:manage`. ## El modelo en tres piezas | pieza | qué es | |---|---| | **Catálogo** | las skills disponibles para tu workspace — las del sistema y las del marketplace | | **Activación** | la skill instalada en tu workspace, con sus credenciales y su estado | | **Vínculo** | qué Mets pueden usarla. Activarla no se la da a todos: se vincula por Met | Esa tercera pieza es la que más se pasa por alto. **Activar una skill no hace que tus Mets la usen** — hay que vincularla. Es a propósito: así un Met de soporte no hereda las capacidades del de facturación. ## Ver qué hay ```ts const catalogo = await met.skills.marketplace(); // las del marketplace const sistema = await met.skills.system(); // las que trae Meteor const activas = await met.skills.active(); // las que ya tienes activas const todas = await met.skills.all(); ``` Para saber en qué estado está cada una: ```ts const estados = await met.skills.status(); // → [{ skill_id, active, configured, missing_credentials: [...] }, …] const una = await met.skills.skillStatus('crm-hubspot'); ``` `configured` es la que importa antes de vincular: una skill puede estar **activa y sin credenciales**, y en ese caso el Met la ve y falla al usarla. ## Activar y configurar ```ts await met.skills.activate('crm-hubspot'); await met.skills.setCredentials('crm-hubspot', { api_key: process.env.HUBSPOT_KEY, }); ``` Las credenciales son **write-only**: se guardan cifradas y ninguna respuesta de la API las devuelve, ni completas ni enmascaradas. Para saber si están puestas usas `configured` del estado; para cambiarlas, las vuelves a mandar enteras. Antes de dárselas a un Met, pruébalas: ```ts const prueba = await met.skills.test('crm-hubspot', { /* payload de ejemplo */ }); ``` `test` ejecuta la skill de verdad contra el sistema externo, así que gasta lo que gaste esa llamada. Con una key `met_test_` no puedes ejecutar skills que tengan efectos externos — ver [Autenticación](autenticacion.html). ## Vincularla a un Met ```ts await met.skills.linkToAgent(agentId, 'crm-hubspot'); const delMet = await met.skills.forAgent(agentId); // qué ve ese Met await met.skills.unlinkFromAgent(agentId, 'crm-hubspot'); ``` Desvincular **no** desactiva la skill ni borra sus credenciales: solo se la quita a ese Met. Para sacarla del workspace: ```ts await met.skills.deactivate('crm-hubspot'); // conserva credenciales await met.skills.remove('crm-hubspot'); // la saca del workspace ``` Las ejecuciones en curso que ya estaban usando la skill terminan; las siguientes dejan de verla. ## Skills con sus propios MCPs Una skill puede traer servidores MCP propios, y puedes controlar cuáles quedan expuestos: ```ts const servidores = await met.skills.mcps('crm-hubspot'); await met.skills.setMcps('crm-hubspot', { /* configuración */ }); await met.skills.updateMcp('crm-hubspot', mcpId, { /* cambios */ }); await met.skills.removeMcp('crm-hubspot', mcpId); ``` No confundir con [Integraciones MCP](integraciones-mcp.html), que son conectores que tú registras a nivel de workspace: los de aquí vienen dentro de la skill y viven y mueren con ella. ## Cuándo una skill y cuándo una tool suelta - **Skill** — si la capacidad tiene credenciales, varias operaciones y quieres prenderla o apagarla como una unidad. - **[Integración MCP](integraciones-mcp.html)** — si ya tienes un servidor MCP y solo quieres que Meteor lo use. - **[Función](funciones-y-flujos.html)** — si es tu propio código y quieres que el Met lo llame como un endpoint. > El orden que casi siempre quieres: `status` → `activate` → `setCredentials` → > `test` → `linkToAgent`. Saltarse `test` es la causa más común de un Met que > "tiene la skill" y falla al primer uso. --- # Imágenes `met.images` genera, edita y anima imágenes desde tu código, por la **misma key** que el resto de la API. No necesitas contratar ni firmar contra un proveedor externo: Meteor corre el modelo, aloja el resultado y **debita Energía** a tu workspace por cada operación. > Requiere una key **live** (`met_live_…`) con scope `integrations:execute`. Una key de test devuelve `403`. Además, tu workspace debe tener una **integración de imágenes ACTIVA** en el marketplace de MCPs; sin ella, la generación falla. Como todas las operaciones con efectos, corre server-side: ```ts import { Met } from '@meteor.ia/sdk'; const met = new Met(process.env.MET_KEY!, { workspaceId: 42 }); ``` ## Generar una imagen ```ts const img = await met.images.generate({ prompt: 'Mockup de una taza blanca con logo minimalista, fondo neutro', aspect_ratio: '1:1', }); console.log(img.url); // URL pública alojada por Meteor console.log(img.format); // 'png' | 'jpeg' | 'webp' | … console.log(img.size_bytes); // tamaño del archivo console.log(img.model); // modelo usado console.log(img.energy_debited); // Energía debitada por esta operación ``` El resultado es una **URL pública alojada por Meteor**, no los bytes crudos. Guárdala o pásala directo a donde la necesites: ```ts const mockup = await met.images.generate({ prompt: 'Empaque de café artesanal, luz de estudio' }); await met.contacts.update(310, { image: mockup.url }); ``` `model` y `aspect_ratio` son opcionales. Si omites `model`, se usa el **modelo por defecto** de la integración activa. `aspect_ratio` acepta valores como `'1:1'`, `'16:9'`, `'9:16'` o `'3:4'`. ## Editar una imagen Transforma una imagen existente pasándola en base64: ```ts const edited = await met.images.edit({ prompt: 'Cambia el fondo a azul degradado', image_base64: '...', // la imagen base, en base64 }); console.log(edited.url); ``` Devuelve la misma forma que `generate` (`url`, `format`, `model`, `energy_debited`). ## Animar (video) Genera un clip desde un prompt, opcionalmente animando una imagen inicial: ```ts const clip = await met.images.video({ prompt: 'La taza gira lentamente sobre la mesa', image_base64: '...', // primer frame opcional (base64) duration_seconds: 4, }); console.log(clip.url); // URL del video alojado console.log(clip.duration_seconds); // duración real del clip console.log(clip.energy_debited); ``` `image_base64`, `duration_seconds` y `model` son opcionales. ## Costo Cada `generate`, `edit` y `video` **debita Energía** a tu workspace y se atribuye a la key. El monto llega en `energy_debited` de la respuesta, y el consumo por API aparece separado del chat en tu dashboard. Si te faltan errores o paginación, mira [Errores e idempotencia](errores-e-idempotencia.html). --- # Variables del workspace `met.variables` guarda dos cosas del workspace: **variables de valor** (configuración y secretos, como el token de una API externa) y las **definiciones de los campos personalizados** de tu CRM. Lo primero necesita el scope `variables:write` para escribir; lo segundo te deja descubrir qué claves acepta un contacto. ```ts const met = new Met(key, { workspaceId }); ``` ## Variables de valor Una variable es un par `name` → `value` que vive en el workspace. Con `set()` haces upsert por nombre (crea o actualiza según exista): es la forma más cómoda. ```ts await met.variables.set('mi_api_token', 'sk_live_...', { encrypted: true }); const vars = await met.variables.list(); // no devuelve secretos en claro ``` Marca `encrypted: true` para tokens y secretos: el valor se guarda cifrado y `list()` ya no lo devuelve en claro. Si prefieres las operaciones explícitas (por id), tienes `create`, `update` y `delete`: ```ts const v = await met.variables.create({ name: 'saludo', value: '¡Hola!', description: 'Texto por defecto', }); // POST /variables (variables:write) await met.variables.update(v.id, { value: '¡Bienvenido!' }); await met.variables.delete(v.id); ``` > `set()` corre un `list()` para buscar el nombre; si vas a escribir muchas variables seguidas, usa `create`/`update` directo con el id que ya tienes. ## Token → Función HTTP El caso estrella: una [Función HTTP](funciones-y-flujos.html) resuelve su token de autenticación por **nombre de variable**, no en crudo. Guardas el token una vez y la Función lo referencia con `auth_workspace_variable`; Meteor lo envía como `Authorization: Bearer ` al llamar tu endpoint. ```ts // 1. Guarda el secreto (cifrado) await met.variables.set('mi_api_token', 'sk_live_...', { encrypted: true }); // 2. La Función lo referencia por nombre await met.functions.create({ name: 'consultar_precio', description: 'Consulta el precio de un producto en la API externa', http: { url: 'https://api.example.com/precio', method: 'POST', auth_workspace_variable: 'mi_api_token', }, }); ``` Así el token nunca queda escrito en la definición de la Función. Para rotarlo, un solo `set()` con el mismo nombre lo reemplaza y todas las Funciones que lo usan quedan al día. ## Definiciones de campos del CRM Los campos personalizados de un contacto viven en su `data` con la **clave pelada** (`etapa`, `monto`); los campos de sistema llevan `$` (`$name`, `$status`). Para saber qué claves existen y qué opciones acepta un `select`, descubre el catálogo: ```ts const defs = await met.variables.fieldDefinitions(); // variables:read const etapas = defs.find((d) => d.field_key === 'etapa')?.options ?? []; await met.contacts.update(42, { data: { etapa: etapas[0] } }); ``` Cada definición trae `field_key`, `label`, `type` (`text` · `number` · `select` · `currency` · `date` · …) y `options`. También puedes gestionarlas por código: ```ts const campo = await met.variables.createFieldDefinition({ field_key: 'etapa', label: 'Etapa', type: 'select', options: ['Nuevo', 'Contactado', 'Cerrado'], }); await met.variables.updateFieldDefinition(campo.id, { options: [...campo.options, 'Perdido'] }); await met.variables.deleteFieldDefinition(campo.id); ``` `field_key` y `type` no se editan: cambiarlos rompería los valores ya guardados en los contactos. Solo ajustas `label`, `description` y `options`. > Para leer y escribir esos valores en cada contacto, mira [Contactos (CRM)](contactos.html). --- # Datos: colecciones e ítems Una **colección** es una tabla de datos estructurados del workspace; sus registros son **ítems**. Es el almacén que tu Met puede leer como fuente de datos y que tú operas por API para guardar, buscar y actualizar información. Trabaja server-side con tu key: ```ts const met = new Met(key, { workspaceId }); ``` ## Colecciones ```ts const col = await met.collections.create({ name: 'Preferencias' }); await met.collections.list(); // Collection[] await met.collections.retrieve(col.id); await met.collections.update(col.id, { name: 'Preferencias del cliente' }); await met.collections.delete(col.id); ``` `create` acepta `name` y un `folder_id?` opcional (scopes `collections:read`/`collections:write`). El `id` de una colección es **numérico**. > **Ocultar una colección al Met sin borrarla:** ponla en `expose_to_agent: false` (default `true`). El Met deja de verla como fuente de datos, pero tú la sigues operando por API. Reexponla con `update(col.id, { expose_to_agent: true })`. Más contexto en [Mets y herramientas](mets-y-herramientas.html). ## Ítems El primer argumento de casi todo es el `collectionId` **numérico**: ```ts const item = await met.items.create(col.id, { nombre: 'Ana', canal: 'preferido' }); await met.items.list(col.id); // una página { data, has_more } for await (const it of met.items.iterate(col.id)) { /* todas las páginas */ } await met.items.retrieve(item.id); await met.items.update(col.id, item.id, { canal: 'whatsapp' }); await met.items.patchField(col.id, item.id, 'canal', 'email'); // un solo campo await met.items.setStatus(item.id, 'archivado'); await met.items.delete(item.id); ``` Los datos del ítem viven en `item.data`. Si un campo es una fórmula (`"=…"`), su resultado resuelto aparece en `item.computed[campo]` — `data` conserva la fórmula cruda; escribe siempre sobre `data`. Para un ítem sin colección (a nivel workspace) usa `met.items.createOrphan(body)`. ## Búsqueda `met.items.search(query)` devuelve un `Item[]` con las coincidencias en todo el workspace: ```ts const encontrados = await met.items.search('descuento anual'); ``` Es **búsqueda léxica por texto**: coincide por las palabras que aparecen en los campos del ítem. **No es semántica ni vectorial** — no hay embeddings ni ranking por significado, así que busca por los términos literales que esperas encontrar. Para acotar por otro campo, **filtra el resultado en tu código**: ```ts const soloDeAna = (await met.items.search('preferencia')) .filter((it) => it.data?.contact_id === 42); ``` ## Ejemplo: memoria por cliente Una colección de preferencias, un ítem por contacto, y recuperación por búsqueda + filtro: ```ts const prefs = await met.collections.create({ name: 'preferencias' }); await met.items.create(prefs.id, { contact_id: 42, nota: 'Prefiere que le escriban por la mañana, tono cercano.', }); // Más tarde: recuperar lo que sabemos de ese contacto const memoria = (await met.items.search('prefiere')) .filter((it) => it.data?.contact_id === 42); ``` Como `search` es léxica, guarda en el texto del ítem las palabras por las que luego querrás encontrarlo. ## Carpetas Cuando el workspace pasa de unas diez colecciones, el panel las agrupa en carpetas. Es organización de la vista, no del dato: mover una colección de carpeta no cambia sus ítems ni rompe ninguna referencia. ```ts const carpetas = await met.folders.list(); const arbol = await met.folders.hierarchy(); ``` `list()` devuelve las carpetas planas; `hierarchy()` las devuelve anidadas, que es lo que quieres para pintar un árbol sin reconstruir la relación padre-hijo a mano. ```ts const f = await met.folders.create({ name: 'Operación' }); await met.folders.move(f.id, null); // null = raíz await met.folders.reorder([f.id, otra.id]); ``` `move` acepta `null` como padre para sacar una carpeta a la raíz. El orden es explícito y persistente: `reorder` recibe los ids en el orden que quieres, y `reorderCollectionsInFolder` hace lo mismo con las colecciones de una carpeta. No hay orden alfabético automático — si no reordenas, quedan como se crearon. ## Slugs: URLs legibles en vez de ids Un ítem y una colección tienen id numérico, y además pueden tener un **slug**. Sirve para que tu integración no tenga que guardar ids nuestros: ```ts await met.slugs.updateCollectionSlug(collection.id, 'facturas'); await met.slugs.updateItemSlug(item.id, 'inv-001'); const col = await met.slugs.resolveCollectionBySlug('facturas'); const inv = await met.slugs.resolveItemBySlug(col.id, 'inv-001'); ``` El slug de un ítem es único **dentro de su colección**, no en todo el workspace: por eso `resolveItemBySlug` pide las dos cosas. El de una colección sí es único en el workspace. Hay dos formas más de llegar a algo, y la diferencia importa: ```ts // Por ruta legible: carpeta/colección const r = await met.slugs.resolvePath('operacion/facturas'); // Por código corto del ítem (el que muestra el panel) const x = await met.slugs.resolveByCode('AB/12'); ``` `resolvePath` es para construir URLs que un humano lee y edita. `resolveByCode` es para el camino inverso: alguien te dicta el código que ve en pantalla y lo tienes que encontrar. El código lo genera Meteor y **no cambia**; el slug lo pones tú y puede cambiar, así que no lo uses como identificador estable en tu base — para eso está el id, o el código. --- # Integraciones MCP Una **integración** es un servidor MCP HTTP tuyo que registras en tu workspace. Meteor lo valida, cachea sus tools y las deja disponibles para que tus Mets (y tu código) las ejecuten. Es la forma self-service de darle a tus Mets capacidades propias sin esperar a que Meteor las incluya. > **No confundir con `mcp.html`.** Esta guía trata de conectar *tu* servidor MCP **a Meteor** (`met.integrations`). La guía de [Meteor como servidor MCP](../mcp.html) es lo contrario: conectar Meteor **como** servidor MCP a Cursor o Claude. Son dos direcciones distintas. ## Registrar tu servidor MCP ```ts const met = new Met(process.env.MET_API_KEY, { workspaceId: 42 }); const integration = await met.integrations.register({ name: 'Mi CRM', endpoint: 'https://mcp.miempresa.com', auth_header: 'Authorization: Bearer {token}', credentials: { token: process.env.CRM_TOKEN }, }); console.log(integration.id); // 'int_…' — úsalo como key console.log(integration.tools_count); // tools descubiertas ``` Al registrar, Meteor le hace `tools/list` a tu `endpoint` para **validar** que responde y **cachear** sus tools. Si el servidor no contesta, el registro falla. El `id` que devuelve es la **`key`** de la integración: la usas en `tools`, `execute`, `activate` y `deactivate`. Los secretos van en `credentials` (write-only: nunca se devuelven) y se rellenan en la plantilla `auth_header`. En el ejemplo, `{token}` se reemplaza por `credentials.token` en cada llamada a tu servidor. ## Listar y explorar tools ```ts const integrations = await met.integrations.list(); const tools = await met.integrations.tools(integration.id); for (const t of tools) { console.log(t.name, '—', t.description); // t.input_schema tiene el JSON Schema } ``` ## Ejecutar una tool ```ts const result = await met.integrations.execute(integration.id, 'buscar_cliente', { email: 'ana@ejemplo.com', }); console.log(result.status); // 'success' console.log(result.output); // lo que devuelve tu tool console.log(result.execution_time_ms); // latencia de la ejecución ``` El `input` es un objeto que cumple el `input_schema` de la tool. Una vez registrada y activa, la integración también queda disponible para el orquestador: tus Mets pueden invocar estas tools solos durante un [Run](ejecutar-agentes.html). ## Activar y desactivar ```ts await met.integrations.deactivate(integration.id); await met.integrations.activate(integration.id, { credentials: { token: nuevoToken } }); ``` `activate` sirve para reconectar y, de paso, **rotar credenciales**: pasa `credentials` para reemplazar los secretos guardados. ## Scopes y modo live - `integrations:read` — listar integraciones y ver sus tools. - `integrations:manage` — registrar, activar y desactivar. - `integrations:execute` — ejecutar tools. Las integraciones operan siempre contra tu servidor real, así que usa una **key live** (no sandbox). > **La generación de imágenes ya viene lista.** No necesitas registrar nada: es una integración incluida. Mira [Imágenes](imagenes.html). --- # Eventos en vivo `met.events` es el feed de lo que pasa en tu workspace: runs que terminan, mensajes que entran por un canal, contactos que se crean. Puedes leerlo en vivo por **Server-Sent Events (SSE)** o consultar el historial reciente. Requiere el scope `events:read`. ## Escuchar en vivo con `stream()` `met.events.stream()` abre una suscripción SSE **efímera** que te emite cada evento apenas ocurre. Es la misma fuente de `met listen` en la CLI: perfecta para el dev-loop y para tiempo real sin montar un endpoint. ```ts const met = new Met(key, { workspaceId }); for await (const ev of met.events.stream()) { console.log(ev.type, ev.data); } ``` Cada `ev` es un `{ id, type, created, livemode, data }` — los mismos tipos del catálogo de webhooks salientes. ### Filtrar por tipo Pasa `types` para recibir solo lo que te interesa: ```ts for await (const ev of met.events.stream({ types: ['run.completed', 'contact.message.received'] })) { if (ev.type === 'run.completed') console.log('run listo:', ev.data); } ``` ### Cortar el stream Rompe el `for await` (con `break`) o pasa un `AbortSignal` para cerrarlo desde afuera: ```ts const ac = new AbortController(); setTimeout(() => ac.abort(), 30_000); // corta a los 30s for await (const ev of met.events.stream({ signal: ac.signal })) { console.log(ev.type); } ``` Algunos tipos útiles del catálogo: `run.completed`, `run.failed`, `contact.message.received`, `contact.created`, `task.completed`, `conversation.handoff`. > **`stream()` vs. webhooks salientes.** `met.events.stream` es efímero: no reintenta y solo llega mientras tu proceso está conectado. Sirve para desarrollo y tiempo real. Para **entrega garantizada cross-instancia** (que el evento llegue aunque tu servicio esté caído y se reintente), monta un webhook persistente — ver [Webhooks](webhooks.html). ## Consultar el historial Cuando no necesitas tiempo real, lee lo ya ocurrido: ```ts const eventos = await met.events.list(); // últimos eventos (default 50) const actividad = await met.events.activity(); // actividad del workspace (default 50) const deItem = await met.events.itemActivity(1234); // actividad de un item puntual ``` Ambos `list()` y `activity()` aceptan `{ limit }` para ajustar cuántos registros traer. --- # CRM y contactos El dominio `contacts` te da acceso programático al CRM del workspace: contactos, mensajes, notas y etiquetas. Necesita los scopes `contacts:read` y/o `contacts:write`. Las rutas son **planas** (`/contacts/…`): operan sobre el workspace al que pertenece la key, así que no hace falta pasar `workspaceId`. ## Crear un contacto Requiere `phone` **o** `email`: ```ts const contact = await met.contacts.create({ name: 'Ada Lovelace', phone: '+573001112233', }); ``` ```python contact = met.contacts.create(name="Ada Lovelace", phone="+573001112233") ``` ```bash curl -X POST https://api.met.meteor.com.co/api/v1/contacts \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Ada Lovelace","phone":"+573001112233"}' ``` ## Mandar un mensaje ```ts await met.contacts.sendMessage(contact.id, '¡Hola! Te escribo desde la API 👋'); ``` ```python met.contacts.send_message(contact["id"], "¡Hola! Te escribo desde la API 👋") ``` Para plantillas de WhatsApp aprobadas: ```ts await met.contacts.sendTemplate(contact.id, { /* ... */ }); ``` ## Etiquetas y notas ```ts await met.contacts.addTag(contact.id, 'lead-caliente'); await met.contacts.createNote(contact.id, 'Pidió una demo para el viernes.'); const tags = await met.contacts.tags(contact.id); ``` ```python met.contacts.add_tag(contact["id"], "lead-caliente") met.contacts.create_note(contact["id"], "Pidió una demo para el viernes.") tags = met.contacts.tags(contact["id"]) ``` ## Campos del contacto: `$` para los de sistema, pelado para los tuyos Esta convención es la fuente de la mayoría de los errores al escribir datos, así que vale la pena tenerla clara: - **Campos de sistema** → van con `$`: `$name`, `$email`, `$phone`, `$status`, `$assigned_user_id`, `$tags`, `$last_message_at`. - **Campos personalizados** (los que define el workspace) → van con su `field_key` **pelado**, sin `$`: `etapa`, `monto`, `empresa`. Escribir `$etapa` en un campo personalizado **no falla**: guarda una clave distinta que ninguna vista lee. El campo queda invisible en la app. Para descubrir los campos personalizados de un workspace y sus valores válidos: ```ts const defs = await met.variables.fieldDefinitions(); // [{ field_key: 'etapa', type: 'select', options: ['Nuevo', 'Calificado', …] }, …] ``` En los `select` hay que mandar **exactamente** una de sus `options`. En los `currency`, `options` trae un solo elemento con el código ISO de la moneda (`['COP']`) y el valor del contacto es solo el número. ## El embudo comercial Todo workspace estrena tres campos personalizados —`etapa`, `monto` y `fecha_cierre_estimada`— y una vista "Pipeline". No hay una entidad "oportunidad" aparte: **el pipeline son campos del contacto**, así que se leen y escriben como cualquier otro campo. ```ts await met.contacts.update(contact.id, { data: { etapa: 'Negociación', monto: 6300000 } }); const summary = await met.contacts.pipelineSummary(); // { stages: [{ stage: 'Negociación', contacts: 12, amount: 45000000 }, …] } ``` `pipelineSummary()` agrega en la base sobre **todos** tus contactos, no sobre una página. Los contactos sin etapa se agrupan en `__no_value__`. Si tu workspace renombró esos campos, pásalos: `pipelineSummary({ stage_field: 'fase', amount_field: 'valor' })`. ## Recorrer la base Usa `iterate()` para recorrer **todos** los contactos sin manejar cursores a mano: ```ts for await (const c of met.contacts.iterate({ limit: 100 })) { console.log(c.id, c.name ?? c.phone); } ``` Por debajo pide `order=id` y encadena `starting_after`. Ese detalle importa si paginas a mano: el orden por defecto de `list()` es por recencia de la conversación y **se reordena con cada mensaje entrante**, así que un barrido sobre él se saltea contactos. Para recorrer todo sin perder nada, pide `order=id` desde la primera página y usa `starting_after` con el id del último contacto recibido. Para una sola página, `list()` acepta filtros del CRM (estado, canal, búsqueda) y devuelve `{ data, total, has_more }`. ## Leer conversaciones ```ts const page = await met.contacts.messages(contact.id, { limit: 50 }); ``` ## WhatsApp: más que texto `sendMessage` manda texto por el canal activo del contacto y alcanza para casi todo. Cuando necesitas que la persona **elija** en vez de escribir, WhatsApp tiene formatos propios, y van por el canal —no por el contacto— porque el formato depende de qué permite ese canal: ```ts await met.channels.whatsapp.sendButtons(channelId, { contact_id: contact.id, body_text: '¿Confirmamos la visita del jueves a las 3?', buttons: [ { id: 'si', title: 'Sí, confirmo' }, { id: 'reagendar', title: 'Reagendar' }, ], }); ``` Hasta **3 botones**; con más opciones, una lista: ```ts await met.channels.whatsapp.sendList(channelId, { contact_id: contact.id, body_text: 'Elige un horario', button_text: 'Ver horarios', sections: [{ title: 'Jueves', rows: [ { id: 'j-9', title: '9:00 a. m.' }, { id: 'j-15', title: '3:00 p. m.' }, ] }], }); ``` El `id` de cada botón o fila es **tuyo**: es lo que te llega de vuelta cuando la persona elige, así que ponle algo que puedas interpretar sin una tabla aparte. Lo que vuelve entra como mensaje del contacto y dispara los mismos [eventos](eventos.html) que un mensaje escrito. Los otros formatos son directos: `sendReply` cita un mensaje anterior (`wa_message_id`), `sendReaction` le pone un emoji, `sendLocation` manda coordenadas con nombre y dirección, y `sendContactCard` comparte una ficha de contacto. Uno importante aparte: **`sendTemplate`**. Fuera de la ventana de 24 horas desde el último mensaje de la persona, WhatsApp **solo** deja escribir con una plantilla aprobada por Meta — cualquier otro envío falla. No es un límite de Meteor y no hay forma de saltearlo: si tu integración escribe a contactos que no acaban de responder, la plantilla es el camino, no la excepción. ## Difusiones Una difusión es un envío a un **segmento**, no a un contacto. Vale la pena antes de mandar: ```ts const { count } = await met.broadcasts.previewCount([ { field: 'estado', operator: 'eq', value: 'cliente' }, ]); ``` `previewCount` resuelve el segmento y te dice a cuánta gente le va a llegar, sin enviar nada. Úsalo siempre: es la diferencia entre descubrir que el filtro estaba mal ahora o después de escribirle a toda la base. Lo que se difunde es un **flujo**, no un texto: ```ts const b = await met.broadcasts.create({ name: 'Promo julio', flow_id: 'flw_123', filters: [{ field: 'estado', operator: 'eq', value: 'cliente' }], send_mode: 'scheduled', scheduled_at: '2026-08-01T14:00:00Z', throttle_per_minute: 60, }); ``` `flow_id` es obligatorio y es la diferencia de diseño que conviene entender: una difusión no manda un mensaje suelto, arranca un [flujo](funciones-y-flujos.html) por cada contacto del segmento. Por eso puede responder, ramificar según lo que contesten y encadenar pasos — cosas que un envío plano no puede. `throttle_per_minute` existe porque mandar todo de golpe es la forma más rápida de que el proveedor te limite. Y `send_mode` puede ser `now`, `scheduled`, `manual` o `recurring`; en `scheduled` necesitas `scheduled_at`. Programada se puede `cancel()` mientras no haya salido; ya enviada, no — `cancel` detiene lo pendiente, no deshace lo entregado. Y como una difusión sale fuera de la ventana de 24 horas por definición, aplica lo de arriba: el primer mensaje del flujo va con plantilla aprobada. > Los contactos operan sobre el workspace de la key. Una key de **partner** puede operar varios workspaces de sus clientes; en ese caso, mira la [API de Partners](../partners.html). --- # Webhooks entrantes Un **webhook entrante** es un endpoint que Meteor expone para que un tercero le haga `POST`. Cuando llega un evento, Meteor puede **disparar una tarea** o **emitir un evento** interno. Necesita el scope `webhooks:manage`. > Estos son webhooks **entrantes** (tercero → Meteor). Si lo que quieres es que **Meteor te avise** cuando pasa algo (`run.completed`, etc.), salta a [Eventos salientes](#eventos-salientes-meteor-tu-servidor) más abajo. ## Crear un webhook ```ts const wh = await met.webhooks.create({ name: 'Entrada de leads', binding_type: 'generic', // 'task' ejecuta una tarea; 'generic' solo emite evento verifier_type: 'hmac_sha256', // 'none' para sin firma }); console.log(wh.url); // la URL a la que apunta el tercero console.log(wh.secret); // ⚠️ se muestra UNA sola vez ``` Guarda el `secret` apenas lo recibes: **no se vuelve a mostrar**. Con él verificas que cada entrega vino realmente de tu integración. ## Verificar la firma El tercero firma el cuerpo con el `secret` (HMAC-SHA256) y manda la firma en un header. Del lado de Meteor, la verificación es automática si el webhook es `hmac_sha256`. ## Rotar el secret Si sospechas que se filtró: ```ts const rotated = await met.webhooks.rotateSecret(wh.id); console.log(rotated.secret); // nuevo secret; invalida el anterior ``` ## Ver entregas ```ts const events = await met.webhooks.events(wh.id, { limit: 20 }); const sample = await met.webhooks.sample(wh.id); // último payload recibido ``` `sample` es útil para configurar el mapeo de campos del payload. ## Conectar un flujo Un webhook `generic` puede disparar un flujo cuando recibe un evento (`connectFlow` en la API). Así, un `POST` externo arranca una automatización completa en Meteor. ## Eventos salientes (Meteor → tu servidor) Un **evento saliente** es Meteor haciéndote `POST` a *ti* cuando algo pasa en tu workspace — por ejemplo, cuando un Run termina. Registras un endpoint, eliges qué eventos quieres, y Meteor entrega cada uno **firmado con HMAC** para que puedas verificar que vino de Meteor. ### Registrar un endpoint Registras el endpoint desde tu panel: **Desarrolladores → Actividad → Webhooks**. Eliges la URL de tu servidor y qué eventos quieres (o `*` para todos). Al crearlo, Meteor te muestra el **secreto de firma** (`whsec_...`) **una sola vez** — guárdalo, lo necesitas para verificar cada entrega. ### Catálogo de eventos Son **17**. La columna `scope` es el permiso que la key debe portar para recibir ese evento: el mismo con el que leerías ese recurso por la API. | Evento | Cuándo dispara | Scope | |---|---|---| | `run.completed` | Un Run terminó con éxito. | `runs:read` | | `run.failed` | Un Run falló. | `runs:read` | | `channel.run.completed` | El Met respondió un mensaje de canal. | `runs:read` | | `channel.run.failed` | El Met falló al responder un mensaje de canal. | `runs:read` | | `contact.created` | Se creó un contacto. | `contacts:read` | | `contact.updated` | Se actualizó un contacto. | `contacts:read` | | `contact.message.received` | Entró un mensaje de un contacto por un canal (WhatsApp, etc.). | `conversations:read` | | `conversation.handoff` | Una conversación pasó a operador humano. | `conversations:read` | | `task.completed` | Una tarea agéntica terminó. | `tasks:read` | | `billing.threshold` | El gasto de Energía de una API key cruzó un umbral de su presupuesto (50/80/100%). | `billing:read` | | `app.authorized` | Un workspace autorizó tu app OAuth (nuevo grant). | `integrations:read` | | `app.revoked` | Un workspace revocó el acceso de tu app OAuth (grant revocado). | `integrations:read` | | `snapshot.published` | Una plantilla propia fue aprobada y publicada al marketplace. | `snapshots:read` | | `snapshot.install.completed` | La instalación de una plantilla terminó. | `snapshots:read` | | `snapshot.install.failed` | La instalación de una plantilla falló. | `snapshots:read` | | `conversion.sent` | Una conversión se emitió con éxito a Meta (CAPI). | `conversions:read` | | `conversion.discarded` | Una conversión propuesta fue descartada por la compuerta (incluye la razón). | `conversions:read` | `*` **no es un evento**: es el comodín «todos los del catálogo», y cubre también los que agreguemos después. Si prefieres enterarte de un evento nuevo antes de empezar a recibirlo, suscríbete a la lista explícita. Tres cosas que ahorran una depuración: - **`channel.run.*` no es `run.*`.** Un `run.*` es un Run de la API, con `id` consultable; un `channel.run.*` es el Met respondiendo un mensaje de canal (WhatsApp y demás) y no deja fila en Runs. Si escuchas solo `run.completed`, las respuestas de canal no te llegan nunca. - **`app.authorized` y `app.revoked` se entregan al workspace que es dueño de la app** OAuth —el tuyo, el del developer—, no al que autoriza. - **Las entregas de prueba llegan con `type: "ping"`**, que no está en el catálogo. Tu handler debería ignorar lo que no reconoce en vez de fallar. Esta misma tabla la devuelve la API, siempre al día: ```ts const { data } = await met.webhooks.subscriptions.eventTypes(); // [{ type: 'run.completed', description: '…', scope: 'runs:read' }, …] ``` ### Verificar la firma Cada entrega trae el header `X-Met-Signature: t=,v1=`. **Nunca** proceses un evento sin verificarlo — usa el helper del SDK, que hace la verificación timing-safe y chequea el timestamp (anti-replay): ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!); app.post('/webhooks/met', (req, res) => { let event; try { event = met.webhooks.constructEvent(req.rawBody, req.headers['x-met-signature'], WHSEC); } catch { return res.status(400).send('firma inválida'); } if (event.type === 'run.completed') { /* ... */ } res.sendStatus(200); // responde 2xx rápido; Meteor reintenta si no. }); ``` > Necesitas el **cuerpo crudo** (raw body) para verificar la firma — configura tu framework para no re-serializar el JSON antes de `constructEvent`. ### Reintentos Si tu endpoint no responde `2xx`, Meteor reintenta con backoff exponencial (1 min → 5 min → 30 min → 2 h → 6 h → 24 h) hasta 6 intentos. Después marca la entrega como `failed`. El **log de intentos** por endpoint está en tu Workbench (sección Desarrolladores). --- # Errores, idempotencia y paginación Tres cosas que hacen robusta cualquier integración: entender los errores, reintentar sin duplicar, y recorrer listas grandes. ## La forma de una respuesta Toda respuesta correcta de la API viaja dentro de un **sobre**. No llega el recurso pelado: llega envuelto, y el recurso está en `data`. ```json { "success": true, "data": { "object": "run", "id": "run_01HXYZ" } } ``` Un error usa el mismo sobre con el signo cambiado, y por eso `success` es el primer campo que conviene mirar: ```json { "success": false, "error": { "type": "invalid_request_error", "code": "missing_scope", "message": "…" }, "request_id": "req_01J8XYZ" } ``` **Con los SDK oficiales no tienes que pensar en esto**: te entregan el contenido de `data` y convierten el sobre de error en una excepción. El sobre te importa si llamas con `curl`, si escribes tu propio cliente, o si generas uno del contrato —en cuyo caso ya viene declarado. Una excepción que conviene conocer: **las listas paginadas ponen `pagination` afuera de `data`**, como hermana y no como hija. ```json { "success": true, "data": [ { "id": 9812 }, { "id": 9813 } ], "pagination": { "page": 1, "limit": 50, "total": 128, "pages": 3 } } ``` ## Errores tipados Todos los errores de la API traen un cuerpo con `type`, `code`, `message` y un `request_id` (envíalo al soporte si algo falla). El SDK los mapea a **clases**: ```ts import { MetRateLimitError, MetAuthError, MetInvalidRequestError } from '@meteor.ia/sdk'; try { await met.runs.create({ input: 'x' }); } catch (err) { if (err instanceof MetRateLimitError) { console.log(`Reintentar en ${err.retryAfter}s`); } else if (err instanceof MetAuthError) { console.log('Key inválida o revocada'); } else if (err instanceof MetInvalidRequestError) { console.log(err.code, err.message); } else throw err; } ``` ```python from meteor_ia import MetRateLimitError, MetAuthError, MetInvalidRequestError try: met.runs.create("x") except MetRateLimitError as e: print(f"Reintentar en {e.retry_after}s") except MetAuthError: print("Key inválida o revocada") except MetInvalidRequestError as e: print(e.code, e.message) ``` La taxonomía es un conjunto cerrado: | `type` | Clase | HTTP | |---|---|---| | `authentication_error` | `MetAuthError` | 401 | | `invalid_request_error` | `MetInvalidRequestError` | 400 | | `rate_limit_error` | `MetRateLimitError` | 429 | | `agent_error` | `MetAgentError` | 5xx del Met | | `api_error` | `MetApiError` | otros | ## Cuando algo falla: mira tus propias requests Cada error trae un **`request_id`**, y ese id es **buscable**. No tienes que adivinar qué pasó ni abrir un ticket para saberlo. ```ts try { await met.contacts.create({ name: 'Ada' }); } catch (err) { console.error(err.requestId); // req_01J8… ← guarda esto } ``` ```python try: met.contacts.create(name="Ada") except Exception as e: print(e.request_id) # req_01J8… ← guarda esto ``` ```bash curl -i -X POST https://api.met.meteor.com.co/api/v1/contacts \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" -d '{}' # El id viene en el cuerpo y en el header X-Request-Id ``` Con ese id, entra a tu **Workbench** en **Ajustes → Desarrolladores → Actividad** y tienes: | pestaña | qué te dice | |---|---| | **Registros** | cada request de tu workspace, filtrable por key, por clase de status (2xx/4xx/5xx) y **por `request_id`**. Cada fila se expande y muestra los bodies — los de error incluyen el detalle que causó el 400 | | **Resumen** | volumen, distribución de errores y latencia p95, por 24 h / 7 d / 30 d | | **Salud** | errores recientes agrupados por código, más las alertas de cuota por key (80% / 100%) | | **Webhooks** | entregas salientes y el log de intentos por endpoint | Es el camino corto: pegas el `request_id` en Registros y ves exactamente qué mandaste y qué contestamos. Si igual necesitas escribirnos, mándanos ese id — es lo primero que vamos a pedir. > El Workbench se sirve con tu sesión del panel, nunca por API key, y solo muestra el > tráfico de tu propio workspace. ## Reintentos e idempotencia El SDK **reintenta solo** los `429` y `5xx` con backoff exponencial (respeta `Retry-After`). No tienes que reimplementarlo. Para que reintentar un `POST` no duplique (por ejemplo, crear dos runs por un corte de red), el SDK manda una **Idempotency-Key** automática en cada POST con efectos. Si el request se reenvía con la misma key, la API devuelve el mismo resultado sin volver a ejecutar. ```ts // Ambas llamadas comparten idempotencia automática; un retry no crea dos runs. await met.runs.create({ input: 'x' }); ``` Con `curl`, manda tú el header: ```bash -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" ``` ## Paginación **Hay dos formas de paginar en la API, y conviene saber cuál te toca** — no porque cambie mucho el código, sino porque usar la equivocada no da error. ### Por cursor Runs, contactos e ítems de una colección devuelven `{ data, has_more }` con un cursor opaco. El SDK lo recorre por ti: ```ts for await (const run of met.runs.iterate()) { // ...cada run, todas las páginas } ``` ```python for run in met.runs.iterate(): ... ``` Si prefieres controlar el cursor a mano, `list()` acepta `limit` y `starting_after`: ```ts const page = await met.runs.list({ limit: 20 }); if (page.has_more) { const next = await met.runs.list({ limit: 20, starting_after: page.data.at(-1).id }); } ``` Es el modo estable: aunque entren registros nuevos mientras recorres, no se te duplica ni se te salta nada. ### Por desplazamiento Facturación, Mediateca, eventos, entregas de webhook y la búsqueda de ítems paginan con `limit` + `offset` (la búsqueda, con `limit` + `page`). Cada uno tiene su iterador: ```ts for await (const e of met.billing.iterateExecutions()) { /* ... */ } for await (const a of met.assets.iterate({ mime_prefix: 'image/' })) { /* ... */ } for await (const it of met.items.iterateSearch('factura')) { /* ... */ } ``` ```python for e in met.billing.iterate_executions(): ... for a in met.assets.iterate(mime_prefix="image/"): ... ``` **Lo que hay que saber:** mandarle `starting_after` a uno de estos endpoints **no falla**. El parámetro se ignora, responde `200` y devuelves la primera página una y otra vez — un bucle infinito que parece funcionar. Si escribes la paginación a mano, revisa en la [referencia](../reference.html) qué parámetros acepta el endpoint. Y el desplazamiento **no es estable**: si algo entra o sale del conjunto mientras recorres, una fila puede aparecer dos veces o ninguna. Es una limitación del endpoint. Para un barrido exacto sobre datos que se mueven, el que sirve es el de cursor. --- # Límites y cuotas La API de Meteor aplica dos tipos de límite por **API key**: un **rate limit** (cuántos requests por minuto) y una **cuota** (cuántos requests por mes). Ambos protegen la plataforma y son visibles en cada respuesta — no tienes que adivinar cuánto te queda. ## Rate limit (requests por minuto) Cada key tiene su propio presupuesto de requests por minuto, independiente del resto del tráfico de tu workspace. El valor por defecto es **100 requests por minuto**; tu plan puede asignarle un límite mayor a la key cuando la emites. El límite se cuenta por key (no por IP ni por workspace), así que dos keys del mismo workspace no compiten entre sí. ## Headers en cada respuesta Toda respuesta de la API pública trae el estado de tu rate limit, para que ajustes el ritmo sin llegar al bloqueo: | Header | Qué dice | |---|---| | `X-RateLimit-Limit` | El tope de requests de la ventana actual (refleja el límite de tu key). | | `X-RateLimit-Remaining` | Cuántos requests te quedan en la ventana. | | `X-RateLimit-Reset` | Segundos hasta que la ventana se reinicia. | ```bash curl -i https://api.met.meteor.com.co/api/v1/runs \ -H "Authorization: Bearer met_live_tu_key" # ... # HTTP/2 200 # x-ratelimit-limit: 100 # x-ratelimit-remaining: 99 # x-ratelimit-reset: 60 ``` ## Cuando te pasas: 429 + `Retry-After` Si superas el rate limit, la API responde **429** con un header `Retry-After` (segundos a esperar) y el cuerpo de error estándar: ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Too many requests." } } ``` Los **SDKs oficiales reintentan solos** respetando el `Retry-After` (con backoff), así que en la mayoría de los casos no tienes que manejar el 429 a mano. Si integras la API directamente, espera los segundos que indica `Retry-After` antes de reintentar. ## Cuota mensual Además del rate limit por minuto, tu plan define una **cuota mensual** de requests. Al superarla, la API responde **429** con `code: "quota_exceeded"`. La cuota depende de tu plan; el consumo del mes y las alertas (80% / 100%) los ves en el Workbench, en **Ajustes → Desarrolladores → Actividad → Salud** — ahí mismo tienes el volumen por día, la latencia p95 y [cada request buscable por `request_id`](errores-e-idempotencia.html#cuando-algo-falla-mira-tus-propias-requests). > Las cuotas y rate limits dependen del plan activo del workspace. Se resuelven al usar la key —aunque la hayas creado antes de contratar el plan— y se reflejan en `X-RateLimit-Limit`. ## En resumen - Mira `X-RateLimit-Remaining` para autorregularte. - Un **429** con `rate_limited` es transitorio → reintenta tras `Retry-After` (los SDKs lo hacen solos). - Un **429** con `quota_exceeded` es tu cuota del mes → sube de plan o espera al reinicio mensual. --- # Plantillas (Snapshots) Una **Plantilla** (Snapshot) empaqueta la configuración de un workspace ya implementado —Mets, flujos, colecciones, habilidades e integraciones— y la instala en otro workspace en minutos. Nunca viajan secretos ni datos vivos (contactos, conversaciones, credenciales): la instalación es **aditiva** (no borra nada) y todo lo ejecutable llega **desactivado** hasta que lo enciendas. Con tu API key puedes **navegar el catálogo** e **instalar** una plantilla programáticamente. ## Scopes | Scope | Para qué | |---|---| | `snapshots:read` | Navegar el catálogo y ver la ficha de una plantilla | | `snapshots:install` | Reclamar (gratis) e instalar una plantilla en el workspace | | `snapshots:publish` | Enviar tu plantilla a revisión editorial (solo partners) | ## Instalar una plantilla con el SDK ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: 123 }); // 1. Descubrir const plantillas = await met.snapshots.list({ vertical: 'inmobiliaria' }); const ficha = await met.snapshots.retrieve(plantillas[0].slug); console.log(ficha.counts); // { mets: 4, flows: 12, collections: 6, ... } console.log(ficha.requirements); // qué canales/credenciales conectar después // 2. Obtener acceso (si es gratuita) e instalar await met.snapshots.claim(ficha.id); const install = await met.snapshots.install(ficha.id, { variables: { nombre_negocio: 'Inmobiliaria Andes' }, }); console.log(install.id, install.status); // sigue el progreso desde el producto ``` Si la plantilla es de pago, `claim` falla: la compra se hace desde el producto (checkout de Stripe). Una vez comprada, `install` funciona igual. ## Con HTTP directo ```bash # Catálogo curl https://api.met.meteor.com.co/api/v1/workspaces/123/snapshot-catalog \ -H "Authorization: Bearer $MET_API_KEY" # Instalar curl -X POST \ https://api.met.meteor.com.co/api/v1/workspaces/123/snapshots/$SNAPSHOT_ID/install \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "variables": { "nombre_negocio": "Inmobiliaria Andes" } }' ``` ## Con un agente (MCP) Si conectas Met como servidor **MCP**, tu agente obtiene estas herramientas (según los scopes de la key): - `met_list_snapshots` — lista el catálogo de plantillas. - `met_get_snapshot` — ficha de una plantilla por slug. - `met_install_snapshot` — instala una plantilla en el workspace. Así, un agente de código puede montar un workspace completo a partir de una plantilla de su vertical con una sola key. ## Después de instalar La instalación deja una **checklist de puesta en marcha** (conectar WhatsApp, credenciales de integraciones, activar flujos, llenar variables del negocio). Esa parte requiere conectar cuentas reales y hoy se completa **desde el producto**, no por API. --- # CLI (met) `met` es la terminal de Meteor: ejecuta un Met, sigue el historial de runs, abre un chat y —lo más útil mientras integras— **escucha los eventos de tu workspace y los reenvía a tu servidor local**. ```bash npm i -g @meteor.ia/cli ``` Node ≥ 20. Usa la misma API key `met_` que el SDK. ## Configurar ```bash met config set --key met_live_xxx --workspace 7 met config show # la key sale enmascarada ``` Queda en `~/.config/met/config.json`. También lee `MET_API_KEY`, `MET_WORKSPACE` y `MET_BASE_URL` del entorno, y cualquier comando acepta `--key` / `--workspace` para un uso puntual. En CI, usa las variables de entorno. Mientras desarrollas, usa una key **de test** (`met_test_`): los runs no cuestan y los efectos externos quedan bloqueados. ## Ejecutar un Met ```bash met run "Resume los leads de hoy" ``` En una terminal interactiva streamea por defecto. Para un pipeline o un cron: ```bash met run "Hola" --json --no-stream ``` `--json` imprime el objeto completo en una línea, listo para `jq`. `--met ` elige qué Met ejecuta si no quieres el del workspace. ```bash met runs list --status failed --limit 20 ``` ## Ejecutar una tarea Una tarea es un procedimiento con pasos que tu Met ejecuta. Desde la terminal: ```bash met tasks list met tasks run tsk_123 ``` `run` dispara la ejecución y devuelve enseguida, porque los pasos corren en segundo plano. Para un cron o un pipeline eso no alcanza —el comando diría "ok" por haber podido disparar, no por haber funcionado— así que usa `--wait`: ```bash met tasks run tsk_123 --wait ``` Con `--wait` el comando espera el estado final y **el código de salida refleja el resultado**: `0` si terminó completada, `1` si falló o se canceló. El tope por defecto son 300 segundos; súbelo con `--timeout 900`. ```bash met tasks show tsk_123 # la tarea y sus últimas ejecuciones met tasks executions tsk_123 --json ``` ## Chat ```bash met chat --met ventas ``` Un REPL sobre el mismo stream de runs. Sirve para probar un prompt sin escribir código. ## Escuchar eventos, y reenviarlos a tu máquina Este es el comando por el que vale la pena instalar el CLI: ```bash met listen --forward http://localhost:3000/webhooks ``` Toma los eventos del workspace en vivo y los **POSTea a tu servidor local**, así desarrollas el handler de webhooks sin exponer tu máquina a internet ni configurar un túnel. Filtra lo que te interesa: ```bash met listen --types run.completed,run.failed ``` Sin `--forward` los imprime, que alcanza para ver qué está pasando. El catálogo de eventos está en [Webhooks](webhooks.html#catalogo-de-eventos). ## Colecciones e ítems Las colecciones son las bases de datos no-code del workspace, y son el paso previo de todo lo demás: sin el id de una colección no puedes listar ni crear nada adentro. ```bash met collections list met collections show 12 # el esquema de campos ``` `show` imprime los campos con su tipo porque esas claves son exactamente las que después usas para escribir: ```bash met items list --collection 12 --limit 20 met items create --collection 12 --set titulo="Casa en el norte" --set precio=250000 met items update 9812 --collection 12 --set estado=vendido ``` `--set` es repetible y se escribe sin escapar nada, que es lo que quieres en una terminal. Se parte en el **primer** `=`, así que una URL con query pasa entera. Ojo con los tipos: del shell todo llega como texto, y `--set` convierte solo lo que no tiene ambigüedad. `true`, `false`, `null` y los números pasan; `007`, `1.50` y `1e3` quedan como texto porque no sobreviven el viaje de ida y vuelta. Lo que **no** puede distinguir es un teléfono `3001234567` de una cantidad — para esos casos, pasa el JSON y el tipo lo pones tú: ```bash met items create --collection 12 --data '{"telefono":"3001234567"}' ``` `--data` gana sobre `--set` si pasas las dos. ## Contactos ```bash met contacts list --limit 50 met contacts show 42 # el contacto y sus últimos mensajes met contacts send 42 "Ya quedó agendada la visita" ``` `send` manda como **operador humano**, no como Met: es el equivalente de escribir desde la bandeja, y el mensaje queda atribuido a una persona en el historial. Si lo que quieres es que responda un Met, eso es `met run`. ## Qué Mets hay `met run --met ` necesita el nombre de un Met. Para verlos: ```bash met agents list met agents show 3 # sus herramientas ``` `show` imprime las herramientas porque responde la pregunta siguiente — *"¿por qué el Met no hace X?"*, que casi siempre es que no tiene la herramienta, no que el prompt esté mal. ## Cuánto estás gastando ```bash met billing resumen --from 2026-07-01 --to 2026-07-31 met billing ejecuciones # el detalle, ordenado por costo met billing saldo ``` `resumen` es el agregado del período y `ejecuciones` el detalle. **Cuando la factura no cuadra, la respuesta está en el detalle**: recorre todas las tareas ejecutadas y las ordena por costo, y arriba casi siempre aparece un Met que se llama a sí mismo en un bucle. `saldo` avisa si la recarga automática está apagada. Importa más de lo que parece en una integración desatendida: quedarse sin Energía **detiene las tareas**, y del lado del cliente eso se ve como "la API dejó de responder". ## Depurar webhooks Hay dos direcciones distintas. No mezcles los comandos: un **webhook entrante** es un tercero que hace POST a Meteor; un **webhook endpoint** es Meteor enviando un evento firmado a tu servidor. ```bash # Lo que Meteor recibió de un tercero: inspección, no genera tráfico. met inbound-webhooks list met inbound-webhooks events 12 met inbound-webhooks sample 12 # Lo que Meteor intentó entregar a tu servidor: prueba y recuperación reales. met webhook-endpoints list met webhook-endpoints test we_123 met webhook-endpoints deliveries we_123 met webhook-endpoints retry we_123 del_456 ``` `sample` imprime el último payload ya recibido para que configures su mapeo; no dispara un evento. Si no hay eventos entrantes, revisa el emisor y la URL de ingreso. `test` y `retry` hacen una entrega saliente firmada. Salen con código `0` solo cuando tu servidor confirma la entrega; si Meteor pudo hacer el intento pero tu endpoint devuelve un error o timeout, salen con `1`, útil para CI. Crear endpoints o rotar secretos se hace desde el panel. El alias `met webhooks` aún funciona para los entrantes, pero está deprecado. ## Provisionar un workspace desde un script Una plantilla (Snapshot) monta un vertical completo en el workspace: colecciones, Mets, flujos y contenido. Desde la terminal puedes hacerlo sin abrir el panel, que es lo que necesitas cuando das de alta un cliente nuevo desde tu propio onboarding o desde CI. ```bash met snapshots list --vertical inmobiliaria met snapshots show inmobiliaria-basica # qué incluye y qué variables pide ``` `show` imprime las variables con un `*` en las obligatorias. Son los datos del negocio que la plantilla usa para personalizar prompts y contenido, y se pasan al instalar: ```bash met snapshots claim 3f1e… # solo para plantillas gratuitas met snapshots install 3f1e… --set negocio="Muebles del Norte" --set ciudad=Medellín ``` `install` necesita que el workspace ya tenga acceso a la plantilla: `claim` lo da para las gratuitas, y las de pago se compran desde el panel. Aquí `--set` **no convierte tipos**: un teléfono o un NIT quedan como texto, que es lo que son. Si una variable de verdad no es texto, pásala en JSON con `--vars '{"cupo":10}'`. La instalación es aditiva e idempotente, y **todo llega desactivado**: `install` imprime el id en stdout y el estado en stderr, así que en un pipeline puedes guardar el id y revisar antes de prender nada. ## Cuando algo no funciona ```bash met whoami ``` Distingue los tres casos que desde afuera se ven idénticos: la key está mal (**401**), la key es válida y le falta el scope de la operación (**403**), o todo está bien. El segundo es el que más tiempo hace perder, porque autentica sin problema y aun así la operación falla — y la primera sospecha siempre cae en la key. También te dice contra qué host y qué workspace estás pegando, que es el otro error silencioso. ## Si eres partner Tu negocio de partner también se consulta desde la terminal, con la misma key: ```bash met partner leads --status won met partner clients met partner commissions --status approved --json met partner payouts --period 2026-07 ``` Los recursos son `clients`, `leads`, `projects`, `commissions` y `payouts`. Son de **solo lectura**: crear una oportunidad o mover una etapa se hace por [SDK](../partners.html) o desde el panel — no es algo que convenga disparar de un tirón en una terminal. La excepción es soporte, que sí escribe: ```bash met partner tickets list met partner tickets show 84 # el hilo completo met partner tickets reply 84 "Ya quedó corregido, prueba de nuevo" ``` `show` imprime los mensajes y no solo la cabecera, porque lo siguiente que vas a escribir es `reply`. Abrir un ticket y cerrarlo se hacen desde el panel o por SDK. Una key de partner no tiene workspace, y estos comandos no lo necesitan. El host de la API de Partners es otro, así que si apuntas a un entorno distinto se configura aparte: ```bash met config set --partner-base-url https://api.partners.meteor.com.co ``` ## Todos los comandos | comando | qué hace | |---|---| | `met run ` | ejecuta un Met y muestra la salida | | `met runs list` | historial de runs (`--status`, `--limit`, `--json`) | | `met tasks list` | tareas del workspace | | `met tasks run ` | dispara una tarea (`--wait`, `--timeout`) | | `met tasks show ` | la tarea y sus últimas ejecuciones | | `met tasks executions ` | historial de ejecuciones | | `met agents list\|show` | los Mets del workspace y sus herramientas | | `met billing resumen\|ejecuciones\|saldo` | consumo de Energía; `ejecuciones` es el detalle por costo | | `met inbound-webhooks list\|show\|events\|sample` | inspeccionar webhooks entrantes de terceros | | `met webhook-endpoints list\|deliveries\|test\|retry` | probar, ver y reintentar entregas salientes | | `met collections list\|show` | colecciones del workspace y su esquema de campos | | `met items list\|show\|create\|update` | filas de una colección (`--collection`, `--set`, `--data`) | | `met contacts list\|show\|send` | CRM y envío como operador | | `met partner ` | tu negocio de partner (solo lectura) | | `met partner tickets list\|show\|reply` | soporte: el hilo de un ticket y tu respuesta | | `met snapshots list\|show\|claim\|install` | plantillas: catálogo, ficha e instalación (`--set`, `--vars`) | | `met chat` | REPL interactivo | | `met listen` | eventos en vivo (`--types`, `--forward`) | | `met config set\|show` | guarda o muestra la configuración | | `met whoami` | verifica la key activa: autenticación, scope y host | | `met help` | la ayuda completa | ## Opciones comunes | opción | para qué | |---|---| | `--met ` | qué Met ejecutar (por defecto, el del workspace) | | `--workspace ` | workspace destino (o `MET_WORKSPACE`) | | `--key ` | API key (o `MET_API_KEY`, o la guardada) | | `--base-url ` | host de la API (o `MET_BASE_URL`), para apuntar a otro entorno | | `--partner-base-url ` | host de la API de Partners (o `MET_PARTNER_BASE_URL`) | | `--json` | salida en JSON, para pipes y CI | | `--no-stream` | espera el resultado completo en vez de streamear | ## Lo que todavía no hace El CLI cubre **runs**, **Mets**, **tareas**, **colecciones**, **ítems**, **contactos**, **flujos**, **webhooks**, **facturación**, **eventos**, **plantillas** y el negocio de partner (lectura, más los tickets de soporte). Los que quedan afuera —habilidades, canales, automatizaciones, integraciones, funciones— se operan por [SDK](../index.html#quickstart) o REST, donde están completos. No es un hueco a llenar por simetría: un CLI sirve para lo que uno hace de a un comando, en un cron o probando algo. Configurar un webhook o activar una habilidad se hace una vez y desde el panel. Las **API keys se crean desde el panel** (Ajustes → Desarrolladores), no desde la terminal: emitir credenciales desde un CLI es la clase de cosa que termina en un historial de shell. Para verificar la que estás usando, `met whoami`. --- # Operación del workspace Cinco dominios que se consultan **juntos**, y siempre por la misma razón: algo ya está corriendo en producción y necesitas saber cuánto está gastando, quién lo está atendiendo o dónde quedó un archivo. No son cosas que se leen aprendiendo; son las que se buscan a las once de la noche. ## Cuánta Energía estás gastando La Energía se debita por tarea ejecutada. Si tu integración dispara Mets, esto es lo que te dice cuánto cuesta antes de que te sorprenda la factura: ```ts const uso = await met.billing.consumption({ from: '2026-07-01', to: '2026-07-31' }); const ejec = await met.billing.executions({ limit: 50 }); ``` `consumption` es el agregado del período; `executions` es el detalle, una fila por tarea ejecutada con su costo. Cuando el total no cuadra con lo que esperabas, la respuesta está en el detalle: casi siempre es un Met que se llama a sí mismo en un bucle, o un flujo que corre más veces de las que creías. ```ts const saldo = await met.billing.subscription(); const recarga = await met.billing.autoRecharge(); ``` `autoRecharge` importa más de lo que parece si tu integración es desatendida: sin recarga automática, quedarse sin Energía **detiene las tareas**, y eso se ve como "la API dejó de responder" cuando en realidad es saldo. Consúltala antes de culpar al código. El resto es lectura de la relación comercial: `invoices()`, `transactions()`, `plans()`, `addons()` y `myAddons()`. Y `access()`, que responde qué tiene habilitado el plan actual — útil para no llamar a un endpoint que va a devolver 403 por plan y no por permisos. ## Archivos del workspace La Mediateca son los archivos del workspace, con carpetas propias: ```ts const imagenes = await met.assets.list({ mime_prefix: 'image/', limit: 50 }); const subido = await met.assets.upload(blob, { filename: 'propuesta.pdf', contentType: 'application/pdf', folder_id: carpeta.id, }); ``` `list` devuelve un array (no una página con cursor) y pagina con `limit`/`offset`. Además de `mime_prefix` filtra por `folder_id`, `search` y `source` — y ese último es el que sirve para auditar: distingue lo que subió una persona (`upload`) de lo que salió de un ítem, de lo que generó un Met (`generated`) o un flujo (`flow_run`). En `upload`, `filename` es **obligatorio**: es lo que determina la extensión del objeto en storage, y sin ella el archivo queda sin tipo reconocible. Dos cosas que ahorran tiempo: **Las miniaturas no las generas tú.** Storage reescala en el borde, así que para mostrar un archivo en chico se pide ya dimensionado en vez de bajar el original. Está en [Imágenes](imagenes.html), y la diferencia medida es de 870 KB a 15 KB. **`cleanupProvisional`** existe porque una subida que empieza y no termina deja el archivo huérfano. Corre en simulación por defecto: ```ts const previo = await met.assets.cleanupProvisional(); // simula await met.assets.cleanupProvisional(true); // aplica ``` Mira el resultado antes de pasar `true`. No es reversible. ## Conversaciones Una conversación es el hilo con un contacto. La distinción que hay que tener clara: ```ts const principal = await met.conversations.main(); const todas = await met.conversations.list(); ``` `main()` devuelve la conversación **principal** del workspace — la del panel, donde el equipo habla con el Met. `list()` devuelve todas, incluidas las de cada contacto. Si buscabas el historial de un contacto, el camino corto es [`met.contacts.messages(id)`](contactos.html#leer-conversaciones), no recorrer `list()`. `threadMessages(threadId)` baja los mensajes de un hilo derivado, que es lo que se arma cuando alguien responde dentro de un mensaje en vez de al final. ## Recordatorios Un recordatorio agenda **un mensaje al contacto** para una fecha futura. Eso es lo primero que hay que tener claro: **siempre se envía**. No existe un modo "nota interna" — `note` es un campo para el equipo, no un interruptor que evite el envío. ```ts await met.reminders.create({ contact_id: 42, scheduled_for: '2026-08-05T14:00:00Z', message: 'Te escribo para confirmar si firmaste la propuesta', note: 'Seguimiento de la propuesta de julio', // interno, no viaja }); ``` A la hora agendada, un despachador se lo manda al contacto por su canal. El canal sale del contacto, así que no hay que elegirlo. `scheduled_for` va en ISO 8601 y tiene que ser futuro. **Con offset** (`2026-08-05T09:00:00-05:00` o el `Z` del ejemplo) es un instante exacto; **sin offset** se interpreta en la zona horaria del workspace. Manda el offset si lo calculas en tu servidor: es la diferencia entre las 9 de la mañana del cliente y las 9 de la mañana de tu proceso. Va con plantilla de WhatsApp y no con texto libre por lo mismo que las difusiones: un recordatorio se dispara días después del último mensaje de la persona, o sea fuera de la ventana de 24 horas, donde solo pasan plantillas aprobadas. Por eso el workspace necesita tener una **plantilla de recordatorio por defecto** configurada (Ajustes): sin ella, `create` responde 400. Tu `message` va como el cuerpo de esa plantilla. Si quieres una plantilla distinta de la de por defecto, pásala explícita — y entonces `language` es obligatorio: ```ts await met.reminders.create({ contact_id: 42, scheduled_for: '2026-08-05T14:00:00Z', template_name: 'seguimiento_propuesta', language: 'es', variables: { '1': 'Ana' }, // los huecos de la plantilla }); ``` `list({ contactId, status })`, `update` y `cancel` completan el CRUD. `update` mueve la fecha o cambia el texto, y **solo funciona mientras está pendiente**; `cancel` lo detiene antes de que se despache. Cancela en cuanto el motivo deja de existir: si no, al contacto le llega un mensaje fuera de contexto. ## Autopiloto: cuándo contesta el Met y cuándo un humano Por defecto el Met atiende. El autopiloto es el interruptor: ```ts await met.contacts.setAutopilot(42, false); // que atienda un humano await met.contacts.pauseAutopilot(42, 30); // 30 minutos, y vuelve solo await met.contacts.setAutopilot(42, true); // devolvérselo al Met ``` **Usa `pause` y no `setAutopilot(false)` para una intervención puntual.** Es la decisión que más se equivoca: apagar el autopiloto es permanente hasta que alguien lo prenda, y lo que sigue es un contacto que quedó sin atención automática durante semanas porque nadie se acordó. `pause` con minutos se reanuda solo; `pause(id, 0)` reanuda ya. `markRead(id)` marca la conversación como leída, para que tu integración no deje el panel del equipo lleno de no-leídos que ya procesaste. ## Grupos de agentes Son grupos de **personas** —los agentes humanos del chat—, no de Mets. Sirven para enrutar y asignar conversaciones a un equipo en vez de a un individuo: ```ts const g = await met.agentGroups.create({ name: 'Soporte técnico' }); await met.agentGroups.addMember(g.id, userId); const miembros = await met.agentGroups.members(g.id); ``` `addMember` recibe un **`user_id`**, y ahí está la confusión que conviene evitar: si buscabas agrupar Mets, eso no existe como grupo — un Met se acota con sus [habilidades y herramientas](mets-y-herramientas.html). Gestionarlos pide rol de supervisor o admin, así que una key con scope pero de un usuario sin ese rol recibe 403. Es permiso, no plan. > Todo lo de esta guía opera sobre el workspace de la key. Una key de **partner** tiene su propia superficie: mira la [API de Partners](../partners.html). --- # Versionado y deprecación Construir sobre una API es apostar a que lo que funciona hoy funcione en seis meses. Esta página dice exactamente qué puede cambiar sin avisarte, qué no cambia nunca sin un aviso previo, y cómo te enteras. ## La regla en una frase **`/api/v1` solo crece.** Todo lo que hoy responde va a seguir respondiendo igual: si algo tiene que romperse, vive en `/api/v2` y los dos conviven al menos seis meses. ## Cambios que puedes esperar sin aviso Estos son **aditivos**: no rompen a nadie que ya esté integrado, y ocurren seguido. - Endpoints nuevos. - Campos nuevos en una respuesta. - Parámetros opcionales nuevos. - Valores nuevos en un campo de tipo enum (un estado nuevo, un tipo de canal nuevo). - Códigos de error nuevos dentro de un `type` que ya existe. Dos consecuencias prácticas para tu integración: 1. **No asumas que conoces todos los campos de una respuesta.** Ignora los que no uses en vez de fallar. Los schemas del contrato están marcados como abiertos justamente para eso. 2. **No hagas un `switch` exhaustivo sobre un enum sin rama por defecto.** Un estado nuevo no debería tumbarte el proceso. ## Cambios que nunca ocurren sin ciclo de deprecación - Quitar un endpoint. - Quitar o renombrar un campo de una respuesta. - Renombrar una ruta o un `operationId`. - Volver obligatorio un parámetro que era opcional. - Cambiar el significado de un valor que ya existía. Cualquiera de estos pasa por el ciclo completo que sigue. ## El ciclo de deprecación Cuando un endpoint se va a retirar, ocurren cuatro cosas **antes** de que deje de responder: 1. **Se marca en el contrato.** La operación queda con `deprecated: true` en `openapi.public.json`, con la fecha de retiro en `x-sunset` y con qué usar en su lugar en `x-alternative`. Si generas tu cliente del contrato, la mayoría de los generadores marcan el método como deprecado y tu editor te lo tacha. 2. **Cada respuesta te lo dice.** Mientras siga vivo, el endpoint responde normal —no falla, no cambia su cuerpo— pero agrega estos headers: | Header | Qué dice | |---|---| | `Deprecation` | `true`. Este endpoint está deprecado. | | `Sunset` | La fecha a partir de la cual deja de responder, en formato HTTP. | | `Link` | Enlace a esta página, con la alternativa en el `title`. | ```bash curl -i https://api.met.meteor.com.co/api/v1/... \ -H "Authorization: Bearer met_live_tu_key" # HTTP/1.1 200 OK # Deprecation: true # Sunset: Wed, 01 Jul 2026 00:00:00 GMT # Link: ; rel="deprecation"; type="text/html"; title="Usa POST /runs" ``` Si tienes observabilidad sobre tus llamadas salientes, vale la pena registrar una alerta cuando aparezca un `Deprecation` — es la forma más barata de no enterarte tarde. 3. **Queda una entrada en el [changelog](../changelog.html)** que explica por qué y a qué migrar. 4. **Pasan al menos seis meses** entre el anuncio y el retiro. ## Cómo saber contra qué versión generaste El contrato declara su versión en `info.version`, y esa versión es la misma que encabeza la entrada más reciente del [changelog](../changelog.html). ```bash curl -s https://developers.meteor.com.co/public/openapi.public.json | jq .info.version ``` Si guardas ese número el día que generas tu cliente, comparar contra el changelog te dice exactamente qué pasó en el medio. ## Lo que este ciclo no cubre - **Los `type` de error son una lista cerrada** de cinco valores y no crecen: `authentication_error`, `invalid_request_error`, `rate_limit_error`, `agent_error`, `api_error`. Los `code` dentro de cada uno sí crecen. Programa contra el `type` cuando necesites una rama de control, y usa el `code` para el mensaje. Está todo en [Errores, idempotencia y paginación](errores-e-idempotencia.html). - **Los límites de tu plan no son contrato de API.** El rate limit y la cuota pueden cambiar con tu plan; se leen en los headers de cada respuesta, no se asumen. Ver [Límites y cuotas](limites.html). --- # Aprovisionamiento de cuentas Si integras Met dentro de tu producto, no necesitas que cada cliente se registre a mano: puedes **crear su cuenta desde tu backend** y dejarla lista para usar. Es el caso "Meteor como servicio". Está disponible para **Tech Partners aprobados**, con una API key de partner. Una key de workspace no puede hacerlo: los permisos de esta guía solo existen en keys de partner. ## Scopes | Scope | Para qué | |---|---| | `workspaces:provision` | Crear una cuenta nueva con su dueño | | `workspaces:recharge` | Cargarle energía a una cuenta que creaste | Los dos son **exclusivos de keys de partner** y ninguno funciona con una key de prueba (`met_test_`): las cuentas que se crean son reales y los cobros también. ## Crear una cuenta ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_PARTNER_KEY!); const cuenta = await met.workspaces.create({ business_name: 'Inmobiliaria Andes', owner_email: 'ana@andes.co', owner_name: 'Ana Gómez', owner_phone: '+573001234567', country: 'CO', }); console.log(cuenta.id, cuenta.operational); // 1234 false ``` ```python from meteor_ia import Met met = Met(api_key=os.environ["MET_PARTNER_KEY"]) cuenta = met.workspaces.create( business_name="Inmobiliaria Andes", owner_email="ana@andes.co", owner_name="Ana Gómez", owner_phone="+573001234567", country="CO", ) ``` La cuenta nace con el plan **Tech Partner**: mensual, sin cargo fijo, con la Energía a tarifa premium. Tu cliente puede subir a un plan comercial cuando quiera, y a partir de ahí paga la tarifa de ese plan. ## Tres cosas que conviene saber antes ### La cuenta nace sin energía La respuesta trae `operational: false` y `balance_usd: 0`. **Una cuenta sin energía no puede correr ni un solo turno de Met**: el primer mensaje responde que no hay presupuesto. La energía la paga siempre la cuenta que la consume, así que el paso siguiente es cargarle saldo (más abajo) o pedirle a tu cliente que lo haga. No lo dejes para después del primer mensaje. ### Tú no defines la contraseña No mandas contraseña en la llamada, y no hay forma de hacerlo. Met le envía al dueño un correo con un enlace para que defina la suya. El campo `owner.invitation_sent` te dice si ese correo salió; si volvió `false`, tu cliente puede pedirlo de nuevo desde "Olvidé mi contraseña". ### Reintentar no duplica La cabecera `Idempotency-Key` es **obligatoria**. El SDK genera una por ti si no la pasas, pero conviene que pases la tuya —el id de tu propio registro, por ejemplo—: así un reintento por timeout de red devuelve la misma cuenta en vez de crear una segunda. ```ts await met.workspaces.create(datos, { idempotencyKey: `alta-${miRegistro.id}` }); ``` ## Cargar energía ```ts const pago = await met.workspaces.recharge(cuenta.id, 50); // pago.checkout_url → enlace de pago de Stripe ``` La recarga **no acredita el saldo al instante**: devuelve un `checkout_url` y la energía entra cuando el pago se completa. El mínimo es USD 5. Solo funciona sobre cuentas que creaste tú. Una cuenta ajena responde `403`. ## Cupos Cada nivel de partner tiene un cupo **mensual** de cuentas nuevas: | Nivel | Cuentas por mes | |---|---| | Bronce | 100 | | Plata | 300 | | Oro | 1000 | Al agotarse, la creación responde `403` con el cupo, lo que llevas consumido y cuándo se reinicia. El contador vuelve a cero el primer día de cada mes. ## Errores | Código | Qué pasó | |---|---| | `403 scope_not_allowed_for_owner` | Estás usando una key de workspace; esto necesita una key de partner | | `403 test_mode_restricted` | Estás usando una key `met_test_`; estas operaciones son reales | | `403` con mensaje de capacidad | Tu cuenta de partner no tiene la capacidad Tech Partner aprobada | | `403` con mensaje de cupo | Se agotó tu cupo del mes | | `400` | Falta la cabecera `Idempotency-Key`, o algún dato del dueño no es válido | Para el detalle de reintentos e idempotencia, ver [Errores e idempotencia](errores-e-idempotencia.html). --- # Un agente que contesta WhatsApp Con el autopiloto encendido, Meteor ya contesta los mensajes de WhatsApp solo. Esta receta es para lo otro: cuando quieres **tu propia lógica en el medio** — consultar tu inventario antes de responder, escalar según el cliente, escribir en tu CRM, decidir cuándo calla el Met y contesta una persona. ## Antes de empezar Conecta el canal de WhatsApp a tu workspace desde el panel (**Canales → WhatsApp**). La API no conecta canales: eso pasa una vez y necesita la aprobación de Meta. Tu key necesita cinco scopes, uno por cada cosa que hace la receta: | scope | para qué | |---|---| | `webhooks:manage` | registrar el endpoint que recibe los eventos | | `conversations:read` | recibir `contact.message.received` | | `runs:execute` | ejecutar el Met | | `channels:send` | responderle al contacto | | `handoff:manage` | apagar y prender el autopiloto | ## 1. Suscríbete a los mensajes que entran ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: Number(process.env.MET_WORKSPACE_ID), }); const sub = await met.webhooks.subscriptions.create({ url: 'https://tu-servidor.com/webhooks/met', enabled_events: ['contact.message.received'], }); console.log(sub.secret); // whsec_… — se muestra UNA sola vez ``` Guarda el `secret` apenas lo recibes: lo necesitas para verificar cada entrega y no se vuelve a mostrar. ## 2. Recibe el mensaje y contesta El evento llega con este cuerpo: ```json { "contact_id": 4821, "message_id": 99312, "channel_id": 12, "channel_type": "whatsapp", "content": "¿Todavía tienen la bici azul?", "attachments": [] } ``` ```ts import express from 'express'; const app = express(); // El cuerpo CRUDO es obligatorio: si tu framework re-serializa el JSON antes de // verificar, la firma deja de coincidir aunque el contenido sea idéntico. app.use('/webhooks/met', express.raw({ type: 'application/json' })); app.post('/webhooks/met', async (req, res) => { let evento; try { evento = met.webhooks.constructEvent( req.body, req.headers['x-met-signature'] as string, process.env.MET_WEBHOOK_SECRET!, ); } catch { return res.status(400).send('firma inválida'); } // Responde YA. Meteor reintenta si no ve un 2xx, y un Met puede tardar varios // segundos: contestar después de pensar te duplica los mensajes. res.sendStatus(200); if (evento.type !== 'contact.message.received') return; const { contact_id, content } = evento.data; const run = await met.runs.create({ input: content }); await met.contacts.sendMessage(contact_id, run.output); }); app.listen(3000); ``` El run que creas por API no queda atado al contacto: recibe el texto que le pasas y nada más. Si quieres que el Met responda sabiendo con quién habla, arma tú el input — por ejemplo con `met.contacts.retrieve(contact_id)` y `met.contacts.messages(contact_id)` — o usa `conversation_id` para que la conversación tenga memoria entre runs. ## 3. Pásasela a una persona cuando haga falta Un Met que no sabe algo tiene que poder soltar la conversación. Con el autopiloto apagado los mensajes siguen llegando a tu bandeja, pero el Met deja de responder a ese contacto hasta que lo vuelvas a prender. ```ts if (/hablar con (alguien|una persona|un asesor)/i.test(content)) { await met.contacts.setAutopilot(contact_id, false); await met.contacts.sendMessage(contact_id, 'Te comunico con alguien del equipo 👋'); await met.contacts.createNote(contact_id, 'Pidió atención humana.'); return; } ``` Para devolverle el control al Met: `met.contacts.setAutopilot(contact_id, true)`. ## La ventana de 24 horas Esto no es un límite de Meteor y no se puede saltar: **WhatsApp solo te deja escribir libremente durante las 24 horas siguientes al último mensaje de la persona.** Pasada esa ventana, lo único que entra es una plantilla aprobada por Meta: ```ts await met.contacts.sendTemplate(contact_id, { name: 'recordatorio_cita', language: 'es', namespace: process.env.WA_TEMPLATE_NAMESPACE!, // obligatorio params: { '1': 'Ana', '2': 'martes a las 3' }, }); ``` Si tu flujo contesta a destiempo —una cola, un reintento nocturno— esa ventana es la primera causa a mirar, antes que la key o los scopes. ## Probarlo sin desplegar nada El CLI te reenvía los eventos reales del workspace a tu máquina, así que puedes escribir el handler contra tráfico de verdad antes de tener servidor: ```bash met listen --forward http://localhost:3000/webhooks/met --types contact.message.received ``` ## Y después - El esquema de cada evento, los reintentos y el log de entregas: [Webhooks](webhooks.html). - Contestar en vivo, palabra por palabra, en vez de esperar el run completo: [Mostrar un run en vivo en tu UI](receta-streaming.html). - Que el Met consulte tus propios datos al responder: [Datos: colecciones e ítems](colecciones-e-items.html). --- # Sincronizar tu sistema con colecciones Tu Met contesta mejor cuando conoce tu negocio. Una **colección** es una tabla del workspace que el Met lee y escribe sola: espejas ahí tu catálogo, tu inventario o tu lista de precios, y dejas de escribir un endpoint por cada pregunta que alguien le pueda hacer. Esta receta sincroniza un sistema externo hacia una colección, cada noche, sin duplicar nada. ## Antes de empezar Tu key necesita `collections:write`, `items:read` e `items:write`. ## 1. Crea la colección y sus campos Una sola vez. El `name` de cada campo es la **clave** con la que el valor vive dentro del ítem y no cambia nunca; el `label` es lo que se ve en pantalla y sí puedes cambiarlo. ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: Number(process.env.MET_WORKSPACE_ID), }); const catalogo = await met.collections.create({ name: 'Catálogo' }); for (const campo of [ { name: 'sku', label: 'SKU', type: 'text', required: true }, { name: 'nombre', label: 'Nombre', type: 'text', required: true }, { name: 'precio', label: 'Precio', type: 'currency' }, { name: 'stock', label: 'Stock', type: 'number' }, { name: 'activo', label: 'Activo', type: 'boolean' }, ] as const) { await met.collections.addField(catalogo.id, campo); } ``` Los tipos disponibles son `text`, `number`, `currency`, `email`, `date`, `datetime`, `boolean`, `url`, `select`, `prompt`, `usuario`, `contact`, `page`, `sitio`, `image` y `file`. ## 2. Sincroniza, sin duplicar La colección no impone unicidad por ti. El patrón que funciona es traerte lo que ya está, indexarlo por tu propia clave de negocio —aquí el `sku`— y decidir crear o actualizar. ```ts // Lo que ya vive en Meteor, indexado por SKU. const porSku = new Map(); for await (const item of met.items.iterate(catalogo.id)) { porSku.set(item.data.sku, item.id); } for (const producto of await traerDeTuSistema()) { const existente = porSku.get(producto.sku); if (!existente) { await met.items.create(catalogo.id, { sku: producto.sku, nombre: producto.nombre, precio: producto.precio, stock: producto.stock, activo: true, }); continue; } // Solo lo que se mueve seguido: un patch por campo evita pisar // lo que alguien haya editado a mano en el panel. await met.items.patchField(catalogo.id, existente, 'precio', producto.precio); await met.items.patchField(catalogo.id, existente, 'stock', producto.stock); porSku.delete(producto.sku); } // Lo que quedó en el mapa ya no está en tu sistema. for (const [, itemId] of porSku) { await met.items.patchField(catalogo.id, itemId, 'activo', false); } ``` Fíjate que lo que se fue se marca `activo: false` en vez de borrarse. Un ítem borrado se lleva las referencias que otros Mets o tareas le hayan hecho; uno inactivo se puede filtrar y sigue explicando por qué una conversación vieja habla de él. ## 3. Comprueba que el Met lo está viendo ```ts const encontrados = await met.items.search('bicicleta azul'); ``` `search` busca sobre el contenido de los ítems del workspace. Si tu Met responde "no tengo esa información" y aquí sí aparece, el problema no es el dato: es que el Met no tiene la colección entre las suyas. Eso se ajusta por Met — mira [Mets y sus herramientas](mets-y-herramientas.html). ## Correrlo cada noche ```bash met items list --collection 12 --limit 5 # comprobación rápida desde la terminal ``` Cualquier cron sirve. Si ya usas GitHub Actions, la receta de [ejecutar Meteor desde CI](receta-github-actions.html) tiene el workflow armado. ## Y después - Vistas, carpetas y el modelo completo: [Datos: colecciones e ítems](colecciones-e-items.html). - Si lo que quieres espejar son personas y no productos, va en el CRM y no en una colección: [CRM y contactos](contactos.html). --- # Recibir eventos firmados en tu backend Sondear la API para saber si algo pasó es caro y llega tarde. Con una suscripción, Meteor te hace `POST` cuando el hecho ocurre. Esta receta arma el endpoint completo: firma verificada, respuesta rápida y un procesamiento que aguanta reintentos. ## Antes de empezar Tu key necesita `webhooks:manage`, más el scope del evento que quieras escuchar — `runs:read` para `run.completed`, `contacts:read` para `contact.created`, y así. Si pides un evento cuyo scope no tienes, no llega. ## 1. Suscríbete ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: Number(process.env.MET_WORKSPACE_ID), }); const sub = await met.webhooks.subscriptions.create({ url: 'https://tu-servidor.com/webhooks/met', enabled_events: ['run.completed', 'run.failed', 'task.completed'], description: 'Procesador de resultados', }); console.log(sub.secret); // whsec_… — se muestra UNA sola vez ``` `enabled_events: ['*']` te trae todos. Conviene enumerar los que de verdad procesas: un endpoint que recibe diecisiete tipos y actúa sobre tres es un endpoint que se cae por un evento que nadie miró. Los tipos disponibles salen de la API, así que no hay que adivinarlos: ```ts const tipos = await met.webhooks.subscriptions.eventTypes(); ``` ## 2. El endpoint ```ts import express from 'express'; const app = express(); // Cuerpo CRUDO. La firma se calcula sobre los bytes exactos que mandó Meteor: // si tu framework parsea y vuelve a serializar el JSON, deja de coincidir. app.use('/webhooks/met', express.raw({ type: 'application/json' })); app.post('/webhooks/met', async (req, res) => { let evento; try { evento = met.webhooks.constructEvent( req.body, req.headers['x-met-signature'] as string, process.env.MET_WEBHOOK_SECRET!, ); } catch { // Firma inválida o timestamp viejo. No reintentar: no va a mejorar. return res.status(400).send('firma inválida'); } // 2xx primero, trabajo después: Meteor considera fallida la entrega que no // responde, y tu procesamiento puede tardar más que su tiempo de espera. res.sendStatus(200); await encolar(evento); }); app.listen(3000); ``` `constructEvent` verifica la firma en tiempo constante y chequea el timestamp del header `X-Met-Signature: t=,v1=`, que es lo que impide que alguien te reenvíe una entrega vieja legítima. ## 3. Sobrevivir a los reintentos Si tu endpoint no responde `2xx`, Meteor reintenta con espera creciente —1 min, 5 min, 30 min, 2 h, 6 h, 24 h— hasta seis veces. Eso significa que **el mismo evento puede llegarte más de una vez**, y también que puede llegar tarde y desordenado. Guarda el id del evento antes de actuar: ```ts async function encolar(evento) { const nuevo = await tuBase.insertarSiNoExiste('eventos_met', { id: evento.id }); if (!nuevo) return; // ya lo procesamos: la entrega es un reintento await procesar(evento); } ``` La regla que evita el susto: **el orden de llegada no es el orden de los hechos.** Si tu lógica depende de la secuencia, ordénala por el `created` del evento —un epoch en segundos— y no por cuándo lo recibiste. Cada entrega viene con esta forma: ```json { "id": "evt_9f2c…", "object": "event", "type": "run.completed", "created": 1786886400, "livemode": true, "data": { } } ``` ## 4. Probarlo antes de que pase algo de verdad ```ts await met.webhooks.subscriptions.test(sub.id); // entrega de prueba, firmada igual ``` Y contra tráfico real, sin desplegar: ```bash met listen --forward http://localhost:3000/webhooks/met --types run.completed,run.failed ``` Si una entrega falló y ya arreglaste la causa, se puede reintentar a mano desde el log de entregas, que está en tu panel en **Desarrolladores → Actividad**. ## Y después - El catálogo completo de eventos y el detalle de los reintentos: [Webhooks](webhooks.html). - Si lo que quieres es reaccionar en el mismo segundo y no en la próxima entrega: [Eventos en vivo](eventos.html). --- # Ejecutar un Met desde GitHub Actions El CLI cubre la misma API que el SDK, así que cualquier cosa que hace tu backend la puede hacer un paso de CI. Esta receta corre un Met cuando pasa algo en el repositorio y hace que el pipeline falle si la tarea falló. ## Antes de empezar Guarda dos secretos en el repositorio (**Settings → Secrets and variables → Actions**): | secreto | qué es | |---|---| | `MET_API_KEY` | una key `met_live_` con los scopes que use tu paso | | `MET_WORKSPACE` | el id numérico del workspace | El CLI lee las dos variables de entorno directamente, así que en CI no hace falta `met config set`. ## 1. Un Met que resume lo que se acaba de desplegar ```yaml name: Resumen de despliegue on: push: branches: [main] jobs: resumir: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 20 - run: npm i -g @meteor.ia/cli - name: Resumir los commits del push env: MET_API_KEY: ${{ secrets.MET_API_KEY }} MET_WORKSPACE: ${{ secrets.MET_WORKSPACE }} run: | COMMITS=$(git log --format='%s' -20) met run "Resume estos commits para el equipo de soporte, en tres viñetas: $COMMITS" \ --json --no-stream > salida.json jq -r '.output' salida.json >> "$GITHUB_STEP_SUMMARY" ``` `--json` imprime el run completo en una línea, listo para `jq`. `--no-stream` es lo que hace que sirva en CI: sin él, el CLI pinta la respuesta a medida que llega y lo que queda en el log son fragmentos, no un JSON parseable. ## 2. Una tarea que hace fallar el pipeline `met run` ejecuta un Met suelto. Para un procedimiento con pasos —el caso normal cuando el resultado importa— va una tarea, y `--wait` espera el estado final: ```yaml - name: Revisión previa al release env: MET_API_KEY: ${{ secrets.MET_API_KEY }} MET_WORKSPACE: ${{ secrets.MET_WORKSPACE }} run: met tasks run tsk_123 --wait ``` Con `--wait`, **el código de salida refleja el estado final de la ejecución**: si la tarea falla, el paso falla y el pipeline se detiene. Sin `--wait`, el comando dispara la ejecución y vuelve enseguida con un `0` que solo dice que arrancó. Ojo con una cosa: si la tarea tiene un paso que espera aprobación de una persona, `--wait` se queda esperando hasta que alguien decida, y en CI eso es un job colgado hasta el tiempo límite. Las tareas que corren desatendidas no llevan pausas humanas — mira [la receta de aprobación](receta-aprobacion-humana.html) para cuándo sí conviene tenerlas. ## 3. Un vistazo a lo que salió mal Útil como paso final cuando algo falló, o como job programado: ```bash met runs list --status failed --limit 20 ``` ## La Energía se consume igual Un run desde CI cuesta lo mismo que uno desde tu backend, y un workflow que corre en cada push suma rápido. Dos cosas que ayudan: usa una key aparte para CI, y ponle presupuesto — con `billing.threshold` suscrito te llega un aviso al 50, 80 y 100% en vez de una sorpresa. El consumo por key está en tu panel, en **Desarrolladores**. ## Y después - Todos los comandos, la configuración y los formatos de salida: [CLI (met)](cli.html). - Para reaccionar al final del run en vez de esperarlo dentro del job: [recibir eventos firmados](receta-webhooks-firmados.html). --- # Mostrar un run en vivo en tu UI Esperar diez segundos mirando un spinner se siente roto aunque no lo esté. Meteor entrega el run como **Server-Sent Events**, y esta receta lo lleva hasta el navegador. El punto que no se puede saltar: **la key es un secreto de servidor.** El navegador nunca habla con la API de Meteor; habla con tu backend, y tu backend retransmite. ## Antes de empezar Tu key necesita `runs:execute`. ## 1. El relay en tu backend ```ts import express from 'express'; import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: Number(process.env.MET_WORKSPACE_ID), }); const app = express(); app.get('/api/preguntar', async (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); try { for await (const ev of met.runs.stream({ input: String(req.query.q) })) { res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`); } } catch (err) { res.write(`event: error\ndata: ${JSON.stringify({ message: 'se cortó' })}\n\n`); } finally { res.end(); } }); app.listen(3000); ``` El SDK te entrega objetos `{ type, data }` ya parseados. El stream emite un conjunto cerrado de eventos: | evento | cuándo | |---|---| | `run.started` | el run arrancó | | `run.step` | un paso del Met, en versión curada | | `run.output` | un fragmento de la respuesta | | `run.completed` | terminó bien | | `run.failed` | terminó con error | ## 2. El navegador ```html

``` `EventSource` reconecta solo cuando se cae la conexión, y ese comportamiento aquí juega en contra: reconectar a `/api/preguntar` **vuelve a ejecutar el Met**, con su costo. Por eso se cierra explícitamente al terminar. ## 3. No pierdas el resultado si el stream se corta Un stream cortado a la mitad no se reanuda de forma transparente. Si lo que estás mostrando también tiene que quedar guardado, no dependas del stream para eso: el run existe en la API con o sin stream. ```ts const run = await met.runs.retrieve(runId); // la fuente de verdad, sin apuro ``` El patrón para lógica que importa es usar el stream **solo para pintar** y confirmar contra `retrieve()` cuando el usuario ya se fue. Si nunca guardas el resultado, un usuario que cierra la pestaña a los ocho segundos deja un run que te cobraron y nadie leyó. ## Verlo desde la terminal ```bash curl -N https://api.met.meteor.com.co/api/v1/workspaces/7/runs \ -H "Authorization: Bearer $MET_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{"input":"Hola","stream":true}' ``` `-N` desactiva el buffering: sin él ves todo junto al final y parece que el streaming no funciona. ## Y después - El esquema de eventos y qué expone `run.step`: [Streaming (SSE)](streaming.html). - Para eventos del workspace en vez de los de un run: [Eventos en vivo](eventos.html). --- # Una tarea que pide aprobación humana Hay pasos que no quieres automatizar del todo: aplicar un descuento, mandar un contrato, emitir una nota crédito. Una **tarea** puede hacer todo el trabajo previo y **detenerse** justo antes de ese paso, esperando a una persona. Esta receta conecta esa pausa con tu propio sistema, para que la decisión se tome donde ya trabaja tu equipo en vez de en un panel más. ## Antes de empezar Tu key necesita `tasks:read` y `tasks:write`. ## Las dos pausas, que no son la misma | | quién hace el trabajo | cómo se destraba | |---|---|---| | **Aprobación** | el Met, y pide permiso para seguir | `approveStep` / `rejectStep` | | **Paso humano** | la persona | `completeHumanStep` | Confundirlas es el error más común: si el trabajo lo hizo el Met y tú llamas `completeHumanStep`, la ejecución avanza sin que nadie haya aprobado nada. ## 1. Dispara la tarea ```ts import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!, { workspaceId: Number(process.env.MET_WORKSPACE_ID), }); const ejecucion = await met.tasks.execute('tsk_123'); // → { id: 'tex_…', status: 'running', … } ``` ## 2. Detecta que se detuvo y en qué paso El detalle de la ejecución trae el resultado de cada paso de esa corrida en `task_step_executions`: ```ts const estado = await met.tasks.execution(ejecucion.id); const pendiente = estado.task_step_executions.find( (p) => p.status === 'waiting_for_approval' || p.status === 'waiting_for_human', ); if (pendiente) { await avisarleATuEquipo({ ejecucion: ejecucion.id, paso: pendiente.step_id, // ← el del paso, no el de su corrida tipo: pendiente.status, loQueHizoElMet: pendiente.output, }); } ``` Un paso de ejecución pasa por `pending`, `running`, `completed`, `failed`, `waiting_for_approval`, `waiting_for_human`, `approved` y `rejected`. > Cada fila tiene **dos** identificadores: `id` es el de esa corrida del paso y `step_id` > es el del paso dentro de la receta. Aprobar y rechazar piden el **`step_id`**. Con el > otro, la respuesta es un 404 que parece decir que el paso no existe. Sondear está bien para empezar, pero para producción es mejor enterarse: suscríbete a `task.completed` con [webhooks firmados](receta-webhooks-firmados.html) y deja de preguntar. ## 3. Deja que tu equipo decida desde donde ya trabaja ```ts const stepId = pendiente.step_id; // Aprobar: la ejecución sigue al paso siguiente, de forma asíncrona. await met.tasks.approveStep(ejecucion.id, stepId, 'Va, los montos cuadran'); // Rechazar: esa rama se detiene. await met.tasks.rejectStep(ejecucion.id, stepId, 'El descuento no está autorizado'); // Paso humano: lo hizo tu sistema, no el Met. await met.tasks.completeHumanStep(ejecucion.id, stepId, 'Contrato firmado y archivado'); ``` El comentario que mandas queda guardado en `approval_comment` del paso, junto con quién decidió y cuándo. Es lo que después explica una corrida rara sin tener que reconstruirla. > Las tres operan sobre el **id de la ejecución**, no el de la tarea. El paso pertenece a > la receta, pero la pausa pertenece a la corrida: usar el id de la tarea es el error que > más tiempo cuesta aquí. ## 4. Cortar una corrida ```ts await met.tasks.cancelExecution(ejecucion.id); ``` Distinto de pausar la tarea con `met.tasks.setStatus(tarea.id, 'paused')`: eso impide que nazcan ejecuciones nuevas y **no detiene las que están corriendo**. ## Lo que hay que decidir antes de poner esto en producción Una ejecución detenida se queda detenida. Si nadie aprueba, ahí sigue — no hay tiempo límite que la resuelva por ti. Decide desde el principio qué pasa con una aprobación que nadie miró en 48 horas: recordar, escalar, o cancelar y rehacer. Es una decisión de producto, y la respuesta "ya la mirará alguien" termina siempre en la misma llamada de un cliente preguntando por algo que quedó a medias. Y por lo mismo, una tarea con pausas humanas no va en CI: el job se queda esperando hasta el tiempo límite. Para lo desatendido, mira [ejecutar un Met desde GitHub Actions](receta-github-actions.html). ## Y después - Pasos, disparadores y el modelo completo: [Tareas](tareas.html). - Enterarte del final sin sondear: [Eventos en vivo](eventos.html). --- Partners API · met.partner.* # La API propia de Partners Además de operar los workspaces de sus clientes (la superficie de meteorus, compartida con las keys de workspace), una key de partner accede a su propio negocio: oportunidades, proyectos de implementación, tickets de soporte, clientes atribuidos, comisiones y payouts. En el SDK vive bajo el namespace met.partner.*. Exclusiva de keys de partner. Ningún scope partner:* se puede emitir en una key de workspace, y el servidor lo re-verifica en cada request. Una key de workspace que intente estas rutas recibe 403. Escribes oportunidades, proyectos y tickets, con su hilo de conversación y adjuntos. Los dominios financieros —comisiones y payouts— se mantienen solo lectura a propósito: crearlos o editarlos desde afuera son procesos con reconciliación humana. Referencia interactiva de met.partner.* → (generada del contrato OpenAPI, con try-it). ## Autenticación Usas la misma key met_* que para el resto del SDK. El SDK enruta automáticamente las llamadas met.partner.* al backend de partners — no tienes que configurar nada. ``` import Met from '@meteor.ia/sdk'; const met = new Met(process.env.MET_API_KEY!); // El namespace partner usa el host de partners de forma transparente. const clientes = await met.partner.clients.list(); ``` Si operas contra un entorno distinto (staging), puedes apuntar el host de partners a mano: ``` const met = new Met(process.env.MET_API_KEY!, { partnerBaseUrl: 'https://api.partners.staging.meteor.com.co', }); ``` ## Clientes Lista los clientes (workspaces) atribuidos a tu organización, con su estado, plan y consumo — lo mismo que ves en tu panel. Paginado y con búsqueda. ``` const page = await met.partner.clients.list({ page: 1, limit: 50, search: 'acme', // opcional: filtra por nombre }); // → { items: [{ workspace_id, status, plan_name, price_usd, // commission_usd, energy_usd, ... }], pagination } ``` Cada cliente incluye su plan y estado en Met, la tarifa de comisión del originador y el consumo de Energía del periodo. Nunca verás datos de clientes de otro partner: el tenancy se aplica por el dueño de la key. ## Leads Lista tus oportunidades (leads) — contacto, empresa, industria y estado, igual que en tu CRM de partner. La salida es curada: nunca expone internals de asignación, atribución ni notas del staff. Excluye las convertidas por defecto. Para crearlas y moverlas, mira Escritura. ``` const leads = await met.partner.leads.list({ status: 'NEW', // opcional: NEW · ASSIGNED · ACCEPTED · REJECTED · CONVERTED limit: 100, }); // → { data: [{ id, status, stage, contact_name, contact_email, // company_name, vertical, zone, plan_target, tags, // partner_name, created_at }], has_more } const lead = await met.partner.leads.get(id); // detalle de una propia ``` ## Comisiones Lista tus comisiones. Filtra por estado (PENDING, APPROVED, PAID, CANCELLED). ``` const comisiones = await met.partner.commissions.list({ status: 'PAID', page: 1, limit: 50, }); ``` ## Payouts Lista tus payouts (liquidaciones) y consulta el resumen de tu wallet. ``` const payouts = await met.partner.payouts.list({ page: 1, limit: 50 }); const wallet = await met.partner.payouts.summary(); // → { pending, approved, paid, scheduled, next_payout, ... } ``` ## Proyectos Lista los proyectos de implementación donde tu organización es originadora o implementadora — fase, estado, precio, deadline y responsables. Salida curada: sin los ids internos de partner ni la mecánica del tablero. Para crearlos y avanzarlos, mira Escritura. ``` const proyectos = await met.partner.projects.list({ limit: 100 }); // → { data: [{ id, title, phase, status, price_amount, price_status, // deadline_at, workspace_id, originator_name, // implementer_name, created_at }], has_more } const proyecto = await met.partner.projects.get(id); // detalle de uno propio ``` ## Escritura Si operas tu propio portal, puedes registrar y mover oportunidades y proyectos desde ahí. Lo que creas por API es tuyo desde el primer momento: una oportunidad nace aceptada y a tu nombre, sin pasar por el pool de Meteor. ### Oportunidades ``` const lead = await met.partner.leads.create({ company_name: 'Acme', contact_name: 'Ada Lovelace', contact_email: 'ada@acme.co', vertical: 'retail', }); await met.partner.leads.update(lead.id, { notes: 'Primera llamada hecha' }); await met.partner.leads.move(lead.id, 'CONTACTADO'); await met.partner.leads.delete(lead.id); // solo las tuyas ``` Las etapas del pipeline son NUEVO, CONTACTADO, CALIFICADO, PROPUESTA, GANADO y PERDIDO. ### Proyectos, tareas e hitos ``` const proyecto = await met.partner.projects.create({ title: 'Implementación Acme', workspace_id: 42, // debe ser un cliente atribuido a ti deadline_at: '2026-09-01T00:00:00Z', }); await met.partner.projects.tasks.create(proyecto.id, { title: 'Kickoff' }); await met.partner.projects.addMilestone(proyecto.id, 'Alcance firmado'); await met.partner.projects.advancePhase(proyecto.id); ``` Tres cosas se gestionan solo desde el portal de Meteor, porque mueven dinero o atribución: proponer y aprobar el precio, asignar implementador, y convertir una oportunidad en cliente. advancePhase() las respeta — falla si el precio no está aprobado, si quedan hitos pendientes, o si el implementador no aceptó su oferta. Los POST aceptan Idempotency-Key; el SDK genera uno por request, así que un reintento por timeout no te deja dos oportunidades iguales. Un proyecto creado por API es siempre de cliente: los proyectos internos son trabajo del equipo de Meteor. ### Tickets de soporte Con workspace_id abres el ticket a nombre de un cliente de tu cartera y ese cliente lo ve en su Met; sin él es un ticket tuyo hacia Meteor. Es la pieza que te deja poner una mesa de ayuda de primer nivel en tu propio portal. ``` const motivos = await met.partner.support.reasons(); const ticket = await met.partner.support.create({ subject: 'No llega el mensaje de WhatsApp', description: 'Desde ayer 3pm no salen las notificaciones.', workspace_id: 42, // cliente de tu cartera; omitir = ticket propio reason_id: motivos[0].id, }); await met.partner.support.reply(ticket.id, 'Ya revisamos la plantilla.'); await met.partner.support.updateStatus(ticket.id, 'resolved'); ``` Estados: new, in_progress, waiting_client, resolved y closed. Las notas internas del equipo de Meteor nunca salen por esta superficie, y los tickets de prioridad crítica los gestiona solo Meteor. ### Hilo de la relación y adjuntos Una oportunidad y el proyecto en que se convierte no son dos cosas para nosotros: son la misma relación en dos momentos. Por eso la conversación es una sola — lo que escribiste mientras vendías sigue ahí cuando estás implementando, sin copiar nada. ``` // El hilo de la oportunidad await met.partner.leads.comments.create(lead.id, { body: 'Pidieron demo el viernes.' }); const hilo = await met.partner.leads.comments.list(lead.id); // Adjuntar un archivo: primero se sube, después se manda su metadata const adjunto = await met.partner.leads.comments.upload(lead.id, archivo, { filename: 'propuesta.pdf', contentType: 'application/pdf', }); await met.partner.leads.comments.create(lead.id, { body: 'Adjunto la propuesta firmada.', attachments: [adjunto], }); // Lo mismo, sobre un proyecto await met.partner.projects.comments.create(proyecto.id, { body: 'Kickoff hecho.' }); ``` El hilo no tiene scope propio: monta el de su dominio. Leerlo pide partner:leads:read o partner:projects:read; escribirlo, el :write correspondiente. Quien puede ver la oportunidad ve su conversación, y no antes. Solo borras los mensajes que escribiste con esa key. Los adjuntos van a un bucket privado y se sirven firmados. La URL que te devuelve el hilo caduca: para volver a abrir un archivo, pide una nueva con attachments.signedUrl(storage_path) — el campo estable es storage_path, no la URL. El tope por archivo es 25 MB, y las menciones (@persona) no se pueden mandar por API: dependen de ids internos del equipo. Los adjuntos de un ticket van por su propia vía (met.partner.support); el hilo del cliente —el que no cuelga ni de una oportunidad ni de un proyecto— todavía no se expone. ## Scopes Cada key de partner porta solo los scopes que le asignes al crearla. Recurso | Método del SDK | Scope | Clientes atribuidos | met.partner.clients.list() | partner:clients:read | Oportunidades (leads) | met.partner.leads.list() · .get() | partner:leads:read | Oportunidades · escritura | .create() · .update() · .move() · .delete() | partner:leads:write | Comisiones | met.partner.commissions.list() | partner:commissions:read | Payouts | met.partner.payouts.list() · .summary() | partner:payouts:read | Proyectos | met.partner.projects.list() · .get() · .tasks.list() | partner:projects:read | Proyectos · escritura | .create() · .update() · .advancePhase() · .tasks.* · hitos | partner:projects:write | Tickets | met.partner.support.list() · .get() · .reasons() | partner:support:read | Tickets · escritura | .create() · .reply() · .updateStatus() | partner:support:write | Hilo · lectura | .leads.comments.list() · .projects.comments.list() | el :read de su dominio | Hilo · escritura | .comments.create() · .upload() · .delete() | el :write de su dominio | Son 37 operaciones en total. La referencia interactiva las lista todas, generadas del mismo contrato que sirve el servidor. ## Próximamente - Hilo del cliente — la misma conversación colgada del workspace, no de una oportunidad ni de un proyecto - Provisioning de workspaces — workspaces:provision, para partners que dan de alta clientes por API - Comandos de partner en el CLI — hoy met opera sobre un workspace; una key de partner todavía no tiene camino ahí Los dominios financieros (comisiones, payouts) se mantienen read-only por diseño: su escritura pasa por reconciliación humana.