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 | Sí | Sí |
| Canales privados | Nunca | Los suyos |
| Proyectos visibles al espacio | Sí | Sí |
| 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.