API de Swarm

Tareas, proyectos, canales, calendario y personas, y la diferencia real entre una clave y un token.

Base: https://swarm.eel.software/api/public/v1

Todo POST en esta superficie exige el encabezado Idempotency-Key, y cada permiso tiene un presupuesto por minuto según su clase (lectura 120, escritura 30). Ver las reglas compartidas para las dos cosas, completas, una sola vez.

Tareas

Método Ruta Permiso
GET /tasks swarm:tasks:read
POST /tasks swarm:tasks:write
GET /tasks/{id} swarm:tasks:read
PATCH /tasks/{id} swarm:tasks:write
POST /tasks/bulk swarm:tasks:write
GET /tasks/{id}/comments swarm:tasks:read
POST /tasks/{id}/comments swarm:tasks:write
POST /tasks/{id}/files swarm:tasks:write
GET /tasks/{id}/checklists swarm:tasks:read
POST /tasks/{id}/checklists swarm:tasks:write
POST /checklists/{id}/items swarm:tasks:write
PATCH /checklist-items/{id} swarm:tasks:write

Esta es la parte más completa de la suite: desde afuera puedes crear una tarea, comentarla, dividirla en checklists y marcar sus pasos.

Filtrar tareas

GET /tasks acepta estos parámetros de consulta, todos opcionales y combinables:

Parámetro Tipo Notas
project_id uuid Las tareas de un proyecto.
assignee_id uuid Las tareas de un responsable.
status open, done o all El valor por defecto es open.
status_id uuid Una columna del tablero de project_id, de GET /projects/{id}. Exige project_id.
label_id uuid Una etiqueta de project_id, de GET /projects/{id}/labels. Exige project_id.
q string Coincide con el título, o con el número de la tarea si parece uno (#32 o 32).
sort created o due El valor por defecto es created (más nuevas primero). due va de más próxima a más lejana, y las sin fecha al final.

status_id y label_id deben nombrar una columna o una etiqueta que pertenezca a project_id. Pasar cualquiera de los dos sin project_id responde 400; emparejarlos con el proyecto equivocado responde 422, el mismo código que recibe un id ajeno cuando escribes. Filtrar por status_id también cambia el valor por defecto de status a all para esa columna: pasa status tú mismo para acotarlo más, porque si no una columna "Hecho" vuelve completa en vez de quedar oculta por el filtro open por defecto.

Campos de una tarea

Campo Tipo Notas
title string, 1 a 300 caracteres Obligatorio al crear.
description string, hasta 10000 caracteres Markdown o texto plano. Ver "Campos de descripción" abajo.
project_id uuid Obligatorio al crear, viene de GET /projects. Inmutable después.
status_id uuid Un id de columna del propio proyecto de la tarea, de GET /projects/{id}.
assignee_id uuid, nullable null desasigna.
due_date YYYY-MM-DD, nullable null la borra.
priority integer No es nullable: Swarm no tiene estado "sin prioridad".
completed boolean (solo PATCH) Es una bandera, nunca una marca de tiempo.
labels uuid[], hasta 50 REEMPLAZA el conjunto completo de etiquetas. Cada id debe pertenecer al proyecto de la tarea, o responde 422.

POST /tasks es idempotente: la misma Idempotency-Key con el mismo cuerpo repite el 201 guardado. PATCH /tasks/{id} no necesita la cabecera: una actualización parcial reintentada con el mismo cuerpo ya deja el registro igual.

Adjuntar una imagen a una tarea

POST /tasks/{id}/files pone una imagen en una tarea, y Swarm la muestra entre los adjuntos de esa tarea. Así un reporte de error creado desde afuera lleva su captura. Envía los bytes como JSON:

{
  "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ",
  "content_type": "image/png",
  "filename": "error-en-el-pago.png"
}
Campo Tipo Notas
image_base64 string, obligatorio Base64 de los bytes. Acepta una URL data: y Swarm descarta el prefijo.
content_type string, opcional Swarm lo compara con los bytes. Un valor que no coincide responde 400.
filename string, hasta 120 caracteres Por defecto screenshot más la extensión del formato.

Envía image/png, image/jpeg, image/webp, image/gif o image/avif, hasta 3MB ya decodificados. Reduce una imagen más grande antes de enviarla. Todo lo demás responde 400, con el motivo en error.message: pasarse del límite, un formato fuera de esa lista, o bytes que contradicen content_type.

La respuesta es 201 con el archivo guardado: id, task_id, filename, content_type, size y created_at. Abre la tarea en Swarm para ver la imagen en la ficha.

Idempotency-Key es obligatorio y Swarm identifica la petición por los bytes decodificados. Si reintentas tras una respuesta perdida, repite el mismo 201 en lugar de poner una segunda copia de la captura en la tarea.

Actualizaciones masivas

POST /tasks/bulk aplica un cambio a hasta 200 tareas a la vez, siempre con Idempotency-Key obligatoria:

{ "op": "move", "task_ids": ["…"], "status_id": "…" }
op Campos extra Efecto
archive - Archiva las tareas listadas.
unarchive - Las restaura.
move status_id Mueve todas las tareas a esa columna.
assign assignee_id (nullable) Reasigna, o desasigna con null.
priority priority Fija la prioridad en todas.
due_date due_date (YYYY-MM-DD, nullable) Fija o borra la fecha límite.
labels add (uuid[]), remove (uuid[]) Agrega y quita etiquetas, a diferencia del PATCH de una sola tarea, que reemplaza el conjunto entero.

No existe la operación delete: archivar 200 tareas de un solo golpe irrecuperable no es una puerta que abra esta API. archive es el equivalente reversible.

La respuesta reporta qué pasó con cada tarea:

{ "ok": ["task-id-1", "task-id-2"], "failed": [{ "id": "task-id-3", "reason": "not_found" }] }

Un id que esta credencial no alcanza, o que nombra la columna de otro proyecto, cae en failed en vez de fallar toda la llamada.

Campos de descripción

description en una tarea, y en un evento de calendario, acepta Markdown. Si el valor parece Markdown y no HTML, se convierte al HTML de texto enriquecido que guarda y renderiza el editor: encabezados, párrafos, listas con viñetas y numeradas, citas, código en bloque e inline, negrita, cursiva, tachado, enlaces y separadores. Lo que ya viene con forma de HTML se deja intacto, y lo que queda fuera de ese subconjunto se conserva como texto literal en vez de perderse.

Proyectos y etiquetas

Método Ruta Permiso
GET /projects swarm:tasks:read
GET /projects/{id} swarm:tasks:read
GET /projects/{id}/labels swarm:tasks:read
POST /projects/{id}/labels swarm:tasks:write

Los proyectos son de solo lectura aquí: puedes listarlos y consultarlos, pero los creas y archivas dentro de la app. GET /projects/{id} devuelve las columnas del tablero en línea, de donde salen los valores de status_id.

Las etiquetas son por proyecto, no compartidas en todo el espacio: una etiqueta de un proyecto no se puede aplicar a una tarea de otro. POST /projects/{id}/labels toma name (1 a 40 caracteres) y un color opcional (#RRGGBB, por defecto #6E56CF); un nombre ya usado en ese proyecto responde 409 conflict.

Canales, personas y actividad

Método Ruta Permiso
GET /channels swarm:channels:read
GET /channels/{id} swarm:channels:read
GET /channels/{id}/messages swarm:channels:read
POST /channels/{id}/messages swarm:messages:write
GET /digests swarm:channels:read
GET /people swarm:people:read
GET /activity swarm:activity:read
GET /inbound-senders swarm:dms:read

Calendario

Cada ruta aquí actúa sobre el calendario propio de la credencial: la persona de un token de usuario, o el usuario que acuñó una clave de espacio, el mismo actor al que POST /tasks ya atribuye sus escrituras. No hay forma de leer o escribir el calendario de otra persona con esta API, con una sola excepción acotada en la sección de horarios libres.

Método Ruta Permiso
GET /calendar/events swarm:calendar:read
POST /calendar/events swarm:calendar:write
GET /calendar/events/{id} swarm:calendar:read
PATCH /calendar/events/{id} swarm:calendar:write
DELETE /calendar/events/{id} swarm:calendar:write
POST /calendar/events/{id}/rsvp swarm:calendar:write
GET /calendar/events/{id}/invitees swarm:calendar:read
PUT /calendar/events/{id}/invitees swarm:calendar:write
GET /calendar/events/{id}/reminders swarm:calendar:read
PUT /calendar/events/{id}/reminders swarm:calendar:write
GET /calendar/free-slots swarm:calendar:read
GET /calendar/booking-schedules swarm:calendar:read
POST /calendar/booking-schedules swarm:calendar:write
GET /calendar/booking-schedules/{id}/bookings swarm:calendar:read

Eventos

GET /calendar/events?from&to devuelve cada ocurrencia en la ventana (instancias de una serie recurrente incluidas, con cualquier edición por ocurrencia ya aplicada), con un tope de 62 días. No pagina con cursor: el tope de la ventana acota la respuesta por construcción, así que paginar solo le costaría a cada llamada un segundo viaje para descubrir que no había segunda página.

{
  "data": [
    {
      "id": "…",
      "master_id": "…",
      "occurrence_date": "2026-09-10",
      "start_at": "…",
      "end_at": "…",
      "all_day": false,
      "title": "…",
      "is_exception": false,
      "timezone": "America/Bogota"
    }
  ],
  "next_cursor": null
}

GET /calendar/events/{id} devuelve el registro MAESTRO cuando se omite occurrence_date (su propio contenido más la regla de recurrencia, null para un evento único), o una ocurrencia con cualquier excepción aplicada cuando se pasa occurrence_date (YYYY-MM-DD, de la lectura por ventana).

Crear y actualizar una serie comparten una misma forma de cuerpo:

Campo Tipo Notas
title string, 1 a 300 caracteres Obligatorio al crear.
description string, hasta 10000 caracteres, nullable Consciente de Markdown, ver arriba.
location string, hasta 300 caracteres, nullable
links { label, url }[], hasta 20
type enum Por defecto MEETING.
timezone string IANA Obligatorio. Ancla el evento y, en una serie, los días y la hora locales de la regla.
all_day boolean Decide qué par de límites se exige, ver abajo.
start_date / end_date YYYY-MM-DD Obligatorios juntos con all_day: true, prohibidos si no.
start_at / end_at instante ISO con offset Obligatorios juntos con all_day: false (el valor por defecto), prohibidos si no.
recurrence objeto, opcional Presente lo vuelve serie; ausente es un evento único.

recurrence, cuando se envía, es la regla completa, nunca un parche por campo:

Campo Tipo Notas
rule DAILY \ WEEKLY \
interval integer, 1 a 52 Cada N periodos. Por defecto 1. YEARLY tiene tope 10, no 52.
by_weekday integer[] (1=lun..7=dom) Obligatorio, no vacío, para WEEKLY.
by_month_day integer (1..31 o -1) Obligatorio para MONTHLY (-1 es el último día). Opcional para YEARLY: sobrescribe el día dentro del mes de starts_on; se omite para usar el propio día de starts_on (su mes siempre aplica, no hay campo de mes aparte).
time_of_day HH:mm Obligatorio.
starts_on YYYY-MM-DD Obligatorio. Para YEARLY, también es el mes/día ancla en que se repite la regla.
ends_on YYYY-MM-DD, nullable Abierto si se omite.

PATCH /calendar/events/{id} toma scope (this | following | all, por defecto all) y occurrence_date como parámetros de query, no como campos del cuerpo. scope: this escribe una excepción por ocurrencia y no puede tocar la regla de recurrencia; following/all la reemplazan entera cuando se envía recurrence.

Hay una regla del PATCH que sorprende: tocar los límites obliga a declarar el par completo. {"all_day": true} por sí solo se rechaza, porque el esquema no ve la fila guardada y no sabe qué límites le corresponden. Envía all_day junto con start_date/end_date para que el evento sea de día completo, o junto con start_at/end_at para que tenga hora, y nunca mezcles los dos pares.

DELETE /calendar/events/{id} cancela el evento (o, con scope, una ocurrencia o todo desde un punto en adelante). Nunca borra la fila: la única ruta destructiva de toda esta superficie es una cancelación suave, no un borrado.

RSVP, invitados y recordatorios

POST /calendar/events/{id}/rsvp siempre es para quien llama: no hay forma de responder en nombre de otra persona.

{ "status": "ACCEPTED", "occurrence_date": "2026-09-10" }

status es ACCEPTED, DECLINED o TENTATIVE (NEEDS_ACTION es el estado por defecto con el que empieza una invitación, nunca una respuesta que se elige). Omite occurrence_date para fijar la respuesta a toda la serie; pásalo para responder solo a esa ocurrencia.

PUT /calendar/events/{id}/invitees reemplaza el listado de invitados, solo el organizador, hasta 200 ids:

{ "user_ids": ["…", "…"] }

Toma los mismos parámetros de query scope (this | following | all, por defecto all) y occurrence_date que PATCH /calendar/events/{id}. scope: this cambia el listado solo para una ocurrencia, sin tocar el listado de la serie; following divide la serie en occurrence_date y le da ese listado a la nueva serie; all reemplaza el listado de toda la serie.

El GET de la misma ruta devuelve { items: [{ user_id, role, rsvp_status, responded_at }] } para el organizador o cualquier invitado. role es ORGANIZER o ATTENDEE. Pasa occurrence_date como parámetro de query para obtener el listado efectivo de una ocurrencia en vez del de toda la serie.

PUT /calendar/events/{id}/reminders reemplaza las reglas de recordatorio propias de quien llama para ese evento, enteras (organizador o invitado, cualquier estado de RSVP), hasta 10 reglas; los offsets duplicados colapsan al último enviado:

{ "reminders": [{ "minutes_before": 15, "channel": "BOTH" }] }

channel es BELL, PUSH o BOTH.

Las tres rutas (rsvp, invitees, reminders) responden los mismos tres motivos de fallo cuando el evento no es alcanzable, el alcance no aplica a esa acción, o occurrence_date no resuelve: NOT_FOUND, INVALID_SCOPE, NO_OCCURRENCE.

Horarios libres

GET /calendar/free-slots es la única ruta de esta superficie que lee calendarios de otras personas, y no lee de ellos nada más que intervalos de ocupado/libre en un rango de tiempo:

GET /calendar/free-slots?from=…&to=…&duration_minutes=30&attendees[]=<user_id>&attendees[]=<user_id>

attendees (claves de query repetidas attendees[]=, user_ids de GET /workspace/members o GET /people) por defecto es [quien llama] si se omite. La ventana no puede exceder 14 días; duration_minutes va de 5 a 480; attendees tiene tope de 20.

{
  "slots": [{ "start_at": "…", "end_at": "…" }],
  "excluded": [{ "user_id": "…", "reason": "HIDDEN" }]
}

Un invitado cuya visibilidad de calendario lo oculta de sus colegas, o un id que no es miembro de este espacio, nunca se lee en silencio como libre: queda nombrado en excluded (HIDDEN o NOT_A_MEMBER). Un horario solo se ofrece cuando cada invitado legible está libre; si todos los invitados solicitados terminan excluidos, la respuesta no trae ningún horario en vez de una ventana completa falsamente segura.

Horarios de reserva

POST /calendar/booking-schedules publica un tipo de evento reservable en el calendario propio de quien llama, una página pública de reservas al estilo Cal.com:

Campo Tipo Notas
slug string, 3 a 40 minúsculas/dígitos/guiones Único en el espacio. El segmento de ruta de la página.
title string, 1 a 200 caracteres
description string, hasta 5000 caracteres, nullable
duration_minutes integer, 5 a 480
buffer_before_minutes / buffer_after_minutes integer, 0 a 480 Por defecto 0.
min_notice_minutes integer, 0 a 43200 Por defecto 60.
max_advance_days integer, 1 a 365 Por defecto 30.
daily_cap integer, 1 a 100, nullable Máximo de reservas por día, sin límite por defecto.
availability { weekday, start, end }[], hasta 50 weekday 1=lun..7=dom; start/end son HH:mm locales a timezone.
timezone string IANA
event_type enum Por defecto MEETING.
questions { key, label, type, required }[], hasta 20 type es text o textarea.
active boolean Por defecto true.

GET /calendar/booking-schedules lista los propios de quien llama; GET /calendar/booking-schedules/{id}/bookings lista las reservas hechas sobre uno. Crear un horario y sus preguntas es la única escritura pública; los invitados reservan a través de una página pública separada, sin sesión, que esta API no expone.

Recordatorios

Cada ruta aquí actúa sobre los recordatorios propios de la credencial, y usa los mismos permisos de calendario de arriba en vez de uno nuevo: un recordatorio pertenece a una sola persona y no tiene audiencia, la misma superficie de "tu propio tiempo" que el calendario.

Método Ruta Permiso
GET /reminders swarm:calendar:read
POST /reminders swarm:calendar:write

GET /reminders devuelve los recordatorios pendientes de quien llama, del más próximo al más lejano. Uno que ya sonó no está aquí; se entregó a las notificaciones en su lugar. Cada fila trae note (las palabras propias de quien llama, nullable), ref (el EER al que apunta, por ejemplo eel:swarm:task:{id}, nullable) y about (el texto resuelto del destino, null cuando quien llama no puede leerlo).

POST /reminders acepta note, ref (un EER, por ejemplo eel:swarm:task:{id}), o ambos, y rechaza los dos ausentes. Pasa exactamente uno de at (un instante ISO) o preset (in20m, in1h, in3h, tomorrow, nextWeek), resuelto en la zona horaria del perfil de quien llama. at debe estar en el futuro.

Lo que cambia con la credencial

Aquí es donde más importa con cuál te autenticas. La forma de la respuesta es la misma; lo que cambia son las filas que llegan.

Clave de API Token de una persona
Canales públicos
Canales privados Nunca Los suyos
Proyectos visibles al espacio
Proyectos privados Nunca Los suyos
Mensajes directos Nunca Los suyos, con swarm:dms:read
Calendario El del usuario que acuñó la clave El propio de quien lleva el token

Ningún permiso cambia la columna de la izquierda. Una clave no tiene persona, y "mis canales privados" no significa nada para un espacio, y tampoco "mi calendario" más allá de la identidad para la que se acuñó la clave.

swarm:dms:read es el único permiso de toda la suite que solo tiene efecto con el token de una persona. Con una clave de API no cambia nada.

Ten presente que la membresía es lo que define "tuyo", no el permiso. El permiso dice qué puedes hacer; la membresía dice sobre qué. Ninguno sustituye al otro.

Lo que no vas a ver

Un canal privado en el que no estás responde 404, exactamente como uno que no existe. Lo mismo para un proyecto, y lo mismo para el evento de calendario de otra persona.

Un 403 confirmaría que el canal existe, y a partir del id se suele deducir de quién es. Distinguir los dos casos filtraría justo lo que "privado" existe para proteger.

Ser administrador de Swarm no cambia esto. No hay una palanca de administración para leer un canal privado en el que no eres miembro, ni el calendario de otra persona, ni por la API ni por la interfaz.