DocsAI and automation

REST API

The HTTP contract between apps/api (Rust) and apps/web (Next.js). Change this file first, then both apps, in the same commit.

Conventions

  • JSON in and out, snake_case fields, integer IDs, RFC 3339 UTC timestamps, YYYY-MM-DD dates.

  • All endpoints except /health live under /api. The web app proxies /api/* to the API (Next.js rewrite), so the browser only ever talks to its own origin.

  • Success returns the resource with 200/201, or 204 with no body.

  • Errors return a non-2xx status and:

    json
    { "error": { "code": "not_found", "message": "Issue not found" } }
    StatuscodeMeaning
    400bad_requestMalformed or invalid input (message says which field)
    401unauthorizedNot signed in, session expired, or bad credentials
    403forbiddenSigned in but not allowed (e.g. not an admin)
    403email_not_verifiedSign-in before verifying the email address
    404not_foundMissing, or hidden because you aren't a member
    409conflictDuplicate (email, slug, team key, label name), or a rule like "can't delete the last team"
    429rate_limitedToo many attempts; retry later
    500internalUnexpected server error

Authentication

  • Sessions are opaque random tokens; the server stores only their SHA-256 hash.
  • Sign-in and verify set the cookie pv_session (HttpOnly, SameSite=Lax, Path=/, Secure when COOKIE_SECURE=true, Max-Age = session lifetime, default 30 days).
  • Every authenticated request may instead send Authorization: Bearer <token> (used by tests and API clients). The token is never returned in a response body.
  • Sign-in, verify and resend-code are rate limited per email and per IP.

Roles

owner (exactly one per workspace) > admin > member > guest. "Admin" below means owner or admin. Guests can read and edit issues in teams they belong to, but cannot manage workspace settings, members, teams or labels.

Types

ts
type User = {
  id: number;
  email: string;
  full_name: string | null;
  username: string | null;
  avatar_color: string | null;   // "#c92a62"; null = derive from id on the client
  verified: boolean;
  preferences: Preferences;
  created_at: string;
};

// Unknown keys are rejected. All fields optional on PATCH; defaults shown.
type Preferences = {
  theme?: "light" | "dark" | "system";        // "light"
  accent?: "rose" | "graphite" | "cobalt" | "coral"; // "rose"
  issue_layout?: "list" | "board";            // "list"
  notifications?: {
    [event in "assigned" | "mentioned" | "comments" | "status" | "project_updates"]?:
      { inbox: boolean; email: boolean };
  };
  email_digest?: "off" | "daily" | "weekly";  // "daily"
  desktop_notifications?: boolean;            // false
};

type UserSummary = { id: number; full_name: string | null; username: string | null; email: string; avatar_color: string | null };

type Session = { id: number; user_agent: string | null; created_at: string; last_seen_at: string; current: boolean };

type Role = "owner" | "admin" | "member" | "guest";

type Workspace = {
  id: number; name: string; slug: string; color: string | null;
  owner_id: number; role: Role;            // the caller's role
  created_at: string; updated_at: string;
};

type Member = { user: UserSummary; role: Role; joined_at: string };

type Invitation = { id: number; email: string; role: Exclude<Role, "owner">; invited_by: UserSummary; created_at: string; expires_at: string };

type InvitationPreview = { workspace_name: string; workspace_slug: string; email: string; role: Role; invited_by_name: string; expired: boolean; accepted: boolean };

type Team = {
  id: number; key: string;                // "WEB", 2–5 uppercase letters, immutable
  name: string; color: string;
  member_ids: number[]; issue_count: number;
  // Milestone 3: cycle settings
  cycles_enabled: boolean;
  cycle_duration_weeks: number;   // 1–8
  cycle_start_weekday: number;    // ISO weekday, 1 = Monday … 7 = Sunday
  cycle_upcoming: number;         // 0–6 future cycles kept ready
  cycle_auto_rollover: boolean;
  created_at: string; updated_at: string;
};

type Label = { id: number; name: string; color: string; issue_count: number; created_at: string };

type IssueStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "canceled";
// UI names: Backlog, Todo, In Progress, Requires QA (in_review), Done, Canceled.
type IssuePriority = 0 | 1 | 2 | 3 | 4;  // No priority, Urgent, High, Medium, Low

type Issue = {
  id: number;
  identifier: string;        // "WEB-12"
  number: number;
  team_id: number;
  title: string;
  description: string;       // plain text / lightweight markdown, "" when empty
  status: IssueStatus;
  priority: IssuePriority;
  estimate: number | null;
  due_date: string | null;   // "2026-10-09"
  assignee_id: number | null;
  creator_id: number;
  parent_id: number | null;
  parent_identifier: string | null; // "WEB-3", for linking to the parent
  project_id: number | null;
  milestone_id: number | null;   // a milestone of `project_id`
  cycle_id: number | null;       // a cycle of the issue's team
  state_id: number;              // the team workflow state; `status` is its category
  blocked: boolean;              // has an open (not done/canceled) issue blocking it
  external_ref: string | null;   // "linear:ENG-12" for imported issues
  label_ids: number[];
  sub_issue_count: number;
  completed_at: string | null;
  created_at: string;
  updated_at: string;
};

type Comment = { id: number; issue_id: number; author: UserSummary; body: string; edited_at: string | null; created_at: string };

// kind: created | title | description | status | priority | assignee | estimate | due_date | labels | parent | team | project | milestone
// data per kind: {from, to} for scalar changes; {added: number[], removed: number[]} for labels; {} for created/description.
type Activity = { id: number; actor: UserSummary | null; kind: string; data: Record<string, unknown>; created_at: string };

Endpoints

{ws} is a workspace slug, {team} a team key, {issue} an identifier like WEB-12 (case-insensitive). Non-members get 404 for anything inside a workspace.

Health

MethodPathAuthResponse
GET/healthno200 { "status": "ok" }

Auth

MethodPathBodyResponse
POST/api/auth/signup{ full_name?, email, password }201 { message }; emails a 6-digit code
POST/api/auth/verify{ email, code }200 User + session cookie (signs you in)
POST/api/auth/resend-code{ email }200 { message } (same answer whether or not the account exists)
POST/api/auth/signin{ email, password }200 User + session cookie
POST/api/auth/signout204, revokes the current session and clears the cookie
POST/api/auth/signout-others204, revokes every other session of the user

Passwords: 8–128 characters. Codes expire after 10 minutes; 5 wrong codes invalidate the code.

Current user

MethodPathBodyResponse
GET/api/users/me200 User
PATCH/api/users/me{ full_name?, username?, avatar_color?, preferences? }200 User. preferences is merged into the stored object.
POST/api/users/me/password{ current_password, new_password }204; also signs out other sessions
GET/api/users/me/sessions200 Session[]

username: 2–30 chars of a-z 0-9 . _ -, unique (409). avatar_color: #rrggbb or null.

Workspaces

MethodPathBodyWhoResponse
GET/api/workspacesany200 Workspace[] (yours, oldest first)
POST/api/workspaces{ name, slug, color? }any201 Workspace. You become owner; a default team (key from the name) and labels Bug, Feature, Improvement are created.
GET/api/workspaces/{ws}member200 Workspace
PATCH/api/workspaces/{ws}{ name?, slug?, color? }admin200 Workspace
DELETE/api/workspaces/{ws}owner204
POST/api/workspaces/{ws}/leavenon-owner member204

slug: 3–50 chars, a-z 0-9 -, not starting/ending with -, globally unique (409). Slugs that clash with web app routes (login, signup, verify, invite, new, api, settings, forgot-password, reset-password, …) are rejected with 400.

Members

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/membersmember200 Member[]
PATCH/api/workspaces/{ws}/members/{user_id}{ role }admin200 Member. Can't change the owner or make someone owner (403).
DELETE/api/workspaces/{ws}/members/{user_id}admin204. Can't remove the owner. Their issues in this workspace become unassigned; they leave all teams.

Invitations

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/invitationsadmin200 Invitation[] (pending only)
POST/api/workspaces/{ws}/invitations{ emails: string[], role }admin201 Invitation[] for the new ones. Existing members and already-invited emails are skipped. Each invitee gets an email with {APP_URL}/invite/{token}. Max 50 emails.
POST/api/workspaces/{ws}/invitations/{id}/resendadmin204 (new token, new expiry)
DELETE/api/workspaces/{ws}/invitations/{id}admin204
GET/api/invitations/{token}none200 InvitationPreview
POST/api/invitations/{token}/acceptsigned in, email must match200 Workspace

Invitations expire after 7 days.

Teams

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/teamsmember200 Team[]
POST/api/workspaces/{ws}/teams{ name, key, color, member_ids? }admin201 Team (creator always included)
PATCH/api/workspaces/{ws}/teams/{team}{ name?, color?, member_ids?, cycles_enabled?, cycle_duration_weeks?, cycle_start_weekday?, cycle_upcoming?, cycle_auto_rollover? }admin200 Team
DELETE/api/workspaces/{ws}/teams/{team}?move_to={key}admin204. move_to is required when the team has issues; issues keep their identifiers. Deleting the last team is 409.

member_ids must all be workspace members (400 otherwise).

Labels

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/labelsmember200 Label[] (by name)
POST/api/workspaces/{ws}/labels{ name, color }admin201 Label
PATCH/api/workspaces/{ws}/labels/{id}{ name?, color? }admin200 Label
DELETE/api/workspaces/{ws}/labels/{id}admin204 (removed from all issues)

Names are unique per workspace, case-insensitive (409). Colors are #rrggbb.

Issues

MethodPathBodyResponse
GET/api/workspaces/{ws}/issuesquery below200 Issue[]
POST/api/workspaces/{ws}/issues{ team_key, title, description?, status?, priority?, estimate?, due_date?, assignee_id?, parent_id?, label_ids?, project_id?, milestone_id? }201 Issue
GET/api/workspaces/{ws}/issues/{issue}200 Issue
PATCH/api/workspaces/{ws}/issues/{issue}any subset of { title, description, status, priority, estimate, due_date, assignee_id, parent_id, label_ids, team_key, project_id, milestone_id }; nullable fields accept null to clear200 Issue
GET/api/workspaces/{ws}/issues/{issue}/subscription200 { subscribed: boolean }
PUT/api/workspaces/{ws}/issues/{issue}/subscription204 subscribe
DELETE/api/workspaces/{ws}/issues/{issue}/subscription204 unsubscribe
DELETE/api/workspaces/{ws}/issues/{issue}204 (soft delete; creator or admin)
POST/api/workspaces/{ws}/issues/{issue}/restore200 Issue (undo a delete; creator or admin)
GET/api/workspaces/{ws}/issues/{issue}/activity200 Activity[] (oldest first)
GET/api/workspaces/{ws}/issues/{issue}/comments200 Comment[] (oldest first)
POST/api/workspaces/{ws}/issues/{issue}/comments{ body }201 Comment
PATCH/api/workspaces/{ws}/comments/{id}{ body }200 Comment (author only)
DELETE/api/workspaces/{ws}/comments/{id}204 (author or admin)

List query parameters (all optional, combinable; comma-separated values mean "any of"):

ParamExampleMeaning
teamWEBTeam key
statustodo,in_progress
priority1,2
assignee12,none or menone = unassigned, me = caller
creatorme or 12
label3,7Has any of these labels
parentWEB-12Sub-issues of an issue
project4,noneProject ids; none = no project
milestone9,noneMilestone ids
subscribedmeIssues the caller is subscribed to
cycle12, current, noneCycle ids; current = the current cycle of each team in the result
qhero imageCase-insensitive match on identifier, title and description
sortnewest (default), oldest, priority, updated, title, relevancerelevance needs q
limit100 (default), max 500Page size
offset0 (default)Rows to skip

Paging: list responses stay a JSON array; the response headers X-Total-Count (matches before paging) and X-Next-Offset (absent on the last page) drive "load more". q uses full-text search (stemmed English over identifier, title and description) plus typo-tolerant trigram matching on the title, so hreo image still finds "Hero image…".

Rules:

  • team_key must be a team in the workspace; the caller must be a member of that team unless they're an admin.
  • assignee_id must be a workspace member. parent_id must be an issue in the same workspace and not create a cycle. label_ids must be workspace labels.
  • Setting status to done sets completed_at; moving out of done clears it.
  • Changing team_key keeps the identifier.
  • Every change records Activity. Several fields in one PATCH record one entry per field.
  • project_id must be a project in the workspace. milestone_id must belong to the issue's project (after the update); changing or clearing project_id clears a milestone that no longer fits.
  • Comment and description bodies may @mention members by username (@alex). Mentioned members are subscribed and notified.

Projects and inbox

Types

ts
type ProjectStatus = "backlog" | "planned" | "in_progress" | "paused" | "completed" | "canceled";
type Health = "on_track" | "at_risk" | "off_track";

type Project = {
  id: number;
  name: string;
  summary: string;           // one line, "" when empty
  description: string;       // lightweight markdown, "" when empty
  color: string;
  status: ProjectStatus;
  priority: IssuePriority;
  lead_id: number | null;
  member_ids: number[];
  team_ids: number[];
  start_date: string | null;
  target_date: string | null;
  health: Health | null;     // from the latest update; null = no updates yet
  progress: { scope: number; started: number; completed: number; percent: number };
  issue_count: number;
  milestone_count: number;
  initiative_ids: number[];
  creator_id: number;
  completed_at: string | null;
  created_at: string;
  updated_at: string;
};

type Milestone = {
  id: number; project_id: number; name: string; target_date: string | null; sort_order: number;
  progress: { scope: number; completed: number; percent: number };
};

type ProjectLink = { id: number; project_id: number; title: string; url: string; created_at: string };

type ProjectUpdate = {
  id: number; project_id: number; author: UserSummary; health: Health; body: string;
  edited_at: string | null; created_at: string;
};

// Burn-up data for the project overview chart and its breakdown tabs.
type ProjectProgress = {
  unit: "points" | "issues";  // "points" when any issue has an estimate, else issue counts
  scope: number; started: number; completed: number;
  // One point per day from the first issue (or start_date) to today, oldest first.
  series: { date: string; scope: number; started: number; completed: number }[];
  by_assignee: { user_id: number | null; total: number; completed: number }[];
  by_label: { label_id: number; total: number; completed: number }[];
  by_milestone: { milestone_id: number | null; total: number; completed: number }[];
};

type NotificationKind = "assigned" | "mentioned" | "comment" | "status" | "project_update";

type Notification = {
  id: number;
  kind: NotificationKind;
  actor: UserSummary | null;
  issue: { id: number; identifier: string; title: string; status: IssueStatus } | null;
  project: { id: number; name: string } | null;
  // assigned: {}; mentioned/comment: { comment_id?, excerpt }; status: { from, to };
  // project_update: { update_id, health, excerpt }
  data: Record<string, unknown>;
  read_at: string | null;
  created_at: string;
};

Progress counting: "scope" is every non-deleted, non-canceled issue in the project; "started" is in_progress + in_review; "completed" is done. With unit = "points", issues without an estimate count as 1 point. percent = completed / scope × 100, rounded (0 when scope is 0).

Projects

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/projectsquery: status, health (none allowed), lead, team, q, sort (updated default, name, target_date, created)member200 Project[]
POST/api/workspaces/{ws}/projects{ name, summary?, description?, color?, status?, priority?, lead_id?, member_ids?, team_ids?, start_date?, target_date? }member (not guest)201 Project
GET/api/workspaces/{ws}/projects/{id}member200 Project
PATCH/api/workspaces/{ws}/projects/{id}any subset of the create fields; nullable ones accept nullmember (not guest)200 Project
DELETE/api/workspaces/{ws}/projects/{id}creator, lead or admin204 (soft delete; its issues keep project_id but it's hidden)
POST/api/workspaces/{ws}/projects/{id}/restorecreator, lead or admin200 Project
GET/api/workspaces/{ws}/projects/{id}/progressmember200 ProjectProgress

Rules: status = completed sets completed_at. lead_id and member_ids must be workspace members; team_ids workspace teams. Guests only see projects that include one of their teams. Deleted projects: issues pointing at them are treated as "no project" in lists and filters.

MethodPathBodyResponse
GET/api/workspaces/{ws}/projects/{id}/milestones200 Milestone[] (by sort_order, then target date)
POST/api/workspaces/{ws}/projects/{id}/milestones{ name, target_date?, sort_order? }201 Milestone
GET/api/workspaces/{ws}/milestones/{id}200 Milestone
PATCH/api/workspaces/{ws}/milestones/{id}{ name?, target_date?, sort_order? }200 Milestone
DELETE/api/workspaces/{ws}/milestones/{id}204 (issues lose the milestone)
GET/api/workspaces/{ws}/projects/{id}/links200 ProjectLink[]
POST/api/workspaces/{ws}/projects/{id}/links{ title, url } (http(s):// only)201 ProjectLink
DELETE/api/workspaces/{ws}/links/{id}204
GET/api/workspaces/{ws}/projects/{id}/updates200 ProjectUpdate[] (newest first)
POST/api/workspaces/{ws}/projects/{id}/updates{ health, body }201 ProjectUpdate; notifies the lead and members
PATCH/api/workspaces/{ws}/updates/{id}{ health?, body? }200 ProjectUpdate (author only)
DELETE/api/workspaces/{ws}/updates/{id}204 (author or admin)

Members (not guests) may manage milestones, links and updates.

Notifications (inbox)

MethodPathBodyResponse
GET/api/workspaces/{ws}/notificationsquery: unread=true, limit (default 50, max 200), before (notification id, for paging)200 Notification[] (newest first, archived excluded)
GET/api/workspaces/{ws}/notifications/unread-count200 { count: number }
POST/api/workspaces/{ws}/notifications/{id}/read204
POST/api/workspaces/{ws}/notifications/{id}/unread204
POST/api/workspaces/{ws}/notifications/read-all204
DELETE/api/workspaces/{ws}/notifications/{id}204 (archive)

Who gets notified (never the person who made the change, and only if their preferences.notifications[event].inbox isn't false; matching email preference also sends an email):

EventRecipientskindPreference key
Issue assigned to someonethe new assigneeassignedassigned
@username in a comment or descriptionthe mentioned membermentionedmentioned
New commentthe issue's subscribers (minus those already notified as mentioned)commentcomments
Status changethe issue's subscribersstatusstatus
Project update postedthe project's lead and membersproject_updateproject_updates

Live events

MethodPathResponse
GET/api/workspaces/{ws}/eventstext/event-stream (Server-Sent Events)

Each event is event: <type> + data: <json>. Types and payloads:

  • issue — { "action": "created" | "updated" | "deleted" | "restored", "identifier": "WEB-12", "id": 1, "project_id": 4 | null }
  • comment — { "action": "created" | "updated" | "deleted", "issue_identifier": "WEB-12" }
  • project — { "action": "created" | "updated" | "deleted" | "restored", "id": 4 } (also sent for milestone, link and update changes, with the project's id)
  • notification — { "unread_count": 3 } (only to the recipient)
  • workspace — { "action": "updated" } for teams, labels and member changes

Clients use these to invalidate cached queries; payloads are hints, not full objects. A comment line : ping is sent every 25 seconds to keep proxies from closing the stream.

Planning, personal organization, account recovery

Types

ts
type CycleState = "past" | "current" | "upcoming";

type Cycle = {
  id: number; team_id: number; number: number;
  name: string | null;               // display "Cycle {number}" when null
  starts_on: string; ends_on: string; // inclusive dates
  state: CycleState;
  completed_at: string | null;       // set when it ended and unfinished issues rolled over
  progress: { scope: number; started: number; completed: number; percent: number };
  issue_count: number;
};

type CycleProgress = ProjectProgress;  // same shape; series runs starts_on → min(today, ends_on)

type InitiativeStatus = "planned" | "active" | "completed";

type Initiative = {
  id: number; name: string; description: string; color: string;
  status: InitiativeStatus;
  owner_id: number | null;
  target_date: string | null;
  project_ids: number[];
  // Worst health among its projects' latest updates (off_track > at_risk > on_track); null if none.
  health: Health | null;
  progress: { scope: number; started: number; completed: number; percent: number }; // sum over projects
  creator_id: number; created_at: string; updated_at: string;
};

type ViewKind = "issues" | "projects";

type SavedView = {
  id: number; name: string; description: string; color: string;
  kind: ViewKind;
  filters: Record<string, string>;   // the list endpoint's query parameters, e.g. {"status": "todo,in_progress", "assignee": "me"}
  display: { layout?: "list" | "board" | "grid"; group_by?: string; order_by?: string };
  shared: boolean;
  owner: UserSummary;
  created_at: string; updated_at: string;
};

type FavoriteKind = "issue" | "project" | "view" | "cycle" | "initiative" | "team" | "label";

type Favorite = {
  id: number; kind: FavoriteKind; target_id: number;
  folder_id: number | null; sort_order: number;
  // Enough to render the sidebar entry without another request.
  target: { title: string; identifier?: string; status?: IssueStatus; color?: string; team_key?: string };
  created_at: string;
};

type FavoriteFolder = { id: number; name: string; sort_order: number };

type Draft = {
  id: number; title: string; description: string;
  // Create-issue fields other than title/description: team_key, status, priority, estimate,
  // due_date, assignee_id, label_ids, project_id, milestone_id, cycle_id, parent_id.
  properties: Record<string, unknown>;
  created_at: string; updated_at: string;
};

Cycles

Cycles exist only for teams with cycles_enabled. The server keeps them generated: whenever a team's cycles are read (or the team's cycle settings change) it ensures the current cycle and cycle_upcoming future cycles exist, numbered consecutively, each cycle_duration_weeks long, starting on cycle_start_weekday. When a cycle has ended and cycle_auto_rollover is on, issues in it that aren't done or canceled move to the next cycle (recorded as cycle activity with a null actor) and the cycle gets completed_at. This happens at most once per cycle, safely under concurrent requests. Turning cycles off keeps existing cycles (read-only history) and stops generating new ones.

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/teams/{team}/cyclesquery: state=past,current,upcomingmember200 Cycle[] (by starts_on, newest first)
GET/api/workspaces/{ws}/cycles/{id}member200 Cycle
PATCH/api/workspaces/{ws}/cycles/{id}{ name? } (null clears)member (not guest)200 Cycle
GET/api/workspaces/{ws}/cycles/{id}/progressmember200 CycleProgress

Issues: cycle_id on create/patch must be a non-past cycle of the issue's team (400 otherwise); moving an issue to another team clears its cycle. Activity kind cycle with {from, to} ids. Activity actor may be null for automatic changes (rollover).

Initiatives

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/initiativesquery: status, qmember200 Initiative[]
POST/api/workspaces/{ws}/initiatives{ name, description?, color?, status?, owner_id?, target_date?, project_ids? }member (not guest)201 Initiative
GET/api/workspaces/{ws}/initiatives/{id}member200 Initiative
PATCH/api/workspaces/{ws}/initiatives/{id}any subset; project_ids replaces the setmember (not guest)200 Initiative
DELETE/api/workspaces/{ws}/initiatives/{id}creator, owner or admin204 (soft)
POST/api/workspaces/{ws}/initiatives/{id}/restorecreator, owner or admin200 Initiative

Projects: initiative_ids appears on Project; PATCH project { initiative_ids } replaces the set. Project list filter initiative=<id>. Guests see initiatives but only the projects they can see.

Saved views

MethodPathBodyResponse
GET/api/workspaces/{ws}/viewsquery: kind200 SavedView[] (yours + shared, by name)
POST/api/workspaces/{ws}/views{ name, description?, color?, kind?, filters?, display?, shared? }201 SavedView
GET/api/workspaces/{ws}/views/{id}200 SavedView (owner, or anyone if shared)
PATCH/api/workspaces/{ws}/views/{id}any subset200 SavedView (owner; admins may edit shared views)
DELETE/api/workspaces/{ws}/views/{id}204 (owner, or admin for shared)

filters keys must be valid list parameters for the view's kind (issues: team, status, priority, assignee, creator, label, project, milestone, cycle, subscribed, q; projects: status, health, lead, team, initiative, q), values strings (400 otherwise). Guests can't create shared views.

Favorites

MethodPathBodyResponse
GET/api/workspaces/{ws}/favorites200 { folders: FavoriteFolder[], favorites: Favorite[] } (yours; by sort_order)
POST/api/workspaces/{ws}/favorites{ kind, target_id, folder_id? }201 Favorite (409 if already favorited)
PATCH/api/workspaces/{ws}/favorites/{id}{ folder_id?, sort_order? }200 Favorite
DELETE/api/workspaces/{ws}/favorites/{id}204
POST/api/workspaces/{ws}/favorite-folders{ name }201 FavoriteFolder
PATCH/api/workspaces/{ws}/favorite-folders/{id}{ name?, sort_order? }200 FavoriteFolder
DELETE/api/workspaces/{ws}/favorite-folders/{id}204 (its favorites move out of the folder)

New favorites go to the end (sort_order = max + 1). Targets must exist and be visible to the caller (404 otherwise). Favorites whose target was later deleted or hidden are omitted from the list. Clients reorder by sending a sort_order between the neighbors' values.

Drafts

MethodPathBodyResponse
GET/api/workspaces/{ws}/drafts200 Draft[] (yours, newest first)
POST/api/workspaces/{ws}/drafts{ title?, description?, properties? }201 Draft
PATCH/api/workspaces/{ws}/drafts/{id}any subset (properties replaces)200 Draft
DELETE/api/workspaces/{ws}/drafts/{id}204

Drafts are private. Max 100 per user per workspace (409 beyond).

Account recovery

MethodPathBodyAuthResponse
POST/api/auth/forgot-password{ email }none200 { message } (same answer whether or not the account exists). Emails a link {APP_URL}/reset-password?token=… valid for 1 hour. Rate limited.
POST/api/auth/reset-password{ token, password }none200 User + session cookie. Single use; signs out every other session. Also marks the email verified.
POST/api/users/me/email{ new_email, current_password }yes200 { message }; emails a 6-digit code to the new address (10 min, 5 attempts). 409 if taken.
POST/api/users/me/email/verify{ code }yes200 User with the new email

Live events (additions)

  • cycle — { "action": "created" | "updated" | "rolled_over", "id": 3, "team_id": 1 }
  • initiative — { "action": "created" | "updated" | "deleted" | "restored", "id": 2 }
  • view — { "action": "created" | "updated" | "deleted", "id": 5 } (shared views only go to everyone; private ones only to the owner)
  • Favorites and drafts are personal: their changes are sent only to their owner as favorite / draft with { "action": ... }.

Search, attachments, hardening

MethodPathResponse
GET/api/workspaces/{ws}/search?q=…&types=issues,projects,initiatives,comments&limit=8200 SearchResults
ts
type SearchResults = {
  issues: { id: number; identifier: string; title: string; status: IssueStatus; team_id: number; highlight: string | null }[];
  projects: { id: number; name: string; status: ProjectStatus; color: string; highlight: string | null }[];
  initiatives: { id: number; name: string; status: InitiativeStatus; color: string; highlight: string | null }[];
  comments: { id: number; issue_identifier: string; issue_title: string; author: UserSummary; highlight: string }[];
};
  • q is required (1–200 chars). types defaults to all four; limit is per type (default 8, max 25).
  • An exact identifier (web-151) ranks first. Then full-text rank (websearch_to_tsquery, so quotes and -word work), then trigram word_similarity on titles/names for typos.
  • highlight is a short excerpt with matches wrapped in « and » (no HTML), or null when the match was only on the title/name.
  • Respects visibility exactly like the list endpoints (guests, deleted items).

Attachments

ts
type Attachment = {
  id: number; issue_id: number; filename: string; content_type: string; size_bytes: number;
  is_image: boolean;
  url: string;              // "/api/attachments/12/content": stable across workspace renames; usable in <img src> and markdown
  uploader: UserSummary | null;
  created_at: string;
};
MethodPathBodyResponse
GET/api/workspaces/{ws}/issues/{issue}/attachments200 Attachment[] (oldest first)
POST/api/workspaces/{ws}/issues/{issue}/attachmentsmultipart/form-data with one file part201 Attachment; activity attachment {added: filename}
GET/api/attachments/{id}/contentthe bytes (see below); same access rules as the issue. /api/workspaces/{ws}/attachments/{id}/content also works.
DELETE/api/workspaces/{ws}/attachments/{id}204 (uploader or admin); activity attachment {removed: filename}
  • Max 25 MB per file (413 with code payload_too_large above). Filenames are sanitized (path separators and control characters stripped). Guests can attach only to issues they can see.
  • Content is served with the stored Content-Type, X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox, Cache-Control: private, max-age=31536000, immutable, and Content-Disposition: inline for images (png, jpeg, gif, webp, avif) and PDF, attachment otherwise. SVG is always served as an attachment (it can carry scripts).
  • Storage backend is configured with STORAGE_DIR (local disk; default ./data/uploads, created on start). Keys are random; the bytes' SHA-256 is recorded.
  • Deleting an issue (soft) keeps its attachments; they're removed when the issue is purged.
  • Live events: issue updated for the issue.

Hardening

  • API responses carry X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, X-Frame-Options: DENY. JSON request bodies are limited to 1 MB (413), uploads to 25 MB.
  • The web app sets a Content-Security-Policy (self + inline styles needed by the UI; images from self, data: and blob:), X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin and Permissions-Policy disabling camera, microphone and geolocation.

Onboarding, imports, relations, workflow states, Google sign-in

Workflow states

ts
type StateCategory = IssueStatus; // "backlog" | "todo" | "in_progress" | "in_review" | "done" | "canceled"
type TeamState = { id: number; team_id: number; name: string; color: string; category: StateCategory; position: number; issue_count: number };

Every team has its own ordered states. Each belongs to a category, and an issue's status is always its state's category (the database enforces it), so everything that works by status (filters, progress, cycle rollover, completed_at) keeps working. New teams get the six defaults: Backlog, Todo, In Progress, Requires QA, Done, Canceled.

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/teams/{team}/statesmember200 TeamState[] (by category order, then position)
POST/api/workspaces/{ws}/teams/{team}/states{ name, color, category, position? }admin201 TeamState
PATCH/api/workspaces/{ws}/teams/{team}/states/{id}{ name?, color?, category?, position? }admin200 TeamState
DELETE/api/workspaces/{ws}/teams/{team}/states/{id}?move_to={state_id}admin204; move_to required when it has issues

Rules: names unique per team (case-insensitive, 409); a team always keeps at least one state in done, one in canceled, and one in backlog or todo (409 otherwise, also when a PATCH would change the last one's category). A team's default state for new issues is its first backlog state, else its first todo state.

Issues: create/PATCH accept state_id (must be a state of the issue's team) or status (a category: picks that team's first state of the category); state_id wins when both are sent. Moving an issue to another team keeps the category: it lands in the target team's first state of the same category. Status activity entries gain from_state / to_state ids: { from, to, from_state, to_state }. List filter state=12,14 (state ids). Workspace and issue Team responses include nothing new; fetch states per team.

Issue relations

ts
type RelationKind = "blocks" | "blocked_by" | "duplicate_of" | "duplicated_by" | "related";
type IssueRelation = {
  id: number; kind: RelationKind;            // from the point of view of the issue you asked about
  issue: { id: number; identifier: string; title: string; status: IssueStatus; state_id: number; team_id: number };
  created_at: string;
};
MethodPathBodyResponse
GET/api/workspaces/{ws}/issues/{issue}/relations200 IssueRelation[]
POST/api/workspaces/{ws}/issues/{issue}/relations{ kind: "blocks" | "blocked_by" | "duplicate_of" | "related", identifier }201 IssueRelation (409 if it exists)
DELETE/api/workspaces/{ws}/relations/{id}204
  • Both issues must be in the workspace and visible to the caller; an issue can't relate to itself.
  • duplicate_of also moves the issue to its team's first canceled state.
  • Activity kind relation on both issues: { added | removed: kind, identifier } (kind from that issue's point of view). Live issue events for both.
  • Issue.blocked is true when some blocks relation points at it from an issue that isn't done/canceled. List filter blocked=true|false.

Onboarding

ts
type OnboardingStepKey = "profile" | "create_issue" | "invite_teammate" | "create_project" | "use_command_menu" | "enable_cycles";
type Onboarding = { dismissed: boolean; steps: { key: OnboardingStepKey; done: boolean }[] };
MethodPathBodyResponse
GET/api/workspaces/{ws}/onboarding200 Onboarding (the caller's)
POST/api/workspaces/{ws}/onboarding/steps/{key}200 Onboarding (marks a step done; only use_command_menu and profile can be marked by hand)
POST/api/workspaces/{ws}/onboarding/dismiss200 Onboarding
POST/api/workspaces/{ws}/onboarding/restore200 Onboarding (show the checklist again)

Derived steps (from data, for the caller in this workspace): profile = username set and avatar color chosen; create_issue = the caller created an issue; invite_teammate = the workspace has another member or a pending invitation; create_project = any project exists; enable_cycles = any team has cycles on. use_command_menu is marked by the client.

Workspace templates. POST /api/workspaces accepts template: "empty" | "example" (default "empty"). "example" fills the new workspace with a realistic demo (teams, states, labels, ~20 issues with relations, projects with milestones and updates, cycles with history, an initiative) authored by and assigned to the creator only — no fake users.

Welcome email. Sent once per account, right after the first successful email verification or the first Google sign-up, with links to the app and a short getting-started list.

Imports

ts
type ImportSource = "csv" | "linear" | "jira" | "github";
type ImportField = "title" | "description" | "status" | "priority" | "assignee" | "labels" | "estimate" | "due_date" | "external_id" | "created_at";
type ImportPreview = {
  source: ImportSource;
  columns: string[];                               // CSV header
  mapping: Partial<Record<ImportField, string>>;  // suggested column per field
  status_values: { value: string; suggested_state_id: number | null; count: number }[];
  assignee_values: { value: string; suggested_user_id: number | null; count: number }[];
  sample: Record<string, string>[];                // first 5 rows
  total_rows: number;
};
type ImportRun = { id: number; source: ImportSource; team_id: number | null; total: number; created: number; skipped: number; errors: { row: number; message: string }[]; created_at: string; finished_at: string | null };
MethodPathBodyWhoResponse
POST/api/workspaces/{ws}/imports/previewmultipart: file (CSV ≤ 10 MB) + source (csv/linear/jira) + team_keyadmin200 ImportPreview
POST/api/workspaces/{ws}/importsmultipart: file, source, team_key, options (JSON: { mapping, status_map: {value: state_id}, assignee_map: {value: user_id|null}, create_labels: boolean })admin201 ImportRun
POST/api/workspaces/{ws}/imports/githubJSON { repo: "owner/name", team_key, include_closed?: boolean, token?: string }admin201 ImportRun
GET/api/workspaces/{ws}/importsadmin200 ImportRun[] (newest first)
  • Up to 5,000 rows per import, processed in one transaction; each issue gets a new identifier in the chosen team. Rows whose external_id (Linear/Jira key, GitHub number) was already imported are skipped. Unknown statuses fall back to the team's default state; unknown assignees to unassigned; unknown labels are created when create_labels is true.
  • Linear and Jira CSV exports are recognized by their headers (Linear: ID, Title, Description, Status, Priority, Assignee, Labels, Estimate, Due Date, Created; Jira: Issue key, Summary, Description, Status, Priority, Assignee, Labels, Created, Due Date). Their priority words map to ours (Urgent/Highest → 1, High → 2, Medium → 3, Low/Lowest → 4).
  • GitHub: open (and optionally closed) issues of a repository via the GitHub REST API (pull requests excluded); public repositories work without a token (GitHub's anonymous rate limit applies), a personal token is used for that request only and never stored. Closed issues land in the default done state; GitHub labels map by name.
  • Imported issues: external_ref is set, the creator is the importing user, and description gets a footer line Imported from Linear ENG-12 (or Jira/GitHub with a link).

Google sign-in

MethodPathResponse
GET/api/auth/google/start?next=/acme/my-issues302 to Google (OpenID Connect, authorization code + PKCE, scopes openid email profile)
GET/api/auth/google/callback302 to {APP_URL}{next} (default /) with the session cookie; on failure 302 to {APP_URL}/login?error=google
GET/api/auth/providers200 { google: boolean } (whether Google sign-in is configured)
  • Configured with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. The redirect URI registered in Google Cloud must be {APP_URL}/api/auth/google/callback.
  • Only Google accounts with a verified email are accepted. An existing ProjectVerse account with the same email is linked (and marked verified); otherwise one is created (no password; "forgot password" can set one). state is single-use, stored hashed, valid for 10 minutes; next must be a same-site path starting with /.
  • Signing in with a password on a Google-only account returns 401 with message Sign in with Google, or reset your password to add one.

Storage

STORAGE_BACKEND=local (default, uses STORAGE_DIR) or s3 for any S3-compatible store (Cloudflare R2, AWS S3, MinIO): S3_ENDPOINT, S3_BUCKET, S3_REGION (auto for R2), S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY. Objects are private; the API streams them to the client with the same headers and permission checks as before. Attachment URLs don't change.

Live events (additions)

  • team_state — { "action": "created" | "updated" | "deleted", "team_id": 1, "id": 7 }
  • import — { "action": "finished", "id": 3 } (admins of the workspace)
  • onboarding — { "action": "updated" } (only to that member)

Integrations, account security, audit log, operations

Secrets the server reads back are encrypted at rest with AES-256-GCM under APP_ENCRYPTION_KEY (32 random bytes, base64; required when any feature below stores a secret). Secret values are write-only in the API: responses show at most a hint (e.g. "https://hooks.slack.com/…/•••").

Personal API tokens

API tokens are described under "Agent access" below. They are refused (403) on the account-security endpoints of this milestone: password, email, 2FA, sessions, sign-out, account deletion and ownership transfer.

Outgoing webhooks

ts
type WebhookEvent = "issue.created" | "issue.updated" | "issue.deleted" | "comment.created" | "comment.updated" | "comment.deleted" | "project.created" | "project.updated" | "project.deleted" | "project_update.created" | "cycle.updated";
type Webhook = { id: number; url: string; description: string; events: WebhookEvent[]; enabled: boolean; consecutive_failures: number; created_at: string; updated_at: string };
type WebhookDelivery = { id: number; event: WebhookEvent; attempts: number; response_status: number | null; error: string | null; delivered_at: string | null; next_attempt_at: string | null; created_at: string };
MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/webhooksadmin200 Webhook[]
POST/api/workspaces/{ws}/webhooks{ url, description?, events? }admin201 Webhook & { secret: string } (shown once)
PATCH/api/workspaces/{ws}/webhooks/{id}{ url?, description?, events?, enabled? }admin200 Webhook
POST/api/workspaces/{ws}/webhooks/{id}/rotate-secretadmin200 { secret }
DELETE/api/workspaces/{ws}/webhooks/{id}admin204
GET/api/workspaces/{ws}/webhooks/{id}/deliveries?before=&limit=admin200 WebhookDelivery[] (newest first; request/response bodies omitted)
POST/api/workspaces/{ws}/webhooks/{id}/testadmin202 queues a ping delivery
POST/api/workspaces/{ws}/webhook-deliveries/{id}/redeliveradmin202

Delivery: POST JSON { "id": <delivery id>, "event": "issue.updated", "created_at": "…", "workspace": { "id", "slug" }, "data": { … the resource as the API returns it … } } with headers User-Agent: ProjectVerse-Webhooks/1, X-ProjectVerse-Event, X-ProjectVerse-Delivery, X-ProjectVerse-Signature: sha256=<hex HMAC-SHA256 of the raw body with the secret>. A 2xx within 10 s is success; otherwise retried with backoff (1 m, 5 m, 30 m, 2 h, 6 h; then given up). After 20 consecutive failed deliveries the webhook is disabled. Webhook and Slack URLs must not resolve to private, loopback or link-local addresses (checked at creation and again at delivery) unless ALLOW_PRIVATE_WEBHOOK_URLS=true (development and tests only). events: [] means all events.

Integrations: Slack and GitHub

ts
type Integration =
  | { id: number; kind: "slack"; team_id: number | null; enabled: boolean; settings: { events: ("issue.created" | "issue.completed" | "comment.created" | "project_update.created")[]; channel_name?: string }; secret_hint: string; created_at: string }
  | { id: number; kind: "github"; team_id: null; enabled: boolean; settings: { repo?: string; on_open: StateCategory | null; on_merge: StateCategory | null }; webhook_url: string; created_at: string };
type IssueLink = { id: number; kind: "github_pr"; url: string; title: string; state: "open" | "draft" | "merged" | "closed"; external_id: string; author: string | null; updated_at: string };
MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/integrationsadmin200 Integration[]
POST/api/workspaces/{ws}/integrations/slack{ webhook_url, team_key?, events, channel_name? }admin201 Integration (sends a "Connected" test message; 400 if Slack rejects it)
POST/api/workspaces/{ws}/integrations/github{ repo?, on_open?, on_merge? }admin201 Integration & { secret: string } (shown once; paste into GitHub with webhook_url, content type application/json, events "Pull requests")
PATCH/api/workspaces/{ws}/integrations/{id}settings fields, enabledadmin200 Integration
DELETE/api/workspaces/{ws}/integrations/{id}admin204
POST/api/integrations/github/{id}/webhookGitHub's payloadGitHub (X-Hub-Signature-256)204; 401 bad signature
GET/api/workspaces/{ws}/issues/{issue}/linksmember200 IssueLink[]
  • Slack: an incoming-webhook URL (https://hooks.slack.com/services/…); messages use Block Kit with the issue identifier, title, state, assignee and a link to {APP_URL}; scoped to one team or the whole workspace.
  • GitHub: on pull_request events (opened, edited, reopened, ready_for_review, converted_to_draft, closed), identifiers like WEB-12 found in the PR title, body or head branch name (case-insensitive, e.g. kemo/web-12-fix-hero) link the PR to those issues in this workspace (issue_links, upsert by owner/repo#number). on_open (default in_progress) moves linked issues in an earlier category to the team's first state of that category; on_merge (default done) does the same when merged. null disables either. Changes are recorded as status activity with a null actor and via: "github" in data. ping events return 204. If settings.repo is set, events from other repositories are ignored.

Two-factor authentication (TOTP)

MethodPathBodyResponse
POST/api/users/me/2fa/setup{ password } (Google-only accounts: {} )200 { secret, otpauth_url } (base32 secret; not active yet)
POST/api/users/me/2fa/enable{ code }200 { recovery_codes: string[] } (10 codes xxxx-xxxx, shown once); User.two_factor_enabled becomes true
POST/api/users/me/2fa/disable{ code } (TOTP or recovery code)204
POST/api/users/me/2fa/recovery-codes{ code }200 { recovery_codes } (replaces the old ones)
POST/api/auth/2fa{ code } or { recovery_code }200 User + session cookie
  • TOTP: RFC 6238, SHA-1, 6 digits, 30 s steps, ±1 step accepted, each step usable once.
  • With 2FA on, POST /api/auth/signin (correct password) and the Google callback don't create a session: sign-in returns 200 { "mfa_required": true } and sets an HttpOnly pv_mfa cookie (Path /api/auth/2fa, 5 minutes); the Google callback sets the same cookie and redirects to {APP_URL}/login/2fa?next=…. POST /api/auth/2fa reads that cookie; 5 wrong codes void the challenge. Rate limited like sign-in.
  • User gains two_factor_enabled: boolean and has_password: boolean.

Account deletion and ownership transfer

MethodPathBodyResponse
POST/api/workspaces/{ws}/transfer-ownership{ user_id, password? } (owner; password required if the account has one)200 Workspace; the old owner becomes admin
DELETE/api/users/me{ password?, code?, confirm: "DELETE" } (password if the account has one; code if 2FA is on)204, signs out everywhere

Deleting an account: refused with 409 (message lists the workspaces) while the user owns a workspace that has other members — transfer ownership or delete it first. Workspaces they own alone are deleted. Then memberships, sessions, tokens, subscriptions, drafts, favorites, notifications and 2FA data are removed, and the user row is anonymized (email → deleted-{id}@deleted.invalid, names cleared, deleted_at set) so issues and comments keep a "Deleted user" author. Deleted accounts can't sign in; the email can be used to sign up again.

Audit log

ts
type AuditEvent = { id: number; action: string; actor: UserSummary | null; target_type: string | null; target_id: string | null; data: Record<string, unknown>; ip: string | null; user_agent: string | null; created_at: string };
MethodPathWhoResponse
GET/api/workspaces/{ws}/audit-log?action=member.&actor=12&before=<id>&limit=50admin200 AuditEvent[] (newest first; action is a prefix filter)
GET/api/users/me/security-events?before=&limit=self200 AuditEvent[]

Workspace actions recorded: workspace.updated, workspace.ownership_transferred, member.role_changed, member.removed, member.left, member.joined, invitation.created, invitation.revoked, team.created|updated|deleted, team_state.created|updated|deleted, label.created|updated|deleted, webhook.created|updated|deleted|secret_rotated, integration.created|updated|deleted, import.completed. Account actions (workspace null): user.signed_in, user.signin_failed, user.password_changed, user.password_reset, user.email_changed, user.2fa_enabled, user.2fa_disabled, user.recovery_codes_regenerated, user.sessions_revoked. Kept 365 days.

Email digest

A background job sends preferences.email_digest (daily at 08:00 UTC, weekly on Mondays at 08:00 UTC; default daily): unread, unarchived notifications since the last digest, grouped by workspace, with links; nothing is sent when there are none. Safe with several API instances (advisory lock). last_digest_at records the last run per user. Each email has a link to the notification settings.

Operations

  • SENTRY_DSN (API) and NEXT_PUBLIC_SENTRY_DSN (web) turn on error reporting; off when unset. No request bodies, cookies or authorization headers are sent.
  • GET /health stays a cheap liveness check; GET /health/ready checks the database and the storage backend (200 / 503).
  • Backups: projectverse-api backup (a subcommand of the API binary / cargo run --bin backup) streams pg_dump --format=custom into the storage backend under backups/YYYY/MM/DD/<timestamp>.dump and deletes backups older than BACKUP_RETENTION_DAYS (default 30). docs/operations.md covers restore.

Live events (additions)

  • webhook — { "action": "created" | "updated" | "deleted", "id": 3 } (admins)
  • integration — { "action": "created" | "updated" | "deleted", "id": 2 } (admins)
  • issue events are also sent when GitHub links or moves an issue.

Agent access: API tokens and MCP

API tokens

ts
type ApiToken = {
  id: number; name: string;
  prefix: string;               // "pv_AbCd", the start of the secret
  created_at: string; last_used_at: string | null;
  expires_at: string | null;    // null = never
};
MethodPathBodyResponse
GET/api/workspaces/{ws}/api-tokens200 ApiToken[] (yours in this workspace, newest first, expired included)
POST/api/workspaces/{ws}/api-tokens{ name, expires_in_days? } (1–365, or null/omitted for no expiry)201 ApiToken & { secret: string }. The secret is shown only here. Max 25 per user per workspace (409).
DELETE/api/workspaces/{ws}/api-tokens/{id}204 (revoke; yours only, 404 otherwise)
  • Send the secret as Authorization: Bearer pv_.... Only its SHA-256 is stored.
  • A token acts as its owner, with their role, in its workspace only: other workspaces answer 404, and GET /api/workspaces lists just that one.
  • Tokens get 403 on account-level routes: everything under /api/auth, /api/users and /api/invitations, creating, deleting and leaving workspaces, and managing API tokens (these need a signed-in session).
  • Leaving or being removed from the workspace deletes the member's tokens there.

MCP

POST /api/mcp: a Model Context Protocol server (streamable HTTP, stateless JSON responses; protocol versions 2025-11-25, 2025-06-18, 2025-03-26) for AI agents. Authenticate with an API token (a session works too). GET answers 405; a request whose Origin isn't one of CORS_ORIGINS answers 403.

Tools (all take an optional workspace slug, defaulting to the token's workspace):

ToolDoes
get_workspace_contextTeams (keys), labels, members, statuses, priorities, and you
list_issuesGET issues with team, status[], assignee (me/none/@username/email), labels[] (names), project (name/id/none), milestone (name within project, id, or none), cycle, parent, query, sort, limit (≤ 100), offset
get_issueOne issue with description, sub-issues, comments (include_comments, default true), activity (include_activity) and linked error groups (errors)
create_issueteam, title, and optionally description, status, priority (none/urgent/high/medium/low), assignee, labels[], project, milestone (name or id within project), parent, estimate, due_date, cycle (current/next/id)
update_issueAny of the create fields plus add_labels[], remove_labels[], team; none (or null for estimate/due_date) clears. milestone resolves in project if given, else the issue's project
add_commentidentifier, body
searchGET search (query, types[], limit)
list_projectsstatus[], query
list_cyclesteam (default: every team with cycles), state[]
list_errorsError groups: service (name), status[], query, sort, limit
get_errorOne error group with its latest occurrence (stack as text lines, request, environment, release, tags) and events recent ones
update_errorstatus, create_issue: true, or link_issue (identifier or none)

Results name people ("Alex Rivera (@alex)"), labels and projects, and link to the web app ({APP_URL}/{ws}/issue/{identifier}). Failures are tool results with isError: true and a readable message (for example the list of valid labels), not JSON-RPC errors.

Error tracking: services, error groups and automatic issues

Running services (backends, frontends, workers) push their failures to ProjectVerse. Each event is fingerprinted into an error group; a group can open an issue automatically, reopen it when the error comes back, and comment when it keeps happening.

Types

ts
type AutoIssue = "off" | "new" | "threshold";

type Service = {
  id: number;
  name: string;                 // unique per workspace (case-insensitive), 1–60 chars
  key_prefix: string;           // "pvi_AbCd", the start of the ingest key
  team_id: number | null;       // team that gets automatic issues; null = no automatic issues
  auto_issue: AutoIssue;        // "new": as soon as a group without an issue gets an event; "threshold": see below
  threshold_count: number;      // 1–100: open an issue once a group has this many events…
  threshold_minutes: number;    // 1–1440: …within this many minutes
  label_ids: number[];          // labels added to automatic issues
  assignee_id: number | null;   // assignee of automatic issues
  creator_id: number;           // automatic issues are created in this member's name
  open_group_count: number;     // unresolved groups
  last_event_at: string | null;
  created_at: string; updated_at: string;
};

type ErrorKind = "exception" | "http" | "log";
type ErrorLevel = "fatal" | "error" | "warning";
type ErrorGroupStatus = "unresolved" | "resolved" | "ignored";

type ErrorGroup = {
  id: number;
  service_id: number; service_name: string;
  kind: ErrorKind; level: ErrorLevel; status: ErrorGroupStatus;
  title: string;                // "TypeError: Cannot read properties of undefined (reading 'total')", "500 on POST /api/checkout"
  culprit: string | null;       // "POST /api/checkout", "computeTotal (src/cart.ts)"
  event_count: number;
  events_24h: number;
  first_seen: string; last_seen: string;
  resolved_at: string | null;
  issue: { id: number; identifier: string; title: string; status: IssueStatus } | null;
};

type ErrorGroupDetail = ErrorGroup & {
  histogram: number[];          // events per hour over the last 24 h, oldest first (24 numbers)
  latest_event: ErrorEvent | null;
};

type StackFrame = { function: string | null; file: string | null; line: number | null; column: number | null; in_app: boolean };

type ErrorEvent = {
  id: number; group_id: number;
  occurred_at: string; received_at: string;
  environment: string | null;   // "production"
  release: string | null;       // "web@1.4.2", a git sha…
  message: string;
  exception: { type: string; value: string | null; frames: StackFrame[]; stack: string | null } | null; // frames innermost first
  request: { method: string | null; url: string | null; route: string | null; status: number | null; duration_ms: number | null } | null;
  tags: Record<string, string>;
  trace_id: string | null;
};

Services

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/servicesmember (not guest)200 Service[] (by name)
POST/api/workspaces/{ws}/services{ name, team_key?, auto_issue?, threshold_count?, threshold_minutes?, label_ids?, assignee_id? }admin201 Service & { ingest_key: string } (shown only here). Defaults: auto_issue "new", threshold 10 events in 60 minutes, team_key the first team (null: no team). You become creator_id.
GET/api/workspaces/{ws}/services/{id}member (not guest)200 Service
PATCH/api/workspaces/{ws}/services/{id}any subset of the create fields; team_key: null stops automatic issuesadmin200 Service
POST/api/workspaces/{ws}/services/{id}/rotate-keyadmin200 Service & { ingest_key: string }; the old key stops working
DELETE/api/workspaces/{ws}/services/{id}admin204; its groups and events are deleted, issues stay

Error groups

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/error-groupsquery belowmember (not guest)200 ErrorGroup[], paged like issues (X-Total-Count, X-Next-Offset)
GET/api/workspaces/{ws}/error-groups/{id}member (not guest)200 ErrorGroupDetail
GET/api/workspaces/{ws}/error-groups/{id}/eventsquery: limit (default 20, max 100), before (event id)member (not guest)200 ErrorEvent[], newest first
PATCH/api/workspaces/{ws}/error-groups/{id}{ status }member (not guest)200 ErrorGroup
POST/api/workspaces/{ws}/error-groups/{id}/issue{ team_key? } (default: the service's team, else the first team)member (not guest)201 ErrorGroup with the new issue linked (created by the caller). 409 if one is linked already.
PUT/api/workspaces/{ws}/error-groups/{id}/issue{ identifier }member (not guest)200 ErrorGroup, linked to that existing issue (replacing any link)
DELETE/api/workspaces/{ws}/error-groups/{id}/issuemember (not guest)200 ErrorGroup, unlinked (the issue stays)

List query: status (comma-separated, default unresolved; all for every status), service (ids), level, kind, q (title/culprit, case-insensitive), issue (an identifier: groups linked to it), sort (last_seen default, first_seen, events), limit (default 50, max 200), offset.

Live events: error_group — { "action": "created" | "updated" | "deleted", "id": 3, "service_id": 1 }.

Ingest

Authenticate with the service's ingest key: Authorization: Bearer pvi_... (or X-ProjectVerse-Key: pvi_...). Wrong or missing key: 401. Each service may send 1000 events per minute (429 beyond). Bodies up to 1 MB.

MethodPathBodyResponse
POST/api/ingest/eventsIngestEvent or { events: IngestEvent[] } (max 100)202 { accepted: number, ignored: number, group_ids: number[] }
POST/api/ingest/otlp/v1/tracesOTLP/HTTP JSON ExportTraceServiceRequest200 {}
POST/api/ingest/otlp/v1/logsOTLP/HTTP JSON ExportLogsServiceRequest200 {}
ts
type IngestEvent = {
  timestamp?: string;            // RFC 3339; default: received time
  level?: ErrorLevel;            // default "error"
  environment?: string; release?: string;
  message?: string;
  exception?: {
    type: string;                // "TypeError"
    value?: string;              // the message
    frames?: StackFrame[];       // innermost first; `in_app` defaults to true
    stack?: string;              // a raw stack trace, if you don't have frames (JS, Python, Rust, Java and Go formats are parsed)
  };
  request?: { method?: string; url?: string; route?: string; status?: number; duration_ms?: number };
  tags?: Record<string, string>; // at most 50
  trace_id?: string;
  fingerprint?: string[];        // custom grouping: events with the same list share a group
};

What is kept: events with an exception; request events with status >= 500; and other events with a message at level error or fatal (or warning when explicitly set). Request events with a status below 500 and no exception are counted as ignored.

Grouping (unless fingerprint is given):

  • exception: type + the message with numbers, ids, hex, quoted strings and URLs replaced by placeholders + the top 3 in-app frames (function and file).
  • request: method + route (or the URL path with numeric, UUID and long hex segments replaced by :id) + status.
  • message: the normalized message.

OTLP: spans count when their status is ERROR or their http.response.status_code (or http.status_code) is 500 or more; exception span events give the exception. Log records count from severity ERROR (17) up. service.name, deployment.environment(.name) and service.version resource attributes become tags, environment and release. Protobuf bodies answer 415 (set OTEL_EXPORTER_OTLP_PROTOCOL=http/json).

Automatic issues

After each ingest, per group touched (never for ignored groups):

  • No linked issue, the service has a team and auto_issue is not off: with "new", an issue is opened as soon as the group gets an event (so on its first one, and again after an unlink); with "threshold", once threshold_count events arrived within threshold_minutes. Title [{service}] {group title} (255 chars max), the description has the culprit, the stack trace and the request, priority from the level (fatal urgent, error high, warning medium), plus the service's labels and assignee. Created in creator_id's name; skipped if they're no longer a member. At most one issue per group, even under concurrent events.
  • Regression: a resolved group that gets an event goes back to unresolved; if its linked issue is done or canceled it's moved back to todo with a comment "Regressed".
  • Still happening: when a group's event_count reaches 10, 100, 1000, 10000…, the linked issue gets a comment with the count and the last occurrence.
  • Moving a linked issue to done doesn't change the group; the next event counts as a regression.

Observability: request logs, health checks, incidents

Builds on error tracking. Services can also send request logs (every request, not only failures), and ProjectVerse can pull: it checks a service's health URL on a schedule. Both feed incidents (high error rate, slow responses, down), which open an issue and close it again when the service recovers.

Types

ts
// Added to Service (all writable on POST/PATCH /services except the read-only state).
type ServiceObservability = {
  error_rate_threshold: number | null;   // percent (1–100) of 5xx responses that opens an incident; null = off
  slow_p95_ms: number | null;            // p95 latency (50–60000 ms) that opens an incident; null = off
  alert_window_minutes: number;          // 1–60, default 5: window both rules look at
  alert_min_requests: number;            // 1–10000, default 20: fewer requests in the window never open an incident
  health_url: string | null;             // http(s) URL checked with GET; null = no health checks
  health_interval_seconds: number;       // 30–3600, default 60
  health_timeout_ms: number;             // 500–30000, default 5000
  health_failures_before_down: number;   // 1–10, default 2 consecutive failures
  // read-only:
  health: {
    status: "up" | "down" | "unknown";   // unknown until the first check (or without health_url)
    checked_at: string | null;
    latency_ms: number | null;
    status_code: number | null;
    error: string | null;                // "timeout", "connection refused", "HTTP 503"…
    uptime_24h: number | null;           // percent of successful checks in the last 24 h, 1 decimal
  };
  open_incidents: IncidentKind[];
  requests_24h: number;                  // request logs received in the last 24 h
};

type RequestLog = {
  id: number; service_id: number;
  at: string;
  method: string;                        // "GET"
  route: string;                         // the route, or the URL path with ids replaced by :id
  path: string | null;                   // the actual path, without the query string
  status: number;
  duration_ms: number | null;
  environment: string | null;
  trace_id: string | null;
};

type RequestStats = {
  window: "1h" | "24h" | "7d";
  bucket_seconds: number;                // 60 (1h), 3600 (24h), 21600 (7d)
  totals: { requests: number; errors: number; client_errors: number; error_rate: number; p50_ms: number | null; p95_ms: number | null; p99_ms: number | null };
  // One point per bucket, oldest first, empty buckets included.
  series: { at: string; requests: number; errors: number; p95_ms: number | null }[];
  // Top 20 routes by requests.
  routes: { method: string; route: string; requests: number; errors: number; error_rate: number; p95_ms: number | null }[];
};
// errors = status >= 500, client_errors = 400–499. error_rate is a percent with 1 decimal.
// Latency percentiles are approximate (bucket upper bounds: 5, 10, 25, 50, 75, 100, 150, 250, 400,
// 600, 1000, 1500, 2500, 4000, 6000, 10000 ms; above that, the slowest request seen).

type HealthCheck = { id: number; checked_at: string; ok: boolean; status_code: number | null; latency_ms: number | null; error: string | null };

type IncidentKind = "error_rate" | "slow" | "down";
type Incident = {
  id: number; service_id: number; kind: IncidentKind;
  summary: string;                       // "Error rate 12.5% over 5 min (25 of 200 requests)"
  started_at: string; resolved_at: string | null;
  issue: { id: number; identifier: string; title: string; status: IssueStatus } | null;
};

Endpoints

MethodPathBodyWhoResponse
GET/api/workspaces/{ws}/services/{id}/requestsquery: status (5xx, 4xx, 2xx, 3xx or a code), method, route, min_duration_ms, limit (default 50, max 200), before (id)member (not guest)200 RequestLog[], newest first
GET/api/workspaces/{ws}/services/{id}/request-statsquery: window (1h default, 24h, 7d)member (not guest)200 RequestStats
GET/api/workspaces/{ws}/services/{id}/health-checksquery: limit (default 50, max 500)member (not guest)200 HealthCheck[], newest first
POST/api/workspaces/{ws}/services/{id}/health-checksmember (not guest)201 HealthCheck: checks now (counts like a scheduled check). 400 without health_url.
GET/api/workspaces/{ws}/services/{id}/incidentsquery: limit (default 20, max 100)member (not guest)200 Incident[], newest first
GET/api/workspaces/{ws}/incidentsquery: open=truemember (not guest)200 Incident[] of every service, newest first (max 100)

Ingesting request logs

MethodPathBodyResponse
POST/api/ingest/requests{ requests: IngestRequestLog[] } (max 1000)202 { accepted: number }
ts
type IngestRequestLog = {
  timestamp?: string;    // default: received time
  method: string;
  url?: string;          // full URL or path; the query string is dropped
  route?: string;        // the route pattern, if known ("/orders/:id")
  status: number;
  duration_ms?: number;
  environment?: string;
  trace_id?: string;
  error_reported?: boolean; // true when the exception of this request was sent as an event already
};
  • Each 5xx request log is also recorded as an error event (kind: "http"), so failures become error groups and issues without sending them twice; unless error_reported is true (the SDK sets it when it captured the request's exception, so one failure makes one group). Error events keep their own budget (1000 per minute); request logs have 20000 per service per minute (429 beyond).
  • OTLP: every server span with HTTP attributes (http.request.method / http.method and a status code) is also stored as a request log.
  • Kept for 3 days (at most 200000 per service); per-minute rollups for stats are kept for 8 days.

Ingest from browsers, compressed and protobuf bodies

All /api/ingest/* routes:

  • allow cross-origin requests from any origin (CORS, no cookies), so browser apps can report;
  • accept the key as ?key=pvi_... too (for navigator.sendBeacon, which can't set headers), and bodies sent as text/plain;
  • accept Content-Encoding: gzip.

The OTLP routes also accept application/x-protobuf (the default of most OpenTelemetry SDKs and the Collector).

A key used in a browser is visible to anyone using the site. Keys can only send data, never read it; rotate the key if it's abused.

Rules, checked at most every 30 seconds per service (after ingest, and by a background job every minute)

  • error_rate: with at least alert_min_requests requests in the last alert_window_minutes, a 5xx share at or above error_rate_threshold opens an incident. It resolves when the share falls below the threshold (or there are no requests in the window).
  • slow: same window and minimum; p95 latency at or above slow_p95_ms opens, below resolves.
  • down: health_failures_before_down failed checks in a row open; the next successful check resolves. A check fails on a connection error, a timeout, or a status of 400 or more. Redirects aren't followed (a 3xx counts as up). Like webhooks, a health_url that resolves to a private, loopback or link-local address fails ("the host resolves to a private …") unless ALLOW_PRIVATE_WEBHOOK_URLS=true.
  • At most one open incident per service and kind. Opening one opens an issue in the service's team (in creator_id's name, like automatic error issues; skipped without a team): priority urgent for down, high for error_rate, medium for slow, title like [Checkout] Error rate 12.5% (5 min). Resolving comments "Recovered after 7 min" and moves the issue to done if it's still open.
  • Live events: service — { "action": "updated", "id": 1 } when health, incidents or stats change.

MCP tools (additions)

ToolDoes
get_projectOne project with milestones, recent updates and progress
create_project / update_projectName, summary, description, status, priority, lead, members, teams, dates (names, not ids)
post_project_updateproject, health, body
create_milestoneproject, name, target_date
update_milestonemilestone (id, or name with project), and any of name, target_date (null clears), position (1 = first)
delete_milestonemilestone (id, or name with project), optional move_issues_to (another milestone)
list_servicesServices with health, open incidents, 24 h requests and error rate
get_service_statsservice, window: totals, slowest and failing routes, recent incidents

Connectors: OAuth for MCP clients (Claude, ChatGPT, Codex, Claude Code)

/api/mcp is an OAuth 2.1 protected resource, and ProjectVerse is its authorization server, so MCP clients connect with just the URL: they discover the server, register themselves (Dynamic Client Registration), send the user to a consent page, and get short-lived tokens. API tokens (pv_…) keep working.

{APP_URL} is the public web URL (the issuer). Paths under /.well-known/ are served by the API at its root and proxied by the web app.

Discovery

MethodPathResponse
GET/.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/api/mcpRFC 9728: { resource: "{APP_URL}/api/mcp", authorization_servers: ["{APP_URL}"], scopes_supported: ["mcp"], bearer_methods_supported: ["header"], resource_name: "ProjectVerse" }
GET/.well-known/oauth-authorization-server (also /.well-known/openid-configuration)RFC 8414: issuer, authorization_endpoint ({APP_URL}/oauth/authorize), token_endpoint ({APP_URL}/api/oauth/token), registration_endpoint ({APP_URL}/api/oauth/register), revocation_endpoint ({APP_URL}/api/oauth/revoke), response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"], scopes_supported: ["mcp"]

POST /api/mcp without valid credentials answers 401 with WWW-Authenticate: Bearer realm="ProjectVerse", resource_metadata="{APP_URL}/.well-known/oauth-protected-resource/api/mcp".

Client registration (RFC 7591)

POST /api/oauth/register (no auth; rate limited per IP), JSON: { client_name, redirect_uris: string[], token_endpoint_auth_method?, grant_types?, response_types?, scope? } → 201 { client_id, client_secret?, client_id_issued_at, client_secret_expires_at: 0, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method }.

  • token_endpoint_auth_method: none (default; public client, PKCE only), client_secret_post or client_secret_basic (a client_secret is returned once).
  • Redirect URIs (1–10): https:// anywhere; http:// only for loopback hosts (localhost, 127.0.0.1, [::1]; any port matches at authorization, RFC 8252); or a private-use scheme (cursor://…); never javascript:, data:, file: or fragments. Errors are RFC 7591 400 { error: "invalid_redirect_uri" | "invalid_client_metadata", error_description }.
  • client_name: 1–100 characters (default "MCP client").

The client opens {APP_URL}/oauth/authorize?response_type=code&client_id&redirect_uri&code_challenge&code_challenge_method=S256&state&scope&resource in the browser. That's a web page: signed-out users go through login first (/login?next=…). The page checks the request, shows which app asks for access, lets the user pick a workspace, and calls:

MethodPathBodyWhoResponse
GET/api/oauth/clients/{client_id}query: redirect_urisigned in200 { client_id, client_name, redirect_uri, redirect_host }. 404 unknown client; 400 if redirect_uri isn't registered (the page shows the error and never redirects there).
POST/api/oauth/authorize{ client_id, redirect_uri, response_type, code_challenge, code_challenge_method, state?, scope?, resource?, workspace, decision: "allow" | "deny" }session only (not API tokens)200 { redirect_to }: redirect_uri?code=…&state=…&iss={APP_URL}, or ?error=access_denied&state=… when denied. 400 for a bad request (missing PKCE, code_challenge_method other than S256, response_type other than code, unregistered redirect_uri); 404 for a workspace you're not in.

Codes live 10 minutes and are single use. Allowing creates (or reuses) one connection per app, user and workspace.

Tokens

POST /api/oauth/token, application/x-www-form-urlencoded (JSON accepted too), RFC 6749 errors (400 { error: "invalid_request" | "invalid_grant" | "invalid_client" | "unsupported_grant_type", error_description }):

  • grant_type=authorization_code&code&redirect_uri&client_id&code_verifier (+ client secret for confidential clients, in the body or Basic auth).
  • grant_type=refresh_token&refresh_token&client_id.

→ 200 { access_token: "pva_…", token_type: "Bearer", expires_in: 3600, refresh_token: "pvr_…", scope: "mcp" }, Cache-Control: no-store. Refresh tokens last 90 days and rotate on every use; presenting a used refresh token revokes the whole connection (theft detection).

POST /api/oauth/revoke (token, form or JSON; RFC 7009) → 200 always; revoking either token of a connection ends the connection.

Access tokens act like API tokens: as their user, in their workspace only, never on account routes. CORS allows any origin on /api/oauth/register, /api/oauth/token and /api/oauth/revoke.

Connected apps

MethodPathWhoResponse
GET/api/workspaces/{ws}/connected-appssession only200 ConnectedApp[] (yours in this workspace, newest first)
DELETE/api/workspaces/{ws}/connected-apps/{id}session only204; its tokens stop working at once
ts
type ConnectedApp = { id: number; client_name: string; redirect_host: string; created_at: string; last_used_at: string | null };

Audit (account events): user.app_connected, user.app_disconnected with { client, workspace }.

Connecting

  • Claude (Desktop, web, mobile): Customize → Connectors → Add custom connector → {APP_URL}/api/mcp.
  • ChatGPT: Settings → Apps & Connectors → Advanced → Developer mode → Create → {APP_URL}/api/mcp, OAuth.
  • Claude Code: claude mcp add --transport http projectverse {APP_URL}/api/mcp, then /mcp → Authenticate. Or the plugin: /plugin marketplace add kemojal/projectverse and /plugin install projectverse@projectverse.
  • Codex: codex mcp add projectverse --url {APP_URL}/api/mcp then codex mcp login projectverse.