- tasks.list: replace status filter with state_group (backlog/unstarted/started/completed/cancelled) - tasks.create: remove obsolete status param (tasks now use state_id FK) - tasks.update: remove obsolete status param - Implement state_group filtering via EXISTS subquery on states table - Update API.md example to use state_group instead of status
38 KiB
API Documentation
Project E exposes a REST API built with Hono running on Bun. All endpoints live under /api/ and exchange JSON.
The API server listens on port 3001 (Docker) and is proxied from the Vite dev server on port 3000. In development, http://localhost:3000/api/... reaches the same routes as http://localhost:3001/api/....
Table of Contents
- Overview
- Authentication
- Request and Response Format
- Error Handling
- Pagination and Sorting
- Domain Scoping
- Health
- Auth
- Domains
- Tasks
- Habits
- Projects
- Notes
- Search
- Calendar
- Graph
- Dashboard
- Agents
- Webhooks
- Canvas
- Daily Notes
- Tags
- Custom Fields
- Error Log
- Analytics
- Notifications
- Export and Import
- Realtime (SSE)
- MCP
Overview
| Item | Value |
|---|---|
| Framework | Hono, served by Bun (bun run dev / the API Docker image) |
| Base path | /api |
| Body format | JSON (Content-Type: application/json) |
| Auth | Cookie session, JWT bearer token, or API key |
| Database | PostgreSQL via Drizzle ORM |
All routes mount through apps/api/src/index.ts:
/api/auth /api/tasks /api/habits /api/projects
/api/notes /api/search /api/calendar /api/graph
/api/dashboard /api/agents /api/webhooks /api/canvas
/api/daily-notes /api/tags /api/custom-fields
/api/error-log /api/analytics /api/notifications
/api/export /api/import /api/realtime /api/mcp
/api/health
Authentication
Every endpoint except GET /api/health requires authentication. The API accepts three credential forms, in this order of resolution:
- Session cookie named
session, set byPOST /api/auth/credentials. The cookie ishttpOnly,SameSite=Lax, and persists for 30 days. The JWT inside it expires after 7 days. - Bearer token via the
Authorization: Bearer <token>header. The token can be the same JWT issued at login, or an API key. - API key in the
Authorization: Bearer <apiKey>header. Keys are validated against theapi_keystable, which stores only the SHA-256 hash of the key.
API keys are the required credential for POST /api/mcp (JSON-RPC). The MCP endpoint does not accept cookie or JWT auth.
Credentials login
POST /api/auth/credentials with { email, password }. On success the server sets the session cookie and returns the user plus the JWT:
curl -X POST http://localhost:3000/api/auth/credentials \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password"}' \
-c cookies.txt
{
"user": {
"id": "a1b2c3d4-...",
"email": "user@example.com",
"name": "User Name"
},
"token": "eyJhbGciOiJIUzI1NiJ9..."
}
First login auto-creates the admin account. When the users table is empty, the request is accepted only if email matches INITIAL_ADMIN_EMAIL and password matches INITIAL_ADMIN_PASSWORD from the environment. The created account uses INITIAL_ADMIN_NAME or the email as its display name.
Invalid credentials return 401 UNAUTHORIZED.
Session check
GET /api/auth/session returns the current session state. It never errors on an unauthenticated request:
{ "authenticated": true, "user": { "id": "...", "email": "...", "name": "..." } }
{ "authenticated": false, "user": null }
Current user
GET /api/auth/me returns the authenticated user or 401 UNAUTHORIZED:
{ "user": { "id": "...", "email": "...", "name": "..." } }
Logout
POST /api/auth/logout clears the session cookie and returns { "success": true }.
Removed features
Passkey authentication was removed. The legacy passkey routes issued a session without verifying the WebAuthn signature, which was an authentication bypass. Do not re-add passkey endpoints without full WebAuthn challenge/attestation verification.
Request and Response Format
Write endpoints (POST, PATCH, PUT) expect a JSON body with Content-Type: application/json. Bodies are validated with Zod schemas. Invalid input returns 400 VALIDATION_ERROR.
Success status codes:
| Status | Meaning |
|---|---|
200 |
Success |
201 |
Created |
204 |
Deleted (no body) |
Responses use camelCase field names (domainId, createdAt, deletedAt). Timestamps are ISO 8601 strings with timezone.
Error Handling
Errors use a consistent envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{
"code": "invalid_string",
"message": "Title is required",
"path": ["title"]
}
]
}
}
details appears only when present (mostly Zod validation issues). Error codes:
| Code | Status | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing or invalid credentials |
VALIDATION_ERROR |
400 | Request body or params failed validation |
NOT_FOUND |
404 | Resource does not exist |
FORBIDDEN |
403 | Missing workspace ID, or the workspace belongs to someone else |
CONFLICT |
409 | State conflict (reserved; not currently returned by routes) |
INTERNAL_ERROR |
500 | Unexpected server error |
Two endpoints deviate from the envelope:
GET /api/realtimereturns401with{ "error": "Unauthorized" }when unauthenticated.POST /api/mcpreturns JSON-RPC error objects (see MCP).
Pagination and Sorting
Most list endpoints paginate with page and perPage:
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
page |
number | 1 | - | Page number, 1-indexed |
perPage |
number | 50 | 100 | Items per page |
Response shape:
{
"items": [],
"totalItems": 42,
"totalPages": 3,
"page": 1,
"perPage": 50
}
Tasks, habits, projects, and notes also accept limit and offset (default limit 50, max 200). These four endpoints echo both styles in the response (page, perPage, limit, offset).
Sorting uses sort=<field> with a leading - for descending:
GET /api/tasks?sort=-due_date
Some endpoints (tasks, habits, projects, notes) accept a separate order=asc|desc parameter that flips the sort direction.
Endpoints that return everything for a domain (calendar events, graph nodes, custom fields, notifications, agent activity) skip pagination and return { items, totalItems } or { items, count }.
Domain Scoping
Every entity belongs to a domain (workspace). All by-ID routes verify the caller owns the entity's domain through requireWorkspaceAccess and return 403 FORBIDDEN (missing or foreign workspace) or 404 NOT_FOUND (no such workspace) otherwise.
List endpoints accept ?domain=<id> to scope results. When omitted, they resolve the user's active domain: the user's first domain by sortOrder then createdAt, or a "Personal" domain auto-created on first use.
Health
GET /api/health
No authentication required. Pings the database and reports runtime state:
{
"status": "ok",
"timestamp": "2026-08-10T10:30:00.000Z",
"version": "0.1.0",
"runtime": "bun",
"uptime": 12345.6,
"database": {
"connected": true,
"ping_ms": 4
}
}
status is ok when the DB ping succeeds and degraded when it fails. database.ping_ms is -1 on failure.
Auth
| Method | Path | Description |
|---|---|---|
POST |
/api/auth/credentials |
Log in with email and password; sets session cookie |
GET |
/api/auth/session |
Return { authenticated, user } |
GET |
/api/auth/me |
Return the current user |
POST |
/api/auth/logout |
Clear the session cookie |
Domains
Domains are workspaces. They are owned by a single user (ownerId).
| Method | Path | Description |
|---|---|---|
GET |
/api/domains |
List the caller's domains |
POST |
/api/domains |
Create a domain |
GET |
/api/domains/:id |
Get a domain |
PATCH |
/api/domains/:id |
Update a domain |
DELETE |
/api/domains/:id |
Delete a domain |
List query parameters: page, perPage, sort (name, slug, sort_order, created_at, updated_at; default sort_order), filter (case-insensitive match on name or slug).
Create:
curl -X POST http://localhost:3000/api/domains \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name":"Work","slug":"work","color":"#3b82f6"}'
name is required. slug defaults to a slugified name. Response (201):
{
"id": "b2c3d4e5-...",
"name": "Work",
"slug": "work",
"color": "#3b82f6",
"icon": null,
"ownerId": "a1b2c3d4-...",
"parentId": null,
"sortOrder": 0,
"customFields": {},
"createdAt": "2026-08-10T10:30:00.000Z",
"updatedAt": "2026-08-10T10:30:00.000Z"
}
PATCH /api/domains/:id accepts any subset of name, slug, color, icon, parentId. DELETE returns 204 and hard-deletes the row (domains have no deleted_at column).
Tasks
Task statuses: todo, in_progress, done, cancelled. Priorities: low, medium, high, urgent.
| Method | Path | Description |
|---|---|---|
GET |
/api/tasks |
List tasks |
POST |
/api/tasks |
Create a task |
POST |
/api/tasks/reorder |
Persist Kanban column ordering |
GET |
/api/tasks/:id |
Get a task with subtasks, tags, dependencies |
PATCH |
/api/tasks/:id |
Update a task |
DELETE |
/api/tasks/:id |
Soft-delete a task |
POST |
/api/tasks/:id/status |
Change task status (Kanban drag) |
POST |
/api/tasks/:id/tags |
Assign a tag |
DELETE |
/api/tasks/:id/tags/:tagId |
Remove a tag |
GET |
/api/tasks/:id/history |
Status change log (from activity feed) |
GET |
/api/tasks/:id/comments |
List comments |
POST |
/api/tasks/:id/comments |
Add a comment |
GET |
/api/tasks/:id/attachments |
List attachment metadata |
List tasks
Query parameters:
| Parameter | Type | Description |
|---|---|---|
domain |
string | Workspace ID; defaults to active domain |
search |
string | Case-insensitive match on title |
filter |
string | Case-insensitive match on title or description |
status |
string | Comma-separated statuses |
priority |
string | Comma-separated priorities |
tag |
string | Comma-separated tag IDs (task must have all) |
parent_id |
string | Filter by parent; null for root tasks |
project_id |
string | Filter by project |
section_id |
string | Filter by project section |
sort |
string | created, updated, title, status, priority, order, due_date (default -created) |
order |
string | asc or desc |
page / perPage |
number | Pagination (default 1 / 50) |
limit / offset |
number | Alternative pagination (max limit 200) |
Example:
curl "http://localhost:3000/api/tasks?status=todo,in_progress&sort=due_date&domain=<domainId>" \
-b cookies.txt
{
"items": [
{
"id": "c3d4e5f6-...",
"title": "Complete documentation",
"description": "Write API docs",
"status": "todo",
"priority": "high",
"domainId": "b2c3d4e5-...",
"projectId": null,
"sectionId": null,
"parentId": null,
"dueDate": "2026-08-20T00:00:00.000Z",
"completedAt": null,
"estimatedMinutes": null,
"trackedMinutes": 0,
"recurrenceRule": null,
"order": 0,
"customFields": {},
"createdAt": "2026-08-10T10:30:00.000Z",
"updatedAt": "2026-08-10T10:30:00.000Z",
"tags": []
}
],
"totalItems": 1,
"totalPages": 1,
"page": 1,
"perPage": 50,
"limit": 50,
"offset": 0
}
Create a task
title is required. domain defaults to the active domain. Other accepted fields: description, status, priority, projectId, sectionId, parentId, dueDate (ISO 8601), estimatedMinutes, order, customFields, recurrenceRule (RRule string; the worker spawns the next occurrence), tagIds.
{
"title": "Ship the release",
"description": "Cut the v0.2.0 tag",
"status": "in_progress",
"priority": "urgent",
"dueDate": "2026-08-20T17:00:00.000Z",
"tagIds": ["f1a2b3c4-..."]
}
Response (201) is the created task. Setting parentId requires the parent to exist and not be deleted (404 NOT_FOUND otherwise).
Reorder
POST /api/tasks/reorder with { "orderedIds": ["id1", "id2", "id3"], "domain": "<domainId>" }. domain is optional; it resolves from the first task when omitted. Every ID must exist in the workspace and be non-deleted, or the request returns 404. Tasks are assigned order 0..n in transaction. Response:
{ "success": true, "orderedIds": ["id1", "id2", "id3"] }
Get a single task
GET /api/tasks/:id returns the task enriched with subtasks, tags (array of { id, name, color }), dependencies (tasks this task blocks on), and dependents (tasks that block on this one).
Update
PATCH /api/tasks/:id accepts any subset of the create fields. A task cannot be its own parent, and parentId cycles are rejected with 400 VALIDATION_ERROR.
Delete
DELETE /api/tasks/:id soft-deletes the task (sets deletedAt) and returns 204. Recurring spawns for the task are stopped.
Change status
POST /api/tasks/:id/status with { "status": "done" }. Marking a task done also sets completedAt.
Tags
POST /api/tasks/:id/tags with { "tagId": "<uuid>" } returns 201 { "success": true }. Re-assigning the same tag is a no-op, not an error. DELETE /api/tasks/:id/tags/:tagId returns 204.
Comments and attachments
Comments and attachment metadata are stored in the activity feed with entity types comment and attachment. POST /api/tasks/:id/comments takes { "content": "..." } and returns 201 { "success": true }. GET endpoints return { items, totalItems }, newest first.
Habits
Habit frequencies: daily, weekly, custom. Difficulties: easy, medium, hard.
| Method | Path | Description |
|---|---|---|
GET |
/api/habits |
List habits |
POST |
/api/habits |
Create a habit |
GET |
/api/habits/:id |
Get a habit with recent completions, tags |
PATCH |
/api/habits/:id |
Update a habit |
DELETE |
/api/habits/:id |
Soft-delete a habit |
POST |
/api/habits/:id/complete |
Log a completion for today |
GET |
/api/habits/:id/completions |
Completion history |
POST |
/api/habits/:id/tags |
Assign a tag |
DELETE |
/api/habits/:id/tags/:tagId |
Remove a tag |
List habits
Query parameters: domain, active (true/false), frequency, difficulty, tag (comma-separated tag IDs), search/filter (match on name), sort (created, updated, name, frequency, difficulty, streak_count; default -created), order, page/perPage, limit/offset.
Create
name is required. Other fields: description, domain (defaults to active), frequency, difficulty, goalPerPeriod (default 1), unit, reminderTime, skipDays (array of 0-6 weekday numbers), moodTracking, active (default true), tagIds.
Complete
POST /api/habits/:id/complete with:
{ "value": 1, "mood": 5, "notes": "Felt great" }
value is required (default 1). mood is 1-5, notes optional. Response (201) includes the stored completion plus updated streaks:
{
"completion": {
"id": "d4e5f6a7-...",
"habitId": "e5f6a7b8-...",
"date": "2026-08-10T12:00:00.000Z",
"value": 1,
"mood": 5,
"notes": "Felt great",
"createdAt": "2026-08-10T12:00:00.000Z"
},
"streakCount": 3,
"bestStreak": 5
}
Completions
GET /api/habits/:id/completions supports from and to (ISO timestamps), limit (default 365, max 1000), offset, and order (asc/desc). Returns { items, totalItems, limit, offset }.
Projects
Project statuses: active, paused, completed, archived.
| Method | Path | Description |
|---|---|---|
GET |
/api/projects |
List projects |
POST |
/api/projects |
Create a project |
GET |
/api/projects/:id |
Get a project with sections, tasks, progress |
PATCH |
/api/projects/:id |
Update a project |
DELETE |
/api/projects/:id |
Soft-delete a project |
GET |
/api/projects/:id/sections |
List sections |
POST |
/api/projects/:id/sections |
Create a section |
GET |
/api/projects/:id/sections/:sid |
Get a section |
PATCH |
/api/projects/:id/sections/:sid |
Update a section |
DELETE |
/api/projects/:id/sections/:sid |
Delete a section |
GET |
/api/projects/:id/members |
List members (from activity feed) |
POST |
/api/projects/:id/members |
Add a member |
DELETE |
/api/projects/:id/members/:uid |
Remove a member |
List projects
Query parameters: domain, status (comma-separated), search/filter (match on name), sort (created, updated, name, status, target_date; default -created), order, page/perPage, limit/offset.
Each item carries tags, taskCount, completedCount, and progress (0-100).
Create
name is required. Other fields: description, domain (defaults to active), status (default active), color, icon, targetDate, tagIds.
Sections
Sections group tasks within a project. kind is section (default) or milestone; status is planned, in_progress, or complete.
Create with { "name": "Design phase", "kind": "milestone", "status": "in_progress", "targetDate": "...", "sortOrder": 1 }. sortOrder defaults to one past the current max. Sections are hard-deleted (204); they have no deleted_at column.
Members
Members are recorded in the activity feed with entity type member. POST /api/projects/:id/members takes { "userId": "<uuid>", "role": "member" } (role optional, default member) and returns 201 { "success": true }. Remove with DELETE /api/projects/:id/members/:uid (204).
Notes
Notes store Tiptap HTML in content. Wikilinks in the content (Obsidian-style links to other notes and entities) are parsed and kept in sync on create and update.
| Method | Path | Description |
|---|---|---|
GET |
/api/notes |
List notes |
POST |
/api/notes |
Create a note |
GET |
/api/notes/:id |
Get a note with tags, backlinks, outgoing links |
PATCH |
/api/notes/:id |
Update a note |
DELETE |
/api/notes/:id |
Soft-delete a note |
GET |
/api/notes/:id/backlinks |
Notes that link to this one |
GET |
/api/notes/:id/versions |
Edit history (from activity feed) |
POST |
/api/notes/:id/tags |
Assign a tag |
DELETE |
/api/notes/:id/tags/:tagId |
Remove a tag |
List notes
Query parameters: domain, pinned (true), archived (true/false/all; default excludes archived), tag (comma-separated tag IDs), search/filter (match on title), sort (title, created_at, updated_at, is_pinned; default -updated_at), order, page/perPage, limit/offset.
Create
title is required. Other fields: content (Tiptap HTML), domain (defaults to active), isPinned, isArchived, tagIds.
Search
Full-text search across tasks, notes, projects, habits, and domains. Results are scoped to the caller's active domain. Backed by Postgres search_vector columns with ts_headline snippets.
GET /api/search
Query parameters:
| Parameter | Type | Description |
|---|---|---|
q |
string | Search query (required; empty returns { results: [], totalCount: 0 }) |
types |
string | Comma-separated entity types; defaults to all five |
limit |
number | Max results (default 20, max 50) |
offset |
number | Offset into the ranked results (default 0) |
Entity types: task, note, project, habit, domain.
{
"results": [
{
"id": "c3d4e5f6-...",
"type": "task",
"title": "Complete documentation",
"snippet": "Complete <mark>documentation</mark> for the API...",
"score": 0.42,
"workspaceId": "b2c3d4e5-...",
"link": "/tasks/c3d4e5f6-..."
}
],
"totalCount": 1,
"query": "documentation"
}
Two stubs exist: GET /api/search/recent returns { items: [], totalItems: 0 } and POST /api/search/index returns { success: true, message: "Reindex triggered" }. Neither performs work yet.
Calendar
| Method | Path | Description |
|---|---|---|
GET |
/api/calendar/events |
List events in a time range |
POST |
/api/calendar/events |
Create an event |
PATCH |
/api/calendar/events/:id |
Update an event |
DELETE |
/api/calendar/events/:id |
Delete an event |
GET |
/api/calendar/upcoming |
Events in the next N days |
GET /events takes from and to (ISO timestamps, matched against startTime), plus domain. Returns events ordered by startTime ascending.
Create:
{
"title": "Sprint review",
"description": "Demo the new dashboard",
"startTime": "2026-08-14T15:00:00.000Z",
"endTime": "2026-08-14T16:00:00.000Z",
"allDay": false,
"color": "#3b82f6",
"entityType": "task",
"entityId": "c3d4e5f6-...",
"recurrenceRule": null
}
title, startTime, and domain are required. GET /upcoming?days=7 (default 7, max 365) returns events from now through days days ahead. Events are hard-deleted (204).
Graph
The knowledge graph of a domain: nodes for notes, tasks, habits, projects, sections, tags, and the domain itself; edges for note links, note-to-entity links, task dependencies, and domain membership.
| Method | Path | Description |
|---|---|---|
GET |
/api/graph/nodes |
All nodes for a domain |
GET |
/api/graph/edges |
All edges for a domain |
POST |
/api/graph/edges |
Create a relationship |
DELETE |
/api/graph/edges/:id |
Delete a relationship |
domain is required on both GET endpoints; omitting it returns 400 VALIDATION_ERROR.
Node shape: { id, label, type, color } with types task, habit, project, note, section, tag, domain. Edge shape: { source, target, type }.
Create an edge with { "sourceId": "<uuid>", "targetId": "<uuid>", "type": "note_link" }. Supported types:
| Type | Meaning |
|---|---|
note_link |
Note to note (source and target are notes) |
note_entity |
Note to task (target is a task) |
task_dependency |
Task depends on task (source depends on target) |
Both endpoints must belong to the caller's domain, verified before any write. Response (201): { "success": true }.
Delete an edge with DELETE /api/graph/edges/:id where :id is "<sourceId>-<targetId>". The server resolves the source entity type, deletes from note_links or task_dependencies, and returns 204.
Dashboard
Per-user dashboard widgets, scoped to a domain.
| Method | Path | Description |
|---|---|---|
GET |
/api/dashboard/widgets |
List the caller's widgets for a domain |
POST |
/api/dashboard/widgets |
Add a widget |
PATCH |
/api/dashboard/widgets/:id |
Update a widget |
DELETE |
/api/dashboard/widgets/:id |
Remove a widget |
Create:
{
"type": "task_progress",
"title": "Sprint progress",
"config": { "projectId": "..." },
"layout": { "x": 0, "y": 0, "w": 4, "h": 2 }
}
type is required. layout defaults to { x: 0, y: 0, w: 2, h: 2 }.
Agents
Agents are API-authenticated assistants scoped to a domain. Creating one generates an apiKey (a UUID) that the agent presents as a bearer token. The MCP endpoint accepts the same key.
Permission tiers: full_access, read_only, content_creator, task_manager, custom.
| Method | Path | Description |
|---|---|---|
GET |
/api/agents |
List agents |
POST |
/api/agents |
Create an agent |
GET |
/api/agents/:id |
Get an agent |
PATCH |
/api/agents/:id |
Update an agent |
DELETE |
/api/agents/:id |
Delete an agent |
POST |
/api/agents/:id/permissions |
Set permission tier and custom permissions |
GET |
/api/agents/:id/permissions |
Get permissions |
GET |
/api/agents/activity |
Activity across all agents in a domain |
GET |
/api/agents/:id/activity |
Activity for one agent (_all for all) |
List
Query parameters: domain, q (match on name), sort (created, updated, name; default -created), page, perPage.
Create
{
"name": "Code Assistant",
"description": "Reviews code and suggests changes",
"status": "active",
"permissionTier": "read_only",
"customPermissions": [],
"tags": ["assistant"],
"config": {}
}
name and domain are required. Response (201) includes the generated apiKey. Store it; the API returns it only at creation.
Activity
GET /api/agents/activity and GET /api/agents/:id/activity accept action, from, to (ISO timestamps; a bare YYYY-MM-DD bounds the whole day), limit (default 100, max 500), and domain. GET /api/agents/_all/activity is equivalent to /api/agents/activity. Activity rows carry agentId, action, entityType, entityId, details, success, errorMessage, createdAt.
Webhooks
Webhooks are domain-scoped outgoing notifications. Creating or updating tasks, habits, projects, and notes enqueues deliveries for matching webhooks. A background worker performs the HTTP delivery and records the result in webhook_deliveries.
| Method | Path | Description |
|---|---|---|
GET |
/api/webhooks |
List webhooks |
POST |
/api/webhooks |
Create a webhook |
PATCH |
/api/webhooks/:id |
Update a webhook |
DELETE |
/api/webhooks/:id |
Delete a webhook |
POST |
/api/webhooks/:id/test |
Enqueue a test delivery |
Create
{
"name": "Task Notifications",
"url": "https://example.com/hook",
"events": ["task.created", "task.updated", "task.completed"],
"secret": "your-webhook-secret",
"active": true,
"retryCount": 3
}
name, url, events, and domain are required. secret enables payload signing. Event names follow <entity>.<action> with entities task, habit, project, note; the test event fires from the test endpoint.
Delivery
The worker POSTs to the webhook URL with:
Content-Type: application/jsonX-Event-Type: <event>X-ProjectE-Signature: <hex HMAC-SHA256>when asecretis set, computed over the raw JSON body
Payload:
{
"event": "task.created",
"entity_type": "task",
"entity_id": "c3d4e5f6-...",
"data": { "title": "Complete documentation" },
"timestamp": "2026-08-10T10:30:00.000Z",
"workspace_id": "b2c3d4e5-..."
}
Deliveries use a 10 second timeout and are recorded in webhook_deliveries with status, status code, response body (first 1000 chars), and attempt count.
POST /api/webhooks/:id/test returns { "success": true, "message": "Test webhook queued" } immediately; the worker performs the delivery.
Canvas
Freeform or graph-mode whiteboards. A canvas has cards positioned on a viewport.
| Method | Path | Description |
|---|---|---|
GET |
/api/canvas |
List canvases |
POST |
/api/canvas |
Create a canvas |
GET |
/api/canvas/:id |
Get a canvas with cards and connections |
PATCH |
/api/canvas/:id |
Update a canvas |
DELETE |
/api/canvas/:id |
Delete a canvas |
POST |
/api/canvas/:id/cards |
Create one card |
PUT |
/api/canvas/:id/cards |
Bulk-replace all cards (primary save path) |
PATCH |
/api/canvas/cards/:cardId |
Update one card |
DELETE |
/api/canvas/cards/:cardId |
Delete one card |
Create:
{
"name": "Brainstorm",
"description": "Q3 roadmap ideas",
"mode": "freeform",
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"tags": ["ideas"],
"background": null
}
name and domain are required. GET /api/canvas/:id returns the canvas plus cards (ordered by zIndex) and connections.
Create a card with { "type": "note", "title": "Idea", "content": "…", "x": 0, "y": 0, "width": 200, "height": 150, "zIndex": 1 }. Defaults: type note, width 200, height 150, zIndex one past the current max.
Bulk save PUT /api/canvas/:id/cards replaces all cards in one transaction:
{
"cards": [
{ "id": "optional-existing-id", "type": "note", "content": "First card", "x": 0, "y": 0 },
{ "type": "note", "content": "Second card", "x": 240, "y": 0 }
]
}
Cards without an id are created; zIndex defaults to the array index. Response is the canvas plus the saved cards. Canvases, cards, and connections are hard-deleted (204).
Daily Notes
One note per calendar day per domain (unique on date + domain_id).
| Method | Path | Description |
|---|---|---|
GET |
/api/daily-notes |
Get a day's note or list all for the domain |
POST |
/api/daily-notes |
Create a note for a date |
PATCH |
/api/daily-notes/:id |
Update a note |
DELETE |
/api/daily-notes/:id |
Delete a note |
GET /api/daily-notes?date=2026-08-10 returns the note object or null. Without date, it returns { items, totalItems } ordered newest first.
Create:
{
"date": "2026-08-10",
"content": "Deep work on the API docs.",
"mood": 8,
"energy": 7,
"customFields": {}
}
date must be YYYY-MM-DD, mood and energy are 1-10. Daily notes are hard-deleted (204).
Tags
Tags are global (no domain column). They attach to tasks, habits, projects, and notes through junction tables.
| Method | Path | Description |
|---|---|---|
GET |
/api/tags |
List tags |
POST |
/api/tags |
Create a tag |
GET |
/api/tags/:id |
Get a tag |
PATCH |
/api/tags/:id |
Update a tag |
DELETE |
/api/tags/:id |
Delete a tag |
List query parameters: page, perPage, sort (name, created, updated; default name), filter (exact match on scope: global, tasks, habits, projects, notes).
Create:
{ "name": "urgent", "color": "#ef4444", "scope": "global" }
name is required, scope defaults to global. Tags are hard-deleted (204).
Custom Fields
Per-domain field definitions attached to entities.
| Method | Path | Description |
|---|---|---|
GET |
/api/custom-fields |
List fields, optionally for one entity type |
POST |
/api/custom-fields |
Create a field |
PATCH |
/api/custom-fields/:id |
Update a field |
DELETE |
/api/custom-fields/:id |
Delete a field |
GET /api/custom-fields?entity=task filters by entity type (task, habit, project, note, and so on). Results are ordered by sortOrder, then name.
Create:
{
"name": "Client",
"type": "text",
"entityType": "task",
"required": false,
"options": [],
"defaultValue": null,
"sortOrder": 0
}
name, entityType, and domain are required. Fields are hard-deleted (204).
Error Log
Server-side error log, useful for admin screens.
| Method | Path | Description |
|---|---|---|
GET |
/api/error-log |
List recent errors |
DELETE |
/api/error-log |
Clear all error logs |
GET /api/error-log?level=error&limit=50 filters by level and caps at 200 rows (default 50). Rows carry level, source, message, stackTrace, metadata, resolved, timestamps. DELETE returns { "deleted": <count> }.
Analytics
All analytics endpoints accept range (days, default 30) and domain. Responses include a short Cache-Control header. None paginate.
| Method | Path | Description |
|---|---|---|
GET |
/api/analytics/productivity |
Task completion rate over the period |
GET |
/api/analytics/habits |
Habit consistency, active streaks, best streak |
GET |
/api/analytics/projects |
Per-project task totals and progress |
GET |
/api/analytics/daily |
Daily task created/completed time series |
Productivity:
{
"taskCompletionRate": 75,
"totalTasks": 40,
"completedTasks": 30,
"period": 30
}
Habits:
{
"habitConsistency": 82,
"totalHabits": 6,
"totalLogs": 148,
"activeStreaks": 5,
"bestStreak": 21,
"period": 30
}
Projects:
{
"projects": [
{ "id": "...", "name": "Website Redesign", "totalTasks": 12, "completedTasks": 9, "progress": 0.75 }
],
"totalProjects": 3,
"period": 30
}
Daily returns { items: [{ date: "2026-07-12", created: 2, completed: 1 }, ...], period } with one bucket per day across the range.
Notifications
The notification bell feed. Backed by activity_feed; there is no read/unread state yet, so count serves as the unread badge. graph_edge rows are excluded.
GET /api/notifications
Query parameters: workspace_id (defaults to active domain), limit (default 20, max 100).
Returns activity from the last 7 days, newest first:
{
"items": [
{
"id": "...",
"actor": "User Name",
"action": "completed",
"entityType": "task",
"entityId": "c3d4e5f6-...",
"changes": { "previousStatus": "in_progress", "newStatus": "done" },
"workspaceId": "b2c3d4e5-...",
"createdAt": "2026-08-10T12:00:00.000Z"
}
],
"count": 3
}
Export and Import
JSON backup and restore, scoped to one domain.
GET /api/export
Lists the exportable collections without exporting anything:
{
"collections": [
{ "name": "tasks", "label": "Tasks" },
{ "name": "habits", "label": "Habits" },
{ "name": "projects", "label": "Projects" },
{ "name": "notes", "label": "Notes" },
{ "name": "tags", "label": "Tags" },
{ "name": "agents", "label": "Agents" },
{ "name": "webhooks", "label": "Webhooks" }
]
}
POST /api/export
Body { "collections": ["tasks", "notes"], "domain": "<domainId>" }. collections defaults to all seven; domain defaults to the active domain. Returns a JSON dump:
{
"version": "1.0",
"exportedAt": "2026-08-10T10:30:00.000Z",
"tasks": [],
"notes": []
}
Tags are exported only when used by the domain's entities. Soft-deleted rows are excluded. A failed collection exports as an empty array rather than failing the whole request.
POST /api/import
Body { "version": "1.0", "domain": "<domainId>", "tasks": [...], "habits": [...], ... }. version is required. The target domain comes from body.domain or body.domain_id (or the active domain) and every imported entity is forced into it; payload-supplied domain_id values on individual rows are ignored.
{
"success": true,
"imported": 12,
"failed": 0,
"results": [
{ "collection": "tasks", "imported": 10, "failed": 0, "errors": [] },
{ "collection": "notes", "imported": 2, "failed": 0, "errors": [] }
]
}
success is true when nothing failed. Per-collection errors hold up to 5 messages.
Realtime (SSE)
GET /api/realtime
Server-sent events stream backed by PostgreSQL LISTEN/NOTIFY on the project_e_events channel. Requires authentication.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
workspace_id |
string | Only forward events for this workspace. When omitted, all workspaces' events are forwarded. |
The ownership check is an IDOR guard: when workspace_id is supplied, the caller must own that workspace, or the request is rejected before the stream opens.
On connect the server sends a connected event, then forwards activity events:
data: {"type":"connected","workspace_id":"b2c3d4e5-..."}
data: {"type":"task","action":"created","id":"c3d4e5f6-...","workspace_id":"b2c3d4e5-..."}
A :ping comment arrives every 30 seconds to keep the connection alive. Events are plain data: frames, not named events. Unauthenticated requests get 401 with { "error": "Unauthorized" }.
MCP
Model Context Protocol over JSON-RPC 2.0. POST /api/mcp is a stateless HTTP endpoint; there are no sessions to establish. Requires an API key in the Authorization: Bearer <apiKey> header. The key is validated against the api_keys table (SHA-256 hash comparison). JWT and cookie auth are not accepted here.
Requests and responses use the JSON-RPC 2.0 envelope:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "tasks.list", "arguments": { "domain_id": "b2c3d4e5-...", "state_group": "unstarted" } },
"id": 1
}
{
"jsonrpc": "2.0",
"result": { "content": [{ "type": "text", "text": "{\"items\":[...],\"total\":3}" }] },
"id": 1
}
Tool results are JSON strings inside content[0].text. Tool calls that pass domain_id or workspace_id are ownership-checked before execution.
Methods:
| Method | Purpose |
|---|---|
initialize |
Negotiate protocol version 2024-11-05, server project-e v1.0.0 |
tools/list |
List available tools and schemas |
tools/call |
Invoke a tool |
resources/list |
List resource URIs |
resources/read |
Read a resource URI (stub) |
server/discover |
Legacy discovery: server info plus all tools |
Tools:
| Tool | Description |
|---|---|
tasks.list |
List tasks with optional filters |
tasks.create |
Create a task |
tasks.update |
Update a task |
tasks.delete |
Delete a task |
tasks.complete |
Mark a task done |
habits.list |
List habits |
habits.create |
Create a habit |
habits.complete |
Log a habit completion |
projects.list |
List projects |
projects.create |
Create a project |
notes.list |
List notes |
notes.create |
Create a note |
notes.update |
Update a note |
notes.search |
Search notes |
domains.list |
List domains |
domains.create |
Create a domain |
search.query |
Cross-entity search |
activity.list |
List activity feed entries |
Errors use JSON-RPC codes:
| Code | Meaning |
|---|---|
-32700 |
Parse error |
-32600 |
Invalid request |
-32601 |
Method or tool not found |
-32602 |
Invalid params (including missing required args) |
-32603 |
Internal error |
-32001 |
Unauthorized (missing or invalid API key, HTTP 401) |
GET /api/mcp returns HTTP 405; the endpoint only accepts POST.
Rate Limiting
The API does not enforce rate limiting at the application level. Configure limits at the reverse proxy or infrastructure layer.