Skip to content

API reference

The stable public surface, grouped by resource. Every shape below is taken from the live handlers and Pydantic models.

Before you start

All paths are relative to https://api.lithora.app and need Authorization: Bearer <token> — see Authentication. The full machine-readable contract, including every schema, is at /api/openapi.json. Where a scope chip appears, that scope is enforced today for token-authenticated callers.

Auth

GET/api/auth/me

The authenticated user. The cheapest way to prove a credential is live.

200 response (abridged)
{
  "user_id": "0d5f5f18-2b4c-4f31-9c8e-1a7d3e6b90aa",
  "email": "you@example.com",
  "name": "Ada Lovelace",
  "role": "admin",
  "has_password": true,
  "username": "ada",
  "display_name": "Ada Lovelace",
  "avatar": "https://…"
}

The full response also carries plan, entitlement and workspace-preference fields used by the web app. Treat anything not listed here as internal and subject to change.

POST/api/auth/login

Exchange email and password for a JWT. May return a 2FA or OTP challenge instead of a token — see Authentication.

Request body

FieldTypeNotes
email*stringAccount email.
password*stringRate limited to 10 attempts per minute per IP, plus a per-account lockout after repeated failures.

Personal access token endpoints (/api/auth/tokens) are documented on Authentication.

Teams

A team owns projects and holds the membership list that every permission check reads. You need a team_id before you can create anything.

GET/api/teams

Every team you are a member of, as a bare array.

200 response
[
  {
    "team_id": "3c9a7e51-8d02-4a6f-b1c4-77e5d0a2f318",
    "name": "Platform",
    "description": "Core services",
    "member_ids": ["0d5f5f18-2b4c-4f31-9c8e-1a7d3e6b90aa"],
    "created_by": "0d5f5f18-2b4c-4f31-9c8e-1a7d3e6b90aa",
    "created_at": "2026-05-02T11:20:04.118Z"
  }
]
POST/api/teams

Create a team. You become its first member, and a default team conversation is created alongside it.

Request body

FieldTypeNotes
name*string1–100 characters.
descriptionstring | nullUp to 500 characters.
member_idsstring[]Up to 100 user ids, deduplicated. Each must be a valid uuid.
GET/api/teams/{team_id}

One team. GET /api/teams/{team_id}/members returns the resolved member list.

Projects

GET/api/projects

Projects across every team you belong to, as a bare array.

Query parameters

FieldTypeNotes
team_idstringRestrict to one team. A team you are not a member of returns an empty array rather than a 403, so existence is never leaked.
limitintegerDefaults to 500, clamped to 2000.
offsetintegerRows to skip.
POST/api/projectsscope: projects:write

Create a project inside a team.

Request body

FieldTypeNotes
name*stringProject name.
team_id*stringOwning team. You must be a member.
descriptionstring | nullEmpty strings are coerced to null.
statusstringactive | archived | completed. Defaults to active.
start_datestring | nullISO date.
end_datestring | nullISO date.
Intended 200 response (ActionReceipt)
{
  "action_id": "b41e7c90-5d8a-4c2f-9e63-0af1d2b48c77",
  "status": "verified",
  "verified": true,
  "message": "Project created successfully",
  "affected_entities": [
    { "entity_type": "project", "entity_id": "8f1c0c2e-…", "action": "created" }
  ],
  "data": {
    "project_id": "8f1c0c2e-4a2d-4f5b-9c31-6b2a7e0d4411",
    "name": "Billing rewrite",
    "team_id": "3c9a7e51-…",
    "status": "active",
    "created_by": "0d5f5f18-…",
    "created_at": "2026-08-01T09:14:22.481Z"
  },
  "rollback_available": false,
  "timestamp": "2026-08-01T09:14:22.481Z"
}
PATCH/api/projects/{project_id}

Partial update. PUT replaces and additionally enforces the projects:write scope.

DELETE/api/projects/{project_id}scope: projects:write

Soft delete. The project and every task inside it move to Trash and disappear from all reads; the response carries a restore_url and the retention window.

Tasks

Tasks are the work items on the board. Internally and over the wire they are called task; the product UI calls the same object work. Status is one of todo, in_progress, in_review, done; priority is low, medium, high or urgent. Hyphenated status values (in-progress) are normalised for you.

GET/api/tasks

Tasks as a bare array, with subtasks nested under their parent.

Query parameters

FieldTypeNotes
project_idstringOne project. Requires membership of the owning team.
team_idstringEvery project in one team. Ignored when project_id is set.
statusstringFilter by status.

With no filter this spans every project you can see, which is rarely what you want. There is no pagination and a single response is hard-capped at 10,000 tasks — filter by project. GET /api/tasks/my returns only tasks assigned to you.

POST/api/tasksscope: tasks:write

Create a task in a project.

Request body

FieldTypeNotes
title*string1–200 characters; whitespace is trimmed.
project_id*stringMust be a live project on a team you belong to.
descriptionstring | nullUp to 2000 characters.
assigned_tostring | nullUser id. Rejected with 400 if that user is not on the project’s team.
statusstringDefaults to todo.
prioritystringDefaults to medium. Case-insensitive.
due_datestring | nullISO date or datetime. A malformed value is a 400.
tagsstring[]Up to 20 tags, 50 characters each, deduplicated.
estimated_hoursnumber | null0–1000.
parent_task_idstring | nullMakes this a subtask.
task_idstring | nullClient-supplied id for idempotent retries — see the callout below.
Request
{
  "title": "Retry failed Dodo webhooks",
  "project_id": "8f1c0c2e-4a2d-4f5b-9c31-6b2a7e0d4411",
  "description": "Add a dead-letter queue with exponential backoff.",
  "priority": "high",
  "status": "todo",
  "due_date": "2026-08-15",
  "tags": ["billing", "reliability"],
  "estimated_hours": 6
}
200 response
{
  "message": "Task created successfully",
  "task_id": "a72b9c1d-33e4-4f08-8b5a-e91c60d7f204",
  "data": {
    "task_id": "a72b9c1d-33e4-4f08-8b5a-e91c60d7f204",
    "title": "Retry failed Dodo webhooks",
    "project_id": "8f1c0c2e-…",
    "team_id": "3c9a7e51-…",
    "assigned_to": null,
    "status": "todo",
    "priority": "high",
    "due_date": "2026-08-15",
    "tags": ["billing", "reliability"],
    "estimated_hours": 6,
    "actual_hours": 0.0,
    "parent_task_id": null,
    "position": null,
    "created_by": "0d5f5f18-…",
    "created_at": "2026-08-01T09:14:22.481Z",
    "updated_at": "2026-08-01T09:14:22.481Z",
    "completed_at": null,
    "is_archived": false,
    "version": 1
  }
}
PATCH/api/tasks/{task_id}scope: tasks:write

Partial update; PUT hits the same handler. Every field is optional — send only what changes.

Returns { "message": "Task updated successfully" } not the updated task. Re-read the task if you need its new state. Include the version you last read to opt into optimistic concurrency; a stale version is rejected with 409 rather than silently overwriting a concurrent edit.

PATCH/api/tasks/{task_id}/status

Status-only update — the endpoint a board drag should call.

Request body

FieldTypeNotes
status*stringtodo | in_progress | in_review | done. Anything else is a 422.
versioninteger | nullThe version you last read. Omit for last-write-wins; send it to get a 409 on conflict.

Also returns a bare message. Completing a recurring task here spawns the next occurrence automatically.

DELETE/api/tasks/{task_id}scope: tasks:write

Soft delete — the task moves to Trash and is restorable.

200 response
{
  "message": "Task moved to Trash",
  "success": true,
  "verified": true,
  "soft_deleted": true,
  "deleted_at": "2026-08-01T09:31:07.882Z",
  "restore_url": "/api/trash/task/a72b9c1d-…/restore",
  "retention_days": 30
}
POST/api/tasks/bulk

Create several tasks in one request. The body is an array of task objects with the same shape as a single create.

Idempotent creates

Generate a uuid client-side and send it as task_id. If a task with that id already exists in the same project, the API returns it instead of creating a duplicate — so a network retry is safe. The same id used against a different project is refused with 409, and never discloses the other record.

Work graph

The work graph is the unified layer over every entity type — task, project, file, note, whiteboard, doc — plus typed edges between them. Use it when you care about relationships rather than a single board.

GET/api/work-items

List work items. POST creates one from { type, title, ...fields }; GET, PUT and DELETE operate on /api/work-items/{id}.

POST/api/work-items/relations

Create a typed edge between two items.

Request body

FieldTypeNotes
source_id*stringOriginating work item.
target_id*stringTarget work item.
relation_type*stringparent_child, blocking, blocked_by, blocks, related_to, attached_to, belongs_to, caused_by or recurrence_of.

Read edges back with GET /api/work-items/{id}/relations, /children and /parents; remove one with DELETE /api/work-items/relations/{relation_id}.

GET/api/work-items/cycle-time

Issue-to-PR-merge cycle-time analytics, filterable by team_id and project_id. Returns per-repo and per-project medians plus the sample set behind them.

AI workspace

The agent reads only what you link into a session, and it never writes without an explicit approval step. That gate is the whole security model — build your integration around it rather than trying to route past it.

POST/api/ai-workspace/sessions

Open a session. Body is { title? }; returns { session_id, title, created_at }.

POST/api/ai-workspace/chat

Send a message. Body is { session_id, message } — message is 1–10,000 characters.

200 response with a staged plan
{
  "response": "I found 4 open tasks tagged 'billing'. I can move the two that are blocked to In Review.",
  "requires_confirmation": true,
  "action_plan": {
    "action_id": "5e2a91c7-…",
    "summary": "Move 2 tasks to In Review",
    "actions": [ ... ]
  }
}

When requires_confirmation is true, the agent has proposed mutations and written nothing at all.

POST/api/ai-workspace/agent/confirm

Approve or reject a staged plan. This is the only place the agent's writes happen.

Approve a plan
curl -s -X POST https://api.lithora.app/api/ai-workspace/agent/confirm \
  -H "Authorization: Bearer $LITHORA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "c81f4a20-…",
    "action_id": "5e2a91c7-…",
    "confirmed": true
  }'

Send "confirmed": false to discard the plan. GET /api/ai-workspace/agent/pending lists plans staged by autonomous runs — overnight triage, CI-failure triage — waiting for your approval.

Use /agent/confirm, not /actions/confirm

POST /api/ai-workspace/actions/confirm is the deprecated pre-agent intent-parser gate. It still responds, for backward compatibility only. New integrations should call /api/ai-workspace/agent/confirm.

Search

Webhooks

Inbound GitHub events arrive at POST /api/github/webhooks, authenticated by HMAC rather than a Bearer credential. Signature verification, the normalized github.* event names and every payload shape are documented on Webhooks.