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 }
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.

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.

DELETE /api/org/{slug}/projects/{id}
DELETE /api/org/{slug}/projects/{id}?purge=true

One 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, or 409 PM_DATASOURCE_REQUIRED when 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.

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.

GET /api/org/{slug}/projects/roadmap

One 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.

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.

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):

RoutePurpose
GET /api/org/{slug}/app-groups/{groupId}/pm-aiConnection 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/disableDisconnect — 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.

When an app group is connected, the wizard can plan the project for you:

RoutePurpose
POST /api/org/{slug}/projects/ai/draftSummary + 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.

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.

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:

ChangeEffect
A story gains an AI owneragent.wake fires with reason story_assigned for the newly added badges
An assigned story moves stateagent.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 throughThe 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.