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.
Requirements
Section titled “Requirements”Creating a project needs three things. The server enforces all three; the wizard mirrors them.
| Requirement | Error when missing |
|---|---|
| A paid plan (Pro or higher) on the organization | UPGRADE_REQUIRED |
| An app group to attach to | APP_GROUP_REQUIRED |
| An active database connection on that app group | PM_DATASOURCE_REQUIRED |
The database is not optional, because there is nowhere else for the work data to go.
Projects
Section titled “Projects”GET /api/org/{slug}/projectsPOST /api/org/{slug}/projectsGET /api/org/{slug}/projects/{id}PUT /api/org/{slug}/projects/{id}POST /api/org/{slug}/projects/{id}/statusPUT /api/org/{slug}/projects/{id}/app-group { appGroupId }DELETE /api/org/{slug}/projects/{id}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.
Archiving
Section titled “Archiving”POST /api/org/{slug}/projects/{id}/archivePOST /api/org/{slug}/projects/{id}/restoreGET /api/org/{slug}/projects/archivedGET /api/org/{slug}/projects/{id}/archive/downloadArchiving 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.
Deleting
Section titled “Deleting”DELETE /api/org/{slug}/projects/{id}DELETE /api/org/{slug}/projects/{id}?purge=trueOne door for make this project go away, with two strengths. Both need the org.project.create
scope — whoever may start a project may clear one away — and both soft-delete the Axowl row, so
the project’s own history survives either way. The destructive strength additionally requires a
personal seal, described below: the scope says who may ask, the seal says who is actually here.
Without purge the call discards an empty shell only: a project still in Planning that
has no stories and no epics. Anything else is refused with 409 PROJECT_NOT_EMPTY_SHELL, which
reports what it found:
{ "error": "PROJECT_NOT_EMPTY_SHELL", "detail": "This project already has work in it.", "stories": 12, "epics": 3}With ?purge=true the work data goes too, from any status. Because that cannot be undone, this
branch — and only this branch — takes a body with two proofs:
{ "confirmName": "Payment rewrite", "assertionResponseJson": "{…}"}confirmName must equal the project’s name exactly (400 NAME_MISMATCH otherwise), and
assertionResponseJson is a personal seal — a passkey assertion bound to this project and the
purge action, so a seal taken for one project cannot delete another. Without a valid one the call
answers 401 SEAL_REQUIRED. This is the same ceremony as deleting a data source for good, and the
name is checked before the seal so that someone who picked the wrong project is not asked for a
fingerprint on the way to cancelling.
The empty-shell branch takes no body and no seal: there is no work to lose.
The server then deletes the project’s rows from the axowl_pm_* tables in your database —
seven tables, children first, in one transaction, because those tables carry no foreign keys and a
half-deleted project would leave orphans behind. The response says what it removed:
{ "discarded": true, "purged": true, "purgedRows": 412, "purgedFiles": 7 }Two things it deliberately does not do:
- It fails closed. If your database cannot be reached, nothing is deleted at all
(
409 PM_DATASOURCE_UNREACHABLE, or409 PM_DATASOURCE_REQUIREDwhen the app group has no active connection). Removing our row while yours survives would leave stories with no owner in your database forever. - It does not erase attachment files. The file-ledger rows are soft-deleted and the stored objects are left to the retention worker — the same rule as deleting a file anywhere else in Axowl. Deletion goes as far as the recycle bin, not past it.
An archived project is refused (409 PROJECT_ARCHIVED): its snapshot and archive record are
cleaned up in a different order, so restore it first and then delete. And nothing here happens on a
timer — Axowl never deletes a project you simply left sitting.
Roster
Section titled “Roster”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}/invitesRoles 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.
Stories and epics
Section titled “Stories and epics”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 # createPATCH /stories/{storyId} # partial updatePOST /stories/{storyId}/state # move between board columnsPUT /stories/{storyId}/owners # replace the assignee setPOST /stories/{storyId}/archivePOST /stories/reorder # backlog priority
POST /stories/{storyId}/tasks # sub-taskPOST /stories/{storyId}/tasks/{taskId}/toggleDELETE /stories/{storyId}/tasks/{taskId}
GET /stories/{storyId}/attachments?withData=truePOST /stories/{storyId}/attachmentsDELETE /stories/{storyId}/attachments/{attachmentId}
GET /stories/{storyId}/links # pull requests, commits, branchesPOST /stories/{storyId}/linksDELETE /stories/{storyId}/links/{linkId}
POST /comments { storyId | epicId, body }
POST /epicsPATCH /epics/{epicId}POST /epics/{epicId}/archiveGET /epics/{epicId}/burndown # raw points; draw the curve yourself
GET /assignees # active agents eligible for assignmentA 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.
Access
Section titled “Access”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.
When the database is not reachable
Section titled “When the database is not reachable”These routes talk to your database, so they can fail in ways ours cannot:
| Code | HTTP | Meaning |
|---|---|---|
PM_DATASOURCE_REQUIRED | 409 | The project has no app group, or the group has no active database |
PM_SCHEMA_FAILED | 409 | The axowl_pm_* tables could not be created — usually the connected account lacks DDL rights |
PM_DATASOURCE_UNREACHABLE | 503 | The database refused the connection or timed out |
PM_NOT_ASSIGNED | 403 | You 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.
Roadmap
Section titled “Roadmap”GET /api/org/{slug}/projects/roadmapOne call for the whole organization: every project you can see, each with its epics already
counted. It exists so that a roadmap view does not call GET /work once per project — epics are
read per app group, one round trip to each connected database rather than one per project.
{ "projects": [ { "id": "3d1a…", "name": "Payment rewrite", "status": "Active", "startDate": "2026-09-01", "endDate": "2026-11-30", "error": null, "epics": [ { "id": "8b77…", "number": 4, "name": "Checkout", "state": "InProgress", "ownerConnectedId": "5c02…", "ownerName": "Yuna Kim", "startDate": "2026-09-01", "targetDate": "2026-10-15", "storyCount": 18, "doneCount": 11, "progress": 61 } ] } ]}progress is doneCount / storyCount rounded to a whole percent, computed server-side so the view
can draw a bar without pulling story bodies.
Archived projects are left out. Scoped guests see only the projects they were invited to, decided
by the same visibility rule as GET /projects — the roadmap is not a way around it.
A database that cannot be read fails per project, not per call: that project comes back with an
empty epics array and a reason in error — PM_DATASOURCE_REQUIRED, PM_SCHEMA_FAILED or
PM_DATASOURCE_UNREACHABLE — while the other projects still return theirs. Reading needs
organization membership; there is no separate scope.
Events
Section titled “Events”Project lifecycle is audited: creation, updates, status changes, roster assignment and
removal, outside invitations, and archiving (org.project.created … org.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.
Project Management AI (add-on)
Section titled “Project Management AI (add-on)”addon.pm_ai connects an app group to Project Management AI — the AI plans Epics from the
big picture a human describes, generates the todo list, splits out tasks, and works them through.
It is priced per connected app group ($199/mo each, first month prorated), and it gates only
the AI planning/execution layer: the manual boards, epics and backlog above stay on the plan axis.
Connecting happens on the app group detail page (the Project Management button), because the
connection has the same prerequisites as hiring an agent: the app group needs an active
database (where axowl_pm_* lives) and a Git repository (what the AI reads and changes).
Console endpoints (cookie-authenticated, owner-gated where money moves):
| Route | Purpose |
|---|---|
GET /api/org/{slug}/app-groups/{groupId}/pm-ai | Connection state, prerequisites, price, org-wide connected count |
POST /api/org/{slug}/settings/addons/addon.pm_ai?appGroupId={id} | Connect (debits prepaid credit; 409 PM_PREREQUISITES_MISSING with set-up paths when the database or repository is missing) |
POST /api/org/{slug}/app-groups/{groupId}/pm-ai/disable | Disconnect — this month’s charge stands, next month’s bill drops the group |
Connecting and disconnecting are audited (appgroup.pm_ai.connected / .disconnected) with the
connected-group count and the amount charged — the two facts an auditor asks about first.
AI project drafting
Section titled “AI project drafting”When an app group is connected, the wizard can plan the project for you:
| Route | Purpose |
|---|---|
POST /api/org/{slug}/projects/ai/draft | Summary + reference images (uploads and/or Figma frames) → proposed epics, stories with sub-tasks, and a roadmap layout. Returns 402 PM_AI_REQUIRED with a connectPath when the app group is not connected. |
The draft is a proposal only — nothing is stored until the human confirms, and the wizard then
materializes the plan through the ordinary POST /projects, /epics and /stories routes above,
so every gate on those routes (including PM_DATASOURCE_REQUIRED) applies unchanged. A Figma
personal access token passed to the draft call is used for that one import and never stored.
Documents
Section titled “Documents”A project has a file box of its own — GET/POST /api/org/{slug}/projects/{projectId}/docs,
GET …/docs/{fileId}/download for a short-lived link, and DELETE …/docs/{fileId} for the trash. Files land
in the project scope of the organization’s file box (the same storage, quota and ledger as every other
upload), and the gate is project assignment rather than the organization-wide file permission: reading needs
to be able to read the project, writing needs to be an active project member.
Assigned agents read these documents through axowl_pm_files / axowl_pm_file and cannot write or delete
them. Keeping specs and decisions here rather than in the code repository matters for one practical reason:
deploying a repository publishes what is in it.
Agents working the board
Section titled “Agents working the board”An assigned agent works its own stories through the MCP tool surface (axowl_pm_*, see
MCP). Assignment is what grants the access: a person puts an agent’s badge on a
story with PUT /stories/{id}/owners, and from that moment the agent can read and move that story
— and only that story.
Two things happen when the board changes:
| Change | Effect |
|---|---|
| A story gains an AI owner | agent.wake fires with reason story_assigned for the newly added badges |
| An assigned story moves state | agent.wake fires with reason story_state_changed for its AI owners, except whoever made the move |
A person presses Start on a To do story (POST /stories/{id}/start) | The story moves to InProgress and agent.wake fires with story_start. Refused with 409 PM_AGENT_BUSY if that agent already has a story in progress, testing or deploying |
A person decides a story in Testing (POST /stories/{id}/review) | pass → Deploying (story_passed), revise / redo → InProgress (story_revise / story_redo), hold → Backlog (story_hold). A note is required except for pass; 409 PM_NOT_IN_TEST if the story is not in InReview |
The merge for a story in Deploying goes through | The story moves to Done and the agent’s next ToDo story opens. If the merge is refused, fails or is rejected, it goes back to InReview |
The webhook carries no story title or state — it is a doorbell, not a telegram. The agent reads
what changed through its own authenticated tools, and axowl_context lists its queue at the start
of every session. Nothing here emits a domain event: day-to-day story movement stays in
axowl_pm_activity, as it always has.
Finishing is deliberately not automatic. When an agent’s last story merges and reaches Done, its
story list tells it to file a completion approval; a person stamps it and archives the project. That keeps the one
irreversible step — the archive snapshot — on a human.
Stories can be assigned at creation time — POST /stories accepts owners, and the AI wizard uses
that to hand its whole plan to one agent in a single pass. Assigning that way wakes the agent just
as PUT /owners does; otherwise the work would sit assigned but unannounced.