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.