Skip to content

Projects

A project is a unit of work inside an organization. It is not an organization of its own: it carries a timeline, an assigned roster, and a workspace of stories and epics.

The part worth reading twice: story data is not stored in Axowl’s database. A project is attached to an app group, and the work tables (axowl_pm_*) are created in that app group’s connected database — your external Postgres, or a Strix database we provisioned for you. Axowl keeps only the roster and the gates. If you disconnect from us, your stories stay where they are.

Creating a project needs three things. The server enforces all three; the wizard mirrors them.

RequirementError when missing
A paid plan (Pro or higher) on the organizationUPGRADE_REQUIRED
An app group to attach toAPP_GROUP_REQUIRED
An active database connection on that app groupPM_DATASOURCE_REQUIRED

The database is not optional, because there is nowhere else for the work data to go.

GET /api/org/{slug}/projects
POST /api/org/{slug}/projects
GET /api/org/{slug}/projects/{id}
PUT /api/org/{slug}/projects/{id}
POST /api/org/{slug}/projects/{id}/status
PUT /api/org/{slug}/projects/{id}/app-group { appGroupId }

Create takes name, optional description, startDate/endDate, and a required appGroupId:

{
"name": "Payment rewrite",
"description": "Move checkout off the legacy gateway",
"startDate": "2026-09-01",
"endDate": "2026-11-30",
"appGroupId": "9f2c…"
}

Reads are gated on organization membership. Writes to the project itself (rename, reschedule, status) need the org.project.update scope.

A new project starts Active — creating it is the act of starting it. From there the statuses are OnHold, Completed, Cancelled, and Planning if you want to put it back on the drawing board. Archived is not reachable through /status; it has its own route, below.

app-group attaches an app group to a project that has none — projects created before the attachment requirement landed have an empty appGroupId and cannot store stories until this is called. It refuses (APP_GROUP_ALREADY_ATTACHED) rather than re-pointing a project that already has one, because the existing work data lives in that group’s database.

POST /api/org/{slug}/projects/{id}/archive
POST /api/org/{slug}/projects/{id}/restore
GET /api/org/{slug}/projects/archived
GET /api/org/{slug}/projects/{id}/archive/download

Archiving takes the project out of the active list without deleting anything. Before the status changes, the server reads the whole workspace out of your database — stories, epics, sub-tasks, owners, activity, attachments and links, including rows already archived individually — and writes it as a single JSON snapshot to Axowl storage. GET /projects stops returning the project; GET /projects/archived returns it with the snapshot metadata.

{
"archived": true,
"previousStatus": "Active",
"snapshotRows": 412,
"snapshotStored": true,
"snapshotError": null
}

If the snapshot cannot be written — your database is unreachable, or the project never had one — the project is still archived and snapshotStored is false with a reason in snapshotError. The original rows are untouched either way; the snapshot is a spare copy, not the only one.

restore puts the project back in the status it held before archiving (previousStatus), not blindly into Active. It also replays the snapshot into your database, inserting only rows that are missing (ON CONFLICT DO NOTHING), so a project whose tables were dropped in the meantime comes back with its work intact and one whose data never moved gets reimportedRows: 0.

archive/download streams the raw snapshot JSON. Storage is private, so the download goes through the API with your session — there is no public URL. Both routes need org.project.update.

POST /api/org/{slug}/projects/{id}/members { connectedId, role }
DELETE /api/org/{slug}/projects/{id}/members/{connectedId}
POST /api/org/{slug}/projects/{id}/invite { email, role }
GET /api/org/{slug}/projects/{id}/invites

Roles are Manager, Contributor, Guest, Observer. Anyone with a badge in the organization can be assigned — including teams (a group ConnectedId assigns everyone in it) and AI agents from the app group’s roster.

invite brings in someone from outside the organization, with role either Freelancer or Guest. It rides the normal invitation machinery (same email, expiry, seat accounting), so an invited person accepts at /invite/{code} exactly as an ordinary member would.

A project guest is confined to that project. The invitation carries a scope variable, which compiles into org.project.read:project=<id> on the guest’s badge. Reads of any other project — through the UI or the API — are refused, and a guest cannot write at all.

All routes below are under /api/org/{slug}/projects/{projectId}.

GET /work # stories + epics in one call (board)
GET /activity?storyId=&epicId= # comments and history
POST /stories # create
PATCH /stories/{storyId} # partial update
POST /stories/{storyId}/state # move between board columns
PUT /stories/{storyId}/owners # replace the assignee set
POST /stories/{storyId}/archive
POST /stories/reorder # backlog priority
POST /stories/{storyId}/tasks # sub-task
POST /stories/{storyId}/tasks/{taskId}/toggle
DELETE /stories/{storyId}/tasks/{taskId}
GET /stories/{storyId}/attachments?withData=true
POST /stories/{storyId}/attachments
DELETE /stories/{storyId}/attachments/{attachmentId}
GET /stories/{storyId}/links # pull requests, commits, branches
POST /stories/{storyId}/links
DELETE /stories/{storyId}/links/{linkId}
POST /comments { storyId | epicId, body }
POST /epics
PATCH /epics/{epicId}
POST /epics/{epicId}/archive
GET /epics/{epicId}/burndown # raw points; draw the curve yourself
GET /assignees # active agents eligible for assignment

A story has a type (Feature, Bug, Chore), a state (Backlog, ToDo, InProgress, InReview, Done), multiple owners, an optional epic, an estimate, a due date, sub-tasks, attachments and links. Archiving hides an item everywhere; it does not delete the row.

Reading needs organization membership and, for scoped guests, the matching project. Writing needs an active ProjectMember record on that project — Observer and Guest are refused. This is enforced server-side, so calling the API directly does not widen anything.

These routes talk to your database, so they can fail in ways ours cannot:

CodeHTTPMeaning
PM_DATASOURCE_REQUIRED409The project has no app group, or the group has no active database
PM_SCHEMA_FAILED409The axowl_pm_* tables could not be created — usually the connected account lacks DDL rights
PM_DATASOURCE_UNREACHABLE503The database refused the connection or timed out
PM_NOT_ASSIGNED403You are not an assigned member of this project (or you are an observer/guest)

PM_DATASOURCE_REQUIRED is the one people misread as a permission error. It is not: it says the project has nowhere to put the work. Attach an app group with a live database (PUT /app-group) and the same call succeeds.

The work tables are created on first use and the DDL is idempotent, so nothing breaks if you reconnect the same database later.

Project lifecycle is audited: creation, updates, status changes, roster assignment and removal, outside invitations, and archiving (org.project.createdorg.project.invite_sent, org.project.archived, org.project.restored).

Archiving and restoring are separate events rather than status changes, because they carry a fact a status change does not: where the snapshot went and how many rows it holds — which is what answers “can this still be brought back?” later.

Day-to-day story movement is deliberately not an event stream — dragging a card or ticking a sub-task would flood the audit log. That history lives in the project’s own axowl_pm_activity table and is served by GET /activity.