API reference
The stable public surface, grouped by resource. Every shape below is taken from the live handlers and Pydantic models.
Before you start
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
/api/auth/meThe authenticated user. The cheapest way to prove a credential is live.
{
"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.
/api/auth/loginExchange email and password for a JWT. May return a 2FA or OTP challenge instead of a token — see Authentication.
Request body
| Field | Type | Notes |
|---|---|---|
| email* | string | Account email. |
| password* | string | Rate 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.
/api/teamsEvery team you are a member of, as a bare array.
[
{
"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"
}
]/api/teamsCreate a team. You become its first member, and a default team conversation is created alongside it.
Request body
| Field | Type | Notes |
|---|---|---|
| name* | string | 1–100 characters. |
| description | string | null | Up to 500 characters. |
| member_ids | string[] | Up to 100 user ids, deduplicated. Each must be a valid uuid. |
/api/teams/{team_id}One team. GET /api/teams/{team_id}/members returns the resolved member list.
Projects
/api/projectsProjects across every team you belong to, as a bare array.
Query parameters
| Field | Type | Notes |
|---|---|---|
| team_id | string | Restrict to one team. A team you are not a member of returns an empty array rather than a 403, so existence is never leaked. |
| limit | integer | Defaults to 500, clamped to 2000. |
| offset | integer | Rows to skip. |
/api/projectsscope: projects:writeCreate a project inside a team.
Request body
| Field | Type | Notes |
|---|---|---|
| name* | string | Project name. |
| team_id* | string | Owning team. You must be a member. |
| description | string | null | Empty strings are coerced to null. |
| status | string | active | archived | completed. Defaults to active. |
| start_date | string | null | ISO date. |
| end_date | string | null | ISO date. |
{
"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"
}/api/projects/{project_id}Partial update. PUT replaces and additionally enforces the projects:write scope.
/api/projects/{project_id}scope: projects:writeSoft 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.
/api/tasksTasks as a bare array, with subtasks nested under their parent.
Query parameters
| Field | Type | Notes |
|---|---|---|
| project_id | string | One project. Requires membership of the owning team. |
| team_id | string | Every project in one team. Ignored when project_id is set. |
| status | string | Filter 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.
/api/tasksscope: tasks:writeCreate a task in a project.
Request body
| Field | Type | Notes |
|---|---|---|
| title* | string | 1–200 characters; whitespace is trimmed. |
| project_id* | string | Must be a live project on a team you belong to. |
| description | string | null | Up to 2000 characters. |
| assigned_to | string | null | User id. Rejected with 400 if that user is not on the project’s team. |
| status | string | Defaults to todo. |
| priority | string | Defaults to medium. Case-insensitive. |
| due_date | string | null | ISO date or datetime. A malformed value is a 400. |
| tags | string[] | Up to 20 tags, 50 characters each, deduplicated. |
| estimated_hours | number | null | 0–1000. |
| parent_task_id | string | null | Makes this a subtask. |
| task_id | string | null | Client-supplied id for idempotent retries — see the callout below. |
{
"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
}{
"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
}
}/api/tasks/{task_id}scope: tasks:writePartial 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.
/api/tasks/{task_id}/statusStatus-only update — the endpoint a board drag should call.
Request body
| Field | Type | Notes |
|---|---|---|
| status* | string | todo | in_progress | in_review | done. Anything else is a 422. |
| version | integer | null | The 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.
/api/tasks/{task_id}scope: tasks:writeSoft delete — the task moves to Trash and is restorable.
{
"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
}/api/tasks/bulkCreate several tasks in one request. The body is an array of task objects with the same shape as a single create.
Idempotent creates
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.
/api/work-itemsList work items. POST creates one from { type, title, ...fields }; GET, PUT and DELETE operate on /api/work-items/{id}.
/api/work-items/relationsCreate a typed edge between two items.
Request body
| Field | Type | Notes |
|---|---|---|
| source_id* | string | Originating work item. |
| target_id* | string | Target work item. |
| relation_type* | string | parent_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}.
/api/work-items/cycle-timeIssue-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.
/api/ai-workspace/sessionsOpen a session. Body is { title? }; returns { session_id, title, created_at }.
/api/ai-workspace/sessions/{session_id}/linkPut a work-graph item in the session's context. Body is { item_type, item_id }. The agent sees nothing you have not linked.
/api/ai-workspace/chatSend a message. Body is { session_id, message } — message is 1–10,000 characters.
{
"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.
/api/ai-workspace/agent/confirmApprove or reject a staged plan. This is the only place the agent's writes happen.
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
/api/searchPermission-scoped search across the workspace. Body is { query, limit? } — query is 1–500 characters, limit is 1–50 and defaults to 20. Results never include anything you cannot already read.
/api/ai-workspace/searchSearch restricted to items that can be linked into an AI session. Pass ?q=.
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.