Swarm API

Tasks, projects, channels, calendar and people, and the real difference between a key and a token.

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

Every POST on this surface requires an Idempotency-Key header, and every scope has a per-minute budget by class (read 120, write 30). See the shared rules for both, in full, once.

Tasks

Method Path Scope
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

This is the most complete part of the suite: from outside you can create a task, comment on it, break it into checklists and tick its steps.

Filtering tasks

GET /tasks takes all of these as query parameters, optional and combinable:

Parameter Type Notes
project_id uuid One project's tasks.
assignee_id uuid One assignee's tasks.
status open, done or all Defaults to open.
status_id uuid A column of project_id's board, from GET /projects/{id}. Requires project_id.
label_id uuid A label of project_id, from GET /projects/{id}/labels. Requires project_id.
q string Matches the title, or the task number for something like #32 or 32.
sort created or due Defaults to created (newest first). due is soonest first, undated tasks last.

status_id and label_id must each name a column or a label of project_id. Passing either without project_id answers 400; pairing either with the wrong project answers 422, the same code a foreign id gets when you write one. Filtering by status_id also defaults status to all for that one column: pass status yourself to narrow it further, otherwise a "Done" column comes back in full rather than hidden by the open-only default.

Fields on a task

Field Type Notes
title string, 1 to 300 chars Required on create.
description string, up to 10000 chars Markdown or plain text. See "Description fields" below.
project_id uuid Required on create, from GET /projects. Immutable after that.
status_id uuid A column id of the task's own project, from GET /projects/{id}.
assignee_id uuid, nullable null unassigns.
due_date YYYY-MM-DD, nullable null clears it.
priority integer Not nullable: Swarm has no "no priority" state.
completed boolean (PATCH only) A flag, never a timestamp.
labels uuid[], up to 50 REPLACES the full label set. Every id must belong to the task's own project, else 422.

POST /tasks is idempotent: the same Idempotency-Key with the same body replays the stored 201. PATCH /tasks/{id} needs no key: a retried partial update with the same body already leaves the record identical.

Attaching an image to a task

POST /tasks/{id}/files puts an image on a task, where Swarm shows it among the task's attachments. This is how a bug report filed from outside carries its screenshot. Send the bytes as JSON:

{
  "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ",
  "content_type": "image/png",
  "filename": "checkout-error.png"
}
Field Type Notes
image_base64 string, required Base64 of the image bytes. A data: URL works, and Swarm drops its prefix.
content_type string, optional Swarm checks it against the bytes. A value that disagrees answers 400.
filename string, up to 120 chars Defaults to screenshot plus the format's extension.

Send image/png, image/jpeg, image/webp, image/gif or image/avif, up to 3MB once decoded. Scale a larger image down before you send it. Everything else answers 400, with the reason in error.message: over the size cap, a format outside that list, or bytes that contradict content_type.

The answer is 201 with the stored file: id, task_id, filename, content_type, size and created_at. Open the task in Swarm to see the image on the card.

Idempotency-Key is required, and Swarm fingerprints the request by the decoded bytes. Retrying after a dropped response replays the same 201 rather than putting a second copy of the screenshot on the task.

Bulk updates

POST /tasks/bulk applies one change to up to 200 tasks at once, always with a required Idempotency-Key:

{ "op": "move", "task_ids": ["…"], "status_id": "…" }
op Extra fields Effect
archive - Archives the listed tasks.
unarchive - Restores them.
move status_id Moves every task to that column.
assign assignee_id (nullable) Reassigns, or unassigns with null.
priority priority Sets priority on every task.
due_date due_date (YYYY-MM-DD, nullable) Sets or clears the due date.
labels add (uuid[]), remove (uuid[]) Adds and removes labels, unlike the single-task PATCH which replaces the whole set.

There is no delete op: bulk archiving 200 tasks in one unrecoverable call is not a door this API opens. archive is the reversible equivalent.

The response reports what happened per task:

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

An id that this credential cannot reach, or that names another project's column, lands in failed rather than failing the whole call.

Description fields

description on a task, and on a calendar event, accepts Markdown. If the value looks like Markdown rather than HTML, it is converted to the rich-text HTML the editor stores and renders: headings, paragraphs, bullet and numbered lists, blockquotes, fenced and inline code, bold, italic, strikethrough, links and rules. Anything already shaped like HTML is left untouched, and anything outside that subset is kept as literal text rather than dropped.

Projects and labels

Method Path Scope
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

Projects are read-only here: you can list and fetch them, but you create and archive them inside the app. GET /projects/{id} returns the board columns inline, which is where status_id values come from.

Labels are per project, not shared workspace-wide: a label from one project cannot be applied to a task in another. POST /projects/{id}/labels takes name (1 to 40 chars) and an optional color (#RRGGBB, defaults to #6E56CF); a name already used in that project answers 409 conflict.

Channels, people and activity

Method Path Scope
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

Calendar

Every route here acts on the credential's own calendar: a user token's human, or a workspace key's minting user, exactly the actor POST /tasks already attributes writes to. There is no way to read or write someone else's calendar through this API, with one narrow exception noted under free slots below.

Method Path Scope
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

Events

GET /calendar/events?from&to returns every occurrence in the window (instances of a recurring series included, with any per-occurrence edit already applied), capped at 62 days. It is not cursor-paginated: the window cap bounds the response by construction, so pagination would only cost every caller a second round trip to learn there was no second page.

{
  "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} returns the MASTER record when occurrence_date is omitted (its own content plus the recurrence rule, null for a one-off), or one occurrence with any override applied when occurrence_date (YYYY-MM-DD, from the window read) is given.

Creating and updating a series shares one body shape:

Field Type Notes
title string, 1 to 300 chars Required on create.
description string, up to 10000 chars, nullable Markdown-aware, see above.
location string, up to 300 chars, nullable
links { label, url }[], up to 20
type enum Defaults to MEETING.
timezone IANA string Required. Anchors the event and, for a series, the rule's local weekdays and time of day.
all_day boolean Decides which bound pair is required, see below.
start_date / end_date YYYY-MM-DD Required together when all_day: true, forbidden otherwise.
start_at / end_at ISO instant with offset Required together when all_day: false (the default), forbidden otherwise.
recurrence object, optional Present makes it a series; absent is a one-off.

recurrence, when sent, is the whole rule, never a per-field patch:

Field Type Notes
rule DAILY \ WEEKLY \
interval integer, 1 to 52 Every N periods. Defaults to 1. YEARLY caps at 10, not 52.
by_weekday integer[] (1=Mon..7=Sun) Required, non-empty, for WEEKLY.
by_month_day integer (1..31 or -1) Required for MONTHLY (-1 means last day). Optional for YEARLY: overrides the day within starts_on's month; omit to use starts_on's own day (its month always applies, there is no separate month field).
time_of_day HH:mm Required.
starts_on YYYY-MM-DD Required. For YEARLY, also the anchor month/day the rule repeats on.
ends_on YYYY-MM-DD, nullable Open-ended when omitted.

PATCH /calendar/events/{id} takes scope (this | following | all, default all) and occurrence_date as query parameters, not body fields. scope: this writes a per-occurrence exception and cannot touch the recurrence rule at all; following/all replace it wholesale when recurrence is sent.

One PATCH rule surprises people: touching the bounds means declaring the whole pair. {"all_day": true} on its own is refused, because the schema cannot see the stored row to know which bounds go with it. Send all_day together with start_date/end_date to make an event all-day, or with start_at/end_at to make it timed, and never mix the two pairs.

DELETE /calendar/events/{id} cancels the event (or, with scope, one occurrence or everything from a point forward). It never erases the row: the one destructive route on this whole surface is a soft cancel, not a delete.

RSVP, invitees and reminders

POST /calendar/events/{id}/rsvp is always for the caller: there is no way to respond on someone else's behalf.

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

status is one of ACCEPTED, DECLINED, TENTATIVE (NEEDS_ACTION is the default an invite starts at, never a response you choose). Omit occurrence_date to set the response for the whole series; pass it to answer for just that one occurrence.

PUT /calendar/events/{id}/invitees replaces the attendee roster, organizer only, up to 200 ids:

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

It takes the same scope (this | following | all, default all) and occurrence_date query parameters PATCH /calendar/events/{id} does. scope: this changes the roster for one occurrence only, without touching the series roster; following splits the series at occurrence_date and gives the new series this roster; all replaces the whole series roster.

GET on the same path returns { items: [{ user_id, role, rsvp_status, responded_at }] } for organizer or any invitee. role is ORGANIZER or ATTENDEE. Pass occurrence_date as a query parameter to get the effective roster for one occurrence instead of the whole series.

PUT /calendar/events/{id}/reminders replaces the caller's own reminder rules for that event whole (organizer or invitee, any RSVP status), up to 10 rules; duplicate offsets collapse to the last one sent:

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

channel is BELL, PUSH or BOTH.

All three (rsvp, invitees, reminders) answer the same three failure reasons when the event is unreachable, out of scope for the action, or the occurrence_date doesn't resolve: NOT_FOUND, INVALID_SCOPE, NO_OCCURRENCE.

Free slots

GET /calendar/free-slots is the one route on this surface that reads other people's calendars, and it reads nothing off them but busy/free intervals in a time range:

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

attendees (repeated attendees[]= query keys, user_ids from GET /workspace/members or GET /people) defaults to [the caller] when omitted. The window may not exceed 14 days; duration_minutes is 5 to 480; attendees caps at 20.

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

An attendee whose calendar visibility hides them from colleagues, or an id that is not a member of this workspace, is never silently read as free: it is named in excluded (HIDDEN or NOT_A_MEMBER) instead. A slot is only offered when every readable attendee is free; if every requested attendee ends up excluded, the response has no slots at all rather than a falsely-confident full window.

Booking schedules

POST /calendar/booking-schedules publishes a bookable event type on the caller's own calendar, a Cal.com-style public booking page:

Field Type Notes
slug string, 3 to 40 lowercase/digits/hyphens Unique in the workspace. The page's own path segment.
title string, 1 to 200 chars
description string, up to 5000 chars, nullable
duration_minutes integer, 5 to 480
buffer_before_minutes / buffer_after_minutes integer, 0 to 480 Default 0.
min_notice_minutes integer, 0 to 43200 Default 60.
max_advance_days integer, 1 to 365 Default 30.
daily_cap integer, 1 to 100, nullable Max bookings per day, unset by default.
availability { weekday, start, end }[], up to 50 weekday 1=Mon..7=Sun; start/end are HH:mm local to timezone.
timezone IANA string
event_type enum Defaults to MEETING.
questions { key, label, type, required }[], up to 20 type is text or textarea.
active boolean Default true.

GET /calendar/booking-schedules lists the caller's own; GET /calendar/booking-schedules/{id}/bookings lists the bookings made against one. Creating a schedule and the questions on it are the only public write; guests book through a separate, sessionless public page that this API does not expose.

Reminders

Every route here acts on the credential's own reminders, and rides the calendar scopes above rather than a new one: a reminder belongs to one person and has no audience, the same "your own time" surface as the calendar.

Method Path Scope
GET /reminders swarm:calendar:read
POST /reminders swarm:calendar:write

GET /reminders returns the caller's own pending reminders, soonest first. A reminder that already fired is not here; it was delivered to the bell instead. Each row carries note (the caller's own words, nullable), ref (the EER it points at, e.g. eel:swarm:task:{id}, nullable) and about (the target's resolved text, null when the caller may not read it).

POST /reminders takes note, ref (an EER, e.g. eel:swarm:task:{id}), or both, and rejects neither. Pass exactly one of at (an ISO instant) or preset (in20m, in1h, in3h, tomorrow, nextWeek), resolved in the caller's own profile timezone. at must be in the future.

What changes with the credential

This is where it matters most which one you authenticate with. The shape of the response is the same; what changes is which rows come back.

API key Person's token
Public channels Yes Yes
Private channels Never That person's
Workspace-visible projects Yes Yes
Private projects Never That person's
Direct messages Never Their own, with swarm:dms:read
Calendar The minting user's The token holder's own

No scope changes the left column. A key has no person, and "my private channels" means nothing to a workspace, and neither does "my calendar" beyond the identity the key was minted for.

swarm:dms:read is the one scope in the suite that only takes effect with a person's token. With an API key it changes nothing.

Keep in mind that membership is what defines "yours", not the scope. The scope says what you may do; membership says what to. Neither substitutes for the other.

What you will not see

A private channel you are not in answers 404, exactly like one that does not exist. Same for a project, and same for someone else's calendar event.

A 403 would confirm the channel exists, and from the id you can usually work out whose it is. Telling the two apart would leak exactly what "private" exists to protect.

Being a Swarm admin does not change this. There is no admin lever to read a private channel you are not a member of, or another person's calendar, through the API or through the interface.