Esta es una traducción automática del documento original en inglés. En caso de cualquier discrepancia entre esta traducción y la versión original en inglés, prevalecerá la versión en inglés. Leer la versión original en inglés


API pública

Caiioo incluye una API REST que te permite controlar todo de forma programática: ejecutar agentes, gestionar herramientas, programar tareas y más. La API reside en el mismo servidor local que alimenta la aplicación de escritorio y el puente del navegador.

URL base: http://localhost:3847/v1

Autenticación: Dos formas de autenticarse, ambas reguladas por el interruptor de API en los ajustes:

Para consumidores externos (scripts, integraciones, curl): Establece un token de acceso a la API en Ajustes > Acceso API, luego úsalo como un token Bearer:

curl -H \"Authorization: Bearer TU_TOKEN_API\" http://localhost:3847/v1/providers

Para la aplicación local (automático): La aplicación de escritorio Caiioo, las extensiones de navegador y las aplicaciones móviles se autentican automáticamente a través del encabezado de autenticación de retransmisión existente (X-Relay-Auth). No se necesita configuración manual; la aplicación se encarga de esto entre bastidores.

Configuración:

  1. Abre Ajustes de Caiioo > Acceso API
  2. Activa Habilitar API pública
  3. Establece un token de acceso a la API (cualquier cadena que elijas; trátala como una contraseña)
  4. Usa ese token en todas las solicitudes de la API

La API está disponible en localhost y a través de la retransmisión privada. Consulta GET /v1/auth/info (no requiere autenticación) para ver el estado actual y las instrucciones de configuración.

Proveedores y modelos

Descubre qué proveedores de LLM están configurados y qué modelos están disponibles.

Listar proveedores:

GET /v1/providers

Devuelve todos los tipos de proveedores configurados (Anthropic, OpenAI, Google, OpenRouter, Ollama, Poe, MLX, Baseten y otros a medida que se añadan) con indicadores de capacidad (supportsVision, supportsToolCalling, supportsStreaming, etc.) y si hay una clave API configurada.

Listar modelos de un proveedor:

GET /v1/providers/anthropic/models

Devuelve el catálogo de modelos para ese proveedor. Cada modelo incluye id, displayName y contextLength cuando están disponibles.

Catálogo unificado de todos los proveedores:

GET /v1/models

Combina los modelos de cada proveedor configurado en una sola lista. Los proveedores sin claves API se omiten y se enumeran en warnings.

Agentes

Los agentes son el núcleo de caiioo. Cada agente es un Modo: una personalidad configurada con su propia instrucción de sistema, herramientas, variables y skills.

Listar todos los agentes:

GET /v1/agents

Devuelve agentes integrados (Compras, Trabajo, General) y cualquier agente personalizado que hayas creado. Cada uno está etiquetado con source: \"builtin\" o source: \"custom\".

Crear un agente personalizado:

POST /v1/agents
Content-Type: application/json

{
  \"id\": \"mi-agente-de-investigacion\",
  \"branding\": {
    \"name\": \"Agente de Investigación\",
    \"description\": \"Busca en la web y resume hallazgos\"
  },
  \"defaultSettings\": {
    \"systemPrompt\": \"Eres un asistente de investigación. Cita siempre las fuentes.\",
    \"enabledTools\": { \"web_browsing\": true, \"search_tools\": true }
  },
  \"settingLevels\": {}
}

Devuelve 201 con el agente creado. Se adjunta automáticamente un vector clock para la sincronización.

Actualizar un agente:

PATCH /v1/agents/mi-agente-de-investigacion
Content-Type: application/json

{ \"branding\": { \"name\": \"Agente de Investigación\", \"description\": \"Descripción actualizada\" } }

Fusiona el parche en el agente existente y actualiza el vector clock. Los agentes integrados devuelven 403: son de solo lectura.

Eliminar un agente:

DELETE /v1/agents/mi-agente-de-investigacion

Eliminación lógica mediante tombstone (se sincroniza entre dispositivos). Devuelve 204.

Ejecución de agentes

Este es el evento principal: invocar a un agente para procesar un mensaje.

Modo síncrono

Espera la respuesta completa:

POST /v1/runs
Content-Type: application/json

{
  "agentId": "general",
  "input": { "message": "¿Qué tiempo hace hoy en París?" },
  "mode": "sync"
}

Devuelve 200 con { content, usage, status: "completed" } después de que el agente termine. Si el agente da error, devuelve 500 con { error, status: "error" }.

Modo asíncrono

Lanzar y olvidar: útil para tareas de larga duración:

POST /v1/runs
Content-Type: application/json

{
  "agentId": "my-research-agent",
  "input": { "message": "Escribe un análisis de 2000 palabras sobre las tendencias de energía renovable" },
  "mode": "async"
}

Devuelve 202 inmediatamente con { runId, threadId, status: "running" }.

Consultar estado:

GET /v1/runs/{runId}

Devuelve { run: { runId, threadId, agentId, status, createdAt, content?, usage?, error? } }. El estado es uno de los siguientes: running, completed, error o cancelled.

Transmitir eventos en tiempo real (SSE):

GET /v1/runs/{runId}/events

Devuelve un text/event-stream con cada evento del agente a medida que ocurre: GENERATION_STARTED, STREAMING_CONTENT, llamadas a herramientas, actividad de subagentes y el evento final (GENERATION_COMPLETE, GENERATION_ERROR o GENERATION_CANCELLED). La transmisión termina después del evento final.

Cancelar una ejecución:

POST /v1/runs/{runId}/cancel

Devuelve { run: { ..., status: "cancelled" } }.

Threads

Los Threads son conversaciones. Cada ejecución de un agente ocurre dentro de un thread, y los threads persisten a través de las sesiones. La API le permite listar, leer, crear y gestionar threads de forma programática.

Listar todos los threads (solo metadatos):

GET /v1/threads

Devuelve los threads del perfil actual con los mensajes omitidos por rendimiento. Cada thread incluye id, title, createdAt, updatedAt, modeId, archived y estadísticas de uso.

Obtener un thread con mensajes completos:

GET /v1/threads/{id}

Devuelve el thread completo incluyendo su array de messages — cada mensaje del usuario, respuesta del asistente, llamada a herramientas (tool call) y resultado de herramientas (tool result).

Obtener solo los mensajes:

GET /v1/threads/{id}/messages

Devuelve solo el array de messages — más ligero que el objeto thread completo cuando solo necesita la conversación.

Crear un thread:

POST /v1/threads
Content-Type: application/json

{ "title": "Research project", "modeId": "general" }

Devuelve 201 con el nuevo thread. Por defecto, la API NO cambia el thread activo de la aplicación — pase "setActive": true en el cuerpo si desea que eso ocurra. El nuevo thread aparece en la barra lateral inmediatamente (vía WebSocket broadcast).

Actualizar un thread:

PATCH /v1/threads/{id}
Content-Type: application/json

{ "title": "Renamed project", "archived": true }

Campos actualizables: title, modeId, archived, lastUsedModel. Los cambios se transmiten a la barra lateral en tiempo real.

Eliminar un thread:

DELETE /v1/threads/{id}

Realiza un borrado lógico (soft-delete) del thread (tombstone para sincronización). Devuelve 204. Los threads eliminados se mueven a la papelera y pueden recuperarse hasta que se vacíe la papelera.

Thread activo:

GET /v1/threads/active            # Devuelve { threadId }
PUT /v1/threads/active            # Cuerpo: { "threadId": "..." }

Gestión de papelera:

GET /v1/threads/trash/count       # Devuelve { count }
POST /v1/threads/trash/empty      # Devuelve { deletedCount, protectedCount }

Los threads protegidos (retenidos mediante el interruptor de retención de datos) se excluyen del vaciado de la papelera.

Continuar una conversación a través de la API: Para enviar un mensaje de seguimiento a un thread existente, use POST /v1/runs con el ID del thread:

POST /v1/runs
Content-Type: application/json

{
  "agentId": "general",
  "threadId": "existing-thread-id",
  "input": { "message": "Follow up on that last point" },
  "mode": "sync"
}

El agente ve el historial completo de la conversación del thread.

Adjuntos

Los adjuntos son archivos vinculados a hilos: capturas de pantalla, PDFs, documentos, imágenes subidas, artefactos generados. La API le permite listarlos, subirlos, descargarlos y gestionarlos.

Listar todos los adjuntos (solo metadatos):

GET /v1/attachments

Devuelve metadatos de adjuntos para el perfil actual. Los campos pesados (dataUrl, extractedContent, extractedImages) se eliminan; use los puntos de conexión de detalle o contenido para ellos.

Listar adjuntos para un hilo específico:

GET /v1/threads/{threadId}/attachments

Obtener metadatos de un adjunto:

GET /v1/attachments/{id}

Devuelve los metadatos completos, incluyendo extractedContent (texto OCR, markdown analizado), contentType, fileName, size y un indicador hasContent. El binario original NO se incluye; use el punto de conexión /content para eso.

Descargar binario de adjunto:

GET /v1/attachments/{id}/content

Devuelve el archivo original con las cabeceras Content-Type y Content-Disposition correctas. Redirija esto a un archivo:

curl -o salida.pdf \
  -H "Authorization: Bearer $API_TOKEN" \
  http://localhost:3847/v1/attachments/{id}/content

Subir un adjunto:

POST /v1/attachments
Content-Type: application/json

{
  "threadId": "id-del-hilo",
  "type": "user_upload",
  "contentType": "application/pdf",
  "fileName": "informe.pdf",
  "description": "Informe trimestral",
  "dataUrl": "data:application/pdf;base64,JVBERi0xLjQ..."
}

El dataUrl es una URL de datos codificada en base64. Devuelve 201 con el nuevo ID del adjunto. El adjunto queda vinculado al hilo especificado.

Actualizar metadatos de adjunto:

PATCH /v1/attachments/{id}
Content-Type: application/json

{ "description": "Descripción actualizada", "fileName": "nuevo-nombre.pdf" }

Eliminar un adjunto:

DELETE /v1/attachments/{id}

Eliminación lógica mediante lápida. Devuelve 204.

Servidores MCP

Administre sus conexiones de servidor MCP (Model Context Protocol) — los servidores que otorgan a los agentes acceso a herramientas externas y fuentes de datos.

Listar servidores configurados:

GET /v1/mcp-servers

Devuelve todas las configuraciones de servidores MCP para el perfil actual. Los campos sensibles (authToken, env, credentialId) se eliminan de la respuesta.

Obtener la configuración de un servidor:

GET /v1/mcp-servers/{id}

Añadir un nuevo servidor MCP:

POST /v1/mcp-servers
Content-Type: application/json

{
  "id": "my-server",
  "name": "My MCP Server",
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"],
  "serverType": "local"
}

Para servidores HTTP remotos, use "url" en lugar de "command":

{
  "id": "remote-server",
  "name": "Remote API",
  "url": "https://my-mcp-server.example.com/sse",
  "serverType": "remote"
}

Actualizar un servidor:

PATCH /v1/mcp-servers/{id}
Content-Type: application/json

{ "name": "Renamed Server", "args": ["-y", "@mcp/server-v2"] }

Activar/desactivar un servidor:

POST /v1/mcp-servers/{id}/toggle
Content-Type: application/json

{ "enabled": false }

Eliminar un servidor:

DELETE /v1/mcp-servers/{id}

Gestión de Procesos

Para servidores MCP locales (stdio), puede gestionar el proceso del servidor directamente.

Listar procesos en ejecución:

GET /v1/mcp-servers/processes

Devuelve los procesos de servidor en ejecución con pid, startedAt y el estado running.

Iniciar un servidor:

POST /v1/mcp-servers/{id}/start

Lee el command/args/env de la configuración del servidor y genera el proceso. Devuelve el estado del proceso.

Detener un servidor:

POST /v1/mcp-servers/{id}/stop

Cierra de forma controlada el proceso del servidor (SIGTERM con recurso de emergencia a SIGKILL).

Llamar directamente a un método JSON-RPC:

POST /v1/mcp-servers/{id}/call
Content-Type: application/json

{ "method": "tools/list", "params": {} }

Envía una solicitud JSON-RPC 2.0 sin procesar al servidor y devuelve el resultado. Útil para depuración o para llamar a métodos que no están expuestos a través de la API de herramientas.

Herramientas y Conjuntos de Herramientas

Explore e invoque las herramientas que usan los agentes: navegación web, búsqueda, calendario, Gmail, Slate y más.

Listar conjuntos de herramientas (agrupados):

GET /v1/toolkits

Devuelve herramientas integradas agrupadas por categoría (Productividad, Búsqueda, Utilidades, etc.) y cualquier servidor MCP conectado como conjuntos de herramientas independientes, cada uno con sus acciones listadas.

Listar todas las herramientas (plano):

GET /v1/tools
GET /v1/tools?source=embedded   # Solo herramientas integradas
GET /v1/tools?source=mcp        # Solo herramientas de servidor MCP

Obtener detalles de herramienta con esquema de entrada:

GET /v1/tools/calculator

Devuelve el esquema JSON de la herramienta para sus parámetros de entrada, para que pueda validar antes de invocar.

Invocar una herramienta directamente:

POST /v1/tools/calculator/invoke
Content-Type: application/json

{ "input": { "expression": "sqrt(144) + 3^2" } }

Devuelve { result }. La entrada se valida contra el esquema de la herramienta; una entrada inválida devuelve 422 con detalles. Las herramientas MCP remotas devuelven 501 con la indicación de usar /v1/runs en su lugar (requieren el transporte de subproceso del agente).

Conectores

Gestione integraciones OAuth: Google, Microsoft, GitHub, Notion, Slack y más.

Explorar integraciones disponibles:

GET /v1/connectors/catalog

Devuelve todos los proveedores OAuth registrados con su nombre, categoría y alcances predeterminados.

Listar sus cuentas conectadas:

GET /v1/connectors

Devuelve las conexiones activas para el perfil actual. Los tokens nunca se exponen, solo los metadatos (proveedor, correo electrónico, estado, alcances, marcas de tiempo).

Comprobar salud de la conexión:

POST /v1/connectors/{id}/test

Devuelve { health: { status, isTokenExpired, canRefresh } }.

Eliminar una conexión:

DELETE /v1/connectors/{id}

La creación de nuevas conexiones requiere el flujo interactivo OAuth a través de la interfaz de la aplicación o las rutas /auth/*.

Disparadores

Programe agentes para que se ejecuten automáticamente: resúmenes diarios, informes semanales, monitoreo basado en intervalos.

Listar disparadores:

GET /v1/triggers

Crear un disparador programado:

POST /v1/triggers
Content-Type: application/json

{
  "name": "Resumen Matutino",
  "prompt": "Resume mis correos no leídos y el calendario de hoy",
  "modeId": "general",
  "schedule": { "type": "daily", "time": "08:00" }
}

Tipos de programación admitidos:

  • { "type": "interval", "minutes": 60 } — cada N minutos (mín 15, máx 1440)
  • { "type": "daily", "time": "09:00" } — diariamente a una hora específica
  • { "type": "weekly", "day": "mon", "time": "09:00" } — semanalmente
  • { "type": "weekdays", "time": "08:30" } — de lunes a viernes
  • { "type": "daysOfWeek", "days": ["mon", "wed", "fri"], "time": "10:00" } — días específicos
  • { "type": "monthly", "dayOfMonth": 1, "time": "09:00" } — mensualmente
  • { "type": "manual" } — solo cuando se dispara vía API

Activar un disparador manualmente:

POST /v1/triggers/{id}/fire

Devuelve 202 con un threadId para la ejecución resultante.

Actualizar o eliminar:

PATCH /v1/triggers/{id}
DELETE /v1/triggers/{id}

Webhooks

Los disparadores de Webhook permiten que servicios externos (CI/CD, monitoreo, constructores de formularios) activen la ejecución de un agente a través de HTTP.

Crear un disparador de webhook:

POST /v1/triggers
Content-Type: application/json

{
  "name": "Gancho de despliegue",
  "prompt": "Ocurrió un despliegue: {{webhook.body}}",
  "modeId": "general",
  "kind": "webhook"
}

Devuelve 201 con un webhookSecret y webhookPath. Guarde el secreto; lo necesitará para firmar las cargas útiles.

Enviar un webhook:

# Calcular HMAC-SHA256 del cuerpo de la solicitud sin procesar
SIGNATURE=$(echo -n '{"repo":"mi-app","branch":"main"}' | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')

POST /v1/webhooks/{triggerId}
Content-Type: application/json
X-Webhook-Signature: $SIGNATURE

{"repo": "mi-app", "branch": "main"}

El punto de conexión del webhook NO requiere autenticación bearer; utiliza verificación HMAC en su lugar. Devuelve 202 con el threadId de la ejecución enviada. El marcador de posición {{webhook.body}} en el mensaje del disparador se reemplaza con el cuerpo de la solicitud original.

Funciones Personalizadas

Cree sus propias herramientas que los agentes puedan llamar. Las funciones se escriben en JavaScript o Python y se ejecutan en un entorno seguro (sandbox).

Listar funciones:

GET /v1/functions

Crear una función:

POST /v1/functions
Content-Type: application/json

{
  "name": "calcular_imc",
  "description": "Calcular el Índice de Masa Corporal a partir de la altura y el peso",
  "language": "javascript",
  "source": "return { imc: (input.weightKg / (input.heightM * input.heightM)).toFixed(1) };",
  "inputSchema": {
    "type": "object",
    "properties": {
      "weightKg": { "type": "number" },
      "heightM": { "type": "number" }
    },
    "required": ["weightKg", "heightM"]
  }
}

Las funciones de JavaScript reciben input y deben devolver un resultado. Las funciones de Python establecen una variable result:

# Ejemplo en Python
result = {"imc": round(input["weightKg"] / (input["heightM"] ** 2), 1)}

Ejecutar una función directamente:

POST /v1/functions/{id}/execute
Content-Type: application/json

{ "input": { "weightKg": 75, "heightM": 1.80 } }

Seguridad: JavaScript se ejecuta en el sandbox vm de Node (sin acceso al sistema de archivos ni a la red, tiempo de espera de 10s). Python se ejecuta como un subproceso con un tiempo de espera de 30s. Ambos validan la entrada antes de la ejecución.

Actualizar o eliminar:

PATCH /v1/functions/{id}
DELETE /v1/functions/{id}

Flujos de trabajo

Orqueste múltiples agentes en un DAG (grafo acíclico dirigido): ejecute pasos en paralelo cuando sea posible y use los resultados de pasos anteriores en los posteriores.

Validar un grafo de flujo de trabajo:

POST /v1/workflows/validate
Content-Type: application/json

{
  "graph": {
    "nodes": [
      { "id": "research", "agentId": "general", "prompt": "Investigar tendencias de energía renovable" },
      { "id": "analyze", "agentId": "general", "prompt": "Investigar precios de la competencia" },
      { "id": "report", "agentId": "general", "prompt": "Escribir un informe combinando: {{outputs.research}} y {{outputs.analyze}}", "dependsOn": ["research", "analyze"] }
    ]
  }
}

Devuelve { valid: true/false, errors: [...] }. Comprueba ciclos, IDs duplicados y referencias de dependencia faltantes.

Ejecutar un flujo de trabajo:

POST /v1/workflows/execute
Content-Type: application/json

{
  "graph": {
    "nodes": [
      { "id": "research", "agentId": "general", "prompt": "Investigar tendencias de energía renovable" },
      { "id": "summarize", "agentId": "general", "prompt": "Resumir: {{outputs.research}}", "dependsOn": ["research"] }
    ]
  }
}

Devuelve { status: "completed", outputs: { research: "...", summarize: "..." }, nodeResults: {...} }.

Los nodos independientes (sin dependencias compartidas) se ejecutan en paralelo. El marcador de posición {{outputs.nodeId}} en el prompt de un nodo se reemplaza con el contenido de salida del nodo ascendente correspondiente. Cada nodo es una ejecución completa del agente, por lo que puede usar herramientas, navegar por la web y acceder a todas las capacidades del agente de destino.

Bases de Conocimiento

Organice documentos en colecciones buscables a las que los agentes puedan hacer referencia.

Listar bases de conocimiento:

GET /v1/knowledge/bases

Crear una base de conocimiento:

POST /v1/knowledge/bases
Content-Type: application/json

{ "name": "Artículos de Investigación" }

Devuelve 201 con el nuevo ID de la base.

Subir un documento a una base de conocimiento:

POST /v1/knowledge/bases/{id}/documents
Content-Type: application/json

{
  "fileName": "articulo-investigacion.pdf",
  "contentType": "application/pdf",
  "dataUrl": "data:application/pdf;base64,JVBERi0xLjQ...",
  "description": "Tendencias de energía renovable 2026"
}

El campo dataUrl es una URL de datos codificada en base64. Devuelve 201 con los metadatos del documento.

Listar documentos en una base de conocimiento:

GET /v1/knowledge/bases/{id}/documents

Buscar dentro de una base de conocimiento:

POST /v1/knowledge/bases/{id}/search
Content-Type: application/json

{ "query": "energía renovable" }

Devuelve documentos coincidentes basados en el nombre del archivo y la descripción. La búsqueda semántica (vectorial) llegará en una versión futura.

Eliminar un documento o base de conocimiento:

DELETE /v1/knowledge/bases/{id}/documents/{docId}
DELETE /v1/knowledge/bases/{id}

Exportar e Importar Agentes

Comparte agentes como paquetes portátiles: entre dispositivos, equipos o el Community Hub.

Exportar un agente:

POST /v1/agents/{id}/export

Devuelve un paquete JSON que contiene la definición del agente, los requisitos de herramientas (derivados de las herramientas habilitadas), los requisitos de conectores (qué proveedores de OAuth son necesarios) y las plantillas de activadores. Los metadatos de sincronización se eliminan: el paquete es un diseño limpio e independiente.

Importar un agente:

POST /v1/agents/import
Content-Type: application/json

{
  \"package\": {
    \"$schema\": \"caiioo.agent.package/v1\",
    \"agent\": {
      \"id\": \"agente-de-investigacion-compartido\",
      \"branding\": { \"name\": \"Agente de Investigación\", \"description\": \"Del equipo\" },
      \"defaultSettings\": { \"systemPrompt\": \"Investigas cosas.\" },
      \"settingLevels\": {}
    },
    \"toolRequirements\": [
      { \"toolId\": \"web_browsing\", \"enabled\": true },
      { \"toolId\": \"search_tools\", \"enabled\": true }
    ]
  }
}

Devuelve 201 con el agente instalado. Las colisiones de ID con agentes integrados o existentes devuelven 409.

Manejo de Errores

La API utiliza códigos de estado HTTP estándar:

Código Significado
200 Éxito
201 Creado
202 Aceptado (operación asíncrona iniciada)
204 Eliminado (sin contenido)
400 Solicitud incorrecta: consulte el campo error para más detalles
401 No autorizado: falta el secreto de sesión o es inválido
403 Prohibido: ej. intentar modificar un agente integrado
404 No encontrado
409 Conflicto: ej. el ID del agente ya existe
422 Error de validación: la entrada no coincide con el esquema de la herramienta
500 Error del servidor: consulte el campo error
501 No implementado: la función existe pero no está disponible de esta forma
503 Servicio no disponible: el almacenamiento o el proveedor no están listos

Todas las respuestas de error incluyen { "error": "mensaje legible por humanos" }.

Ejemplo de Inicio Rápido

Aquí tiene un flujo de trabajo completo: crear un agente, ejecutarlo y transmitir los resultados.

# 1. Crear un agente personalizado
curl -X POST http://localhost:3847/v1/agents \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "resumidor-rapido",
    "branding": { "name": "Resumidor Rápido" },
    "defaultSettings": {
      "systemPrompt": "Resume cualquier entrada de forma concisa en 3 puntos clave.",
      "enabledTools": { "web_browsing": true }
    },
    "settingLevels": {}
  }'

# 2. Ejecutarlo asíncronamente
RUN=$(curl -s -X POST http://localhost:3847/v1/runs \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "agentId": "resumidor-rapido", "input": { "message": "Resumir https://es.wikipedia.org/wiki/Inteligencia_artificial" }, "mode": "async" }')

RUN_ID=$(echo $RUN | jq -r '.runId')

# 3. Transmitir los eventos
curl -N http://localhost:3847/v1/runs/$RUN_ID/events \
  -H "Authorization: Bearer $API_TOKEN"

# 4. Exportar el agente para compartir
curl -X POST http://localhost:3847/v1/agents/resumidor-rapido/export \
  -H "Authorization: Bearer $API_TOKEN"

This guide is maintained by the Caiioo team using Slate, our built-in editor.