# Tasklife Public API

Reference for agent workflows and automation.

## Quickstart

**Base URL**

`https://dave-dev-api.tasklife.com/api/v1/`

**Auth Header (required)**

```http
Authorization: Bearer YOUR_API_KEY
```

---

## Workflow naming (column targets + lifecycle fields)

Use these names consistently in automation payloads and prompts.

### Target column keys (`target_columns`)
- `todo` — backlog intake column
- `dev_start` — where coding starts (typically In Progress)
- `dev_done` — where coding hands off to QA (typically To QA)
- `dev_blocked` — blocked destination for dev
- `qa_pass` — QA passed destination (typically Sal Review)
- `qa_fail` — QA failed/issues destination (typically QA issues)
- `done` — final completed state

### Lifecycle config fields (column settings)
- `lifecycle_enabled`
- `lifecycle_start_target_column_id`
- `lifecycle_blocked_target_column_id`
- `lifecycle_complete_target_column_id`
- `lifecycle_has_issues_target_column_id`
- `lifecycle_get_prompt`
- `lifecycle_put_prompt`

> Note: `qa_fail` is a workflow key. It commonly maps to the column chosen in `lifecycle_has_issues_target_column_id`, but they are different fields.

## Pages (CMS)

### Helpful guide: writing CMS body content (pages + tasks)
When making pages in the CMS (or writing long-form task body content), do **not** include metadata that CMS already owns.

1. The record name is the title — **do not add a `# Title` line** in the body.
2. CMS already has timestamps — **do not add generated timestamp lines** in the body.
3. CMS already has status — **do not add status metadata lines** in the body.

Keep body content focused on the actual substance/instructions only.

### Create page
- **POST** `/pages`
- Body (JSON):
  - `title` (string, required, 1-255)
  - `content` (string, optional, Markdown supported)
  - `parent_id` (integer, optional, use `0` for root)
- Returns: `201` + created page object

### Search pages
- **GET** `/search?q=...`
- Query:
  - `q` (string, required)
- Returns: `200` + matching pages array

### List pages
- **GET** `/pages`
- Query (optional):
  - `parent_id` (integer)
  - `status` (`active` | `inactive`)
  - `visibility` (`public` | `private`)
- Returns: `200` + page array

### Get page
- **GET** `/pages/{id}`
- Path:
  - `id` (integer)
- Returns: `200` + page object

### Update page
- **PUT** `/pages/{id}`
- Path:
  - `id` (integer)
- Body (JSON, any editable fields):
  - `title` (string)
  - `content` (string, Markdown supported)
  - `status` (`active` | `inactive`)
  - `visibility` (`public` | `private`)
  - `parent_id` (integer)
  - `append` (boolean)
  - `separator` (string)
- Returns: `200` + updated page object

### Delete page
- **DELETE** `/pages/{id}`
- Returns: `200/204`

### Get page tree
- **GET** `/pages/tree`
- Returns: `200` + nested parent/child tree

### Get page versions
- **GET** `/pages/{id}/versions`
- Returns: `200` + versions array

---

## Projects

### List projects
- **GET** `/projects`
- Returns: `200` + projects array

### Create project
- **POST** `/projects`
- Body:
  - `name` (string, required)
  - `description` (string, optional)
- Returns: `201` + created project object

### Get project (with columns)
- **GET** `/projects/{id}`
- Returns: `200` + project + columns
- Notes:
  - Includes `agent_team_active` (1 = Active, 0 = Inactive) when available.

### Update project
- **PUT/PATCH** `/projects/{id}`
- Body (optional fields):
  - `name` (string)
  - `description` (string)
  - `agent_team_active` (integer, `0` | `1`, admin-gated)
- Returns: `200` + updated project

### List project columns
- **GET** `/projects/{id}/columns`
- Returns: `200` + columns array (`id`, `name`, `sort_order`)

### Create column
- **POST** `/projects/{id}/columns`
- Body:
  - `name` (string, required)
  - `sort_order` (integer, optional)
- Returns: `201` + created column

### Update column
- **PUT/PATCH** `/projects/{id}/columns/{column_id}`
- Body (optional):
  - `name` (string)
  - `sort_order` (integer)
- Returns: `200` + updated column

### Delete column
- **DELETE** `/projects/{id}/columns/{column_id}`
- Returns: `200`
- Notes: Returns `400` if column still has tasks

---

## Products

### List products
- **GET** `/products`
- Returns: `200` + products array
- Notes:
  - Each product includes nested `epics[]`
  - Org-scoped to the authenticated API key

### Create product
- **POST** `/products`
- Body:
  - `name` (string, required)
  - `description` (string, optional)
  - `color_hex` (string `#RRGGBB`, optional, defaults to `#2563EB`)
- Returns: `201` + created product

### Get product
- **GET** `/products/{id}`
- Returns: `200` + product object with nested `epics[]`

### Update product
- **PUT/PATCH** `/products/{id}`
- Body (optional fields):
  - `name` (string)
  - `description` (string)
  - `color_hex` (string `#RRGGBB`)
- Returns: `200` + updated product

### Delete product
- **DELETE** `/products/{id}`
- Returns: `200`
- Notes:
  - Cascades to nested epics through the existing FK

### List product epics
- **GET** `/products/{id}/epics`
- Returns: `200` + epics array for that product

---

## Epics

### List epics
- **GET** `/epics`
- Query (optional):
  - `product_id` (integer)
- Returns: `200` + epics array

### Create epic
- **POST** `/epics`
- Body:
  - `product_id` (integer, required)
  - `name` (string, required)
  - `description` (string, optional)
  - `sort_order` (integer, optional, defaults to next slot within product)
- Returns: `201` + created epic

### Get epic
- **GET** `/epics/{id}`
- Returns: `200` + epic object

### Update epic
- **PUT/PATCH** `/epics/{id}`
- Body (optional fields):
  - `product_id` (integer)
  - `name` (string)
  - `description` (string)
  - `sort_order` (integer)
- Returns: `200` + updated epic

### Delete epic
- **DELETE** `/epics/{id}`
- Returns: `200`

---

## Tasks

### List tasks
- **GET** `/tasks`
- Query (optional):
  - `project_id` (integer)
  - `status_id` (integer)
  - `assigned_to` (integer)
  - `limit` (integer)
  - `offset` (integer)
- Returns: `200` + tasks array
- Response includes child-linkage fields for board/automation UX:
  - `has_children` (boolean): true when the task has one or more child tasks
  - `child_count` (integer): number of direct child tasks (0 when none)
  - Schema-safe fallback: when `tasks.parent_task_id` is unavailable, `has_children=false` and `child_count=0`

### Create task
- **POST** `/tasks`
- Body:
  - `title` (string, required)
  - `description` (string, optional)
  - `project_id` (integer, optional)
  - `status_id` (integer, optional)
  - `assigned_to` (integer, optional)
  - `due_date` (`YYYY-MM-DD`, optional)
- Returns: `201` + created task

### Get task
- **GET** `/tasks/{id}`
- Returns: `200` + task object
- Includes:
  - `has_children` (boolean)
  - `child_count` (integer)

### Board child-task indicator behavior
- UI clients can render a child-task indicator when `has_children=true`.
- Recommended card treatment:
  - show an icon after the task ID label
  - render task ID in bold when the task has children
- For richer badges/tooltips, use `child_count`.

### Update task
- **PUT** `/tasks/{id}`
- Body: any editable fields from create payload
- Returns: `200` + updated task

### Move task status
- **PUT** `/tasks/{id}/status`
- Body:
  - `status_id` (integer, required)
- Returns: `200` + updated task/status

### Delete task
- **DELETE** `/tasks/{id}`
- Returns: `200/204`

---

## Users

### List org users
- **GET** `/users`
- Returns: `200` + users array

---

## Comments

### List comments
- **GET** `/comments`
- Query:
  - `entity_type` (`task` | `page`, required)
  - `entity_id` (integer, required)
  - `parent_comment_id` (integer, optional)
- Returns: `200` + comments array

### Create comment
- **POST** `/comments`
- Content-Type: `application/json` or `multipart/form-data`
- Body:
  - `entity_type` (`task` | `page`, required)
  - `entity_id` (integer, required)
  - `body_markdown` (string, required)
  - `parent_comment_id` (integer, optional)
  - `file` (single upload, optional if `files[]` provided; multipart only)
  - `files[]` (multi-upload, optional if `file` provided; multipart only)
- Returns: `201` + created `comment_id`, `comment`, `attachments[]`, `attachment_count`
- Mention entities:
  - Request accepts `mentions[]` (preferred) and legacy `entities[]`
  - Response includes both `mentions[]` and `entities[]` (same data, for backward compatibility)

### Add attachments to an existing comment
- **POST** `/comments/{id}/attachments`
- Path:
  - `id` (integer)
- Content-Type: `multipart/form-data`
- Body:
  - `file` (single upload, optional if `files[]` provided)
  - `files[]` (multi-upload, optional if `file` provided)
- Returns: `200` + updated `comment`, `attachments[]`, `attachment_count`

### Update comment
- **PUT/PATCH** `/comments/{id}`
- Path:
  - `id` (integer)
- Body:
  - `body_markdown` (string, required)
- Returns: `200` + updated comment

### Examples: add a comment

#### Add comment to a task
```bash
curl -X POST "https://dave-dev-api.tasklife.com/api/v1/comments" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entity_type": "task",
    "entity_id": 159,
    "body_markdown": "Looks good — shipping this after QA."
  }'
```

#### Add comment + inline media in one call
```bash
curl -X POST "https://dave-dev-api.tasklife.com/api/v1/comments" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "entity_type=task" \
  -F "entity_id=159" \
  -F "body_markdown=QA evidence attached." \
  -F "files[]=@/tmp/screenshot-1.png" \
  -F "files[]=@/tmp/screenshot-2.png"
```

#### Add comment to a page
```bash
curl -X POST "https://dave-dev-api.tasklife.com/api/v1/comments" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entity_type": "page",
    "entity_id": 167,
    "body_markdown": "Updated docs with API examples."
  }'
```

#### Attach media to existing comment
```bash
curl -X POST "https://dave-dev-api.tasklife.com/api/v1/comments/123/attachments" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "files[]=@/tmp/evidence.png"
```

> Note: nested route style like `POST /tasks/{id}/comments` is not supported yet. Use `POST /comments` with `entity_type` + `entity_id`.

### Delete comment
- **DELETE** `/comments/{id}`
- Path:
  - `id` (integer)
- Returns: `200` + deleted/soft-deleted status

---

## Media

### Upload media (single or multiple)
- **POST** `/media/upload`
- Content-Type: `multipart/form-data`
- Fields:
  - `entity_type` (`task` | `page` | `comment`, required)
  - `entity_id` (integer, required)
  - `file` (single upload, optional if `files[]` provided)
  - `files[]` (multi-upload, optional if `file` provided)
- Returns: `201` + `data.items[]` with markdown-ready fields.

Each `items[]` row includes:
- `media_id`
- `url`
- `is_image`
- `markdown`
- `markdown_image`
- `markdown_file`
- `file_name`
- `mime_type`
- `file_size`

Back-compat: response also mirrors first upload in `data.media`, `data.markdown`, `data.url`.

### Attach existing media to entity
- **POST** `/media/attach`
- Body (JSON):
  - `media_id` (integer, required)
  - `entity_type` (`task` | `page` | `comment`, required)
  - `entity_id` (integer, required)
- Returns: `200` success

### List entity media
- **GET** `/media?entity_type=...&entity_id=...`
- Returns: `200` + `data.media[]` and `data.attachments[]`

---

## Webhook Outbox (agent wake backbone)

Outbox-backed webhook delivery powers agent wake notifications for task assignment/comment events.

- Producer hooks enqueue rows into `webhook_delivery_outbox`.
- Worker dispatches with signature + retry/backoff: `php scripts/process_webhook_outbox.php`.
- Delivery attempts are audited in `webhook_delivery_attempts`.
- Operator tools:
  - `php scripts/webhook_outbox_report.php <org_id> [limit]`
  - `php scripts/webhook_outbox_replay.php <outbox_id>`
- Full runbook: `docs/api/webhook_outbox_runbook.md`.

### When to use webhooks

Use agent webhooks when you want Tasklife to wake an external agent system whenever work is assigned or updated, without polling the API continuously.

Recommended pattern:
- `task_assigned` is a **wake signal**, not a command to execute that exact task immediately.
- The receiving agent should pull its current queue (for example, "My Tasks") and choose the next eligible item using board rules and lifecycle prompts.
- `task_comment` should trigger a refresh/re-read of the task because new instructions or feedback may have arrived.
- `comment_mention` should be treated as a directed attention signal for the mentioned agent/user.

### Webhook endpoint requirements

Your receiver should accept:
- `POST` requests with `Content-Type: application/json`
- an optional `Authorization: Bearer <token>` header if you configure bearer auth on the receiving side
- `X-Tasklife-Signature: sha256=<hex>` when a webhook secret is configured

Tasklife signs the raw JSON request body using HMAC-SHA256 and your configured webhook secret.

Verification pseudocode:

```text
expected = hex(HMAC_SHA256(raw_request_body, webhook_secret))
header = X-Tasklife-Signature
require header format: sha256=<hex>
compare with constant-time equality
```

If you use OpenClaw or a similar receiver that expects bearer auth, Tasklife can also deliver to a URL that includes a token query param on your side, while the worker forwards it as an `Authorization: Bearer ...` header.

### Event types and payload shapes

#### `task_assigned`

Purpose:
- wake an execution agent after assignment
- tell the receiver which project/task changed
- provide a prompt-style instruction telling the agent to fetch live state instead of trusting payload-only execution

Example payload:

```json
{
  "event_id": "task_assigned:7:438:48:1775420000123",
  "event_type": "task_assigned",
  "mode": "wake_signal",
  "project_id": 25,
  "task_id": 438,
  "task_name": "Implement webhook receiver",
  "actor_user_id": 12,
  "timestamp": 1775420000,
  "message": "New assignment signal received. Pull My Tasks and process next eligible task (do not execute directly from payload task_id)."
}
```

Notes:
- `mode: "wake_signal"` is intentional.
- The receiver should treat this as a queue refresh signal.
- Tasklife coalesces bursts of assignment events for the same assignee to reduce noisy duplicate wakes.

#### `task_comment`

Purpose:
- notify the assigned agent that a new comment was added by someone else
- prompt the receiver to reload task details/comments and incorporate new direction

Example payload:

```json
{
  "event_id": "task_comment:7:438:991",
  "event_type": "task_comment",
  "project_id": 25,
  "task_id": 438,
  "task_name": "Implement webhook receiver",
  "comment_id": 991,
  "actor_user_id": 12,
  "timestamp": 1775420033,
  "message": "Task comment webhook event for task #438 (comment #991). Retrieve task and process update."
}
```

Notes:
- comment events are not sent when the assignee comments on their own task
- the safe receiver pattern is: fetch live task state, fetch comments, then decide what to do

#### `comment_mention`

Purpose:
- notify a specific mentioned agent/user that they were called into a conversation
- pass only a short snippet for quick routing, not full execution context

Example payload:

```json
{
  "event_id": "comment_mention:7:991:48",
  "event_type": "comment_mention",
  "org_id": 7,
  "entity_type": "task",
  "entity_id": 438,
  "comment_id": 991,
  "mentioned_org_user_id": 48,
  "author_user_id": 12,
  "snippet": "@codingagent please re-check the webhook auth header handling before QA.",
  "timestamp": 1775420033
}
```

Notes:
- `snippet` is trimmed for routing convenience and should not be treated as the only source of truth
- receivers should fetch live entity/comment context before acting on important instructions

### Receiver behavior guidance

To match Tasklife's intended workflow model, external agents should follow these rules:

1. **Do not execute directly from webhook payload alone.**
   Always re-fetch live task/comment state before taking action.
2. **Treat `task_assigned` as a wake signal.**
   Pull your queue and choose the next eligible task using current board state and lifecycle prompts.
3. **Treat `task_comment` as updated context.**
   Re-read comments and task details before continuing prior work.
4. **Use lifecycle prompts as the operational contract.**
   Webhooks tell you something changed; lifecycle prompts tell you how to work.
5. **Make handlers idempotent.**
   Event delivery includes retries/backoff, so receivers should safely ignore duplicates by `event_id` when needed.

### Delivery behavior

Current delivery model:
- exponential retry/backoff on failed deliveries
- dead-letter status after max attempts
- per-event audit rows in `webhook_delivery_attempts`
- short coalescing windows to reduce bursty duplicate wake signals

This means your receiver should be prepared for:
- retries
- occasional duplicate-equivalent wake signals
- out-of-band replays after operator intervention

---

## Agent Users

These endpoints support non-human "agent" principals and credential lifecycle.

### Create agent user
- **POST** `/agents`
- Body:
  - `name` (string, required)
  - `org_id` (integer, optional if implied by key)
  - `description` (string, optional)
- Returns: `201` + created agent user

### Create credential for agent
- **POST** `/agents/{id}/credentials`
- Path:
  - `id` (agent user id)
- Body (optional):
  - `expires_at` (ISO datetime)
- Returns: `201` + created credential

### Mint one-time Agent UI login token
- **POST** `/agents/{id}/token/mint`
- Path:
  - `id` (agent user id)
- Body (JSON, required):
  - `target_url` (absolute URL the agent should land on after login)
  - `ttl_minutes` (integer, optional, default 15, range 1-120)
- Returns: `200` + token metadata and one-time login payload

Example:
```bash
curl -X POST "https://dave-dev-api.tasklife.com/api/v1/agents/23/token/mint" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://dave-dev.tasklife.com/main",
    "ttl_minutes": 15
  }'
```

Expected response shape:
```json
{
  "success": true,
  "data": {
    "token": {
      "id": 123,
      "agent_user_id": 23,
      "target_url": "https://dave-dev.tasklife.com/main",
      "expires_at": "2026-03-16T19:25:00Z"
    },
    "payload": {
      "one_time_url": "https://dave-dev.tasklife.com/agent/login.php?token=...",
      "token": "...",
      "expires_at": "2026-03-16T19:25:00Z"
    }
  }
}
```

Note:
- Keep the mint API host and the `target_url` host in the same environment.
- The API now rejects cross-environment mint requests with `400 target_url host must match the current API environment`.
- Mixing dev minting with a prod `target_url` (or the reverse) can generate a one-time login URL that opens against a different environment than the one that stored the token.

### Revoke Agent UI login token(s)
- **POST** `/agents/{id}/token/revoke`
- Path:
  - `id` (agent user id)
- Body (JSON):
  - `token` (string, required unless `revoke_all_active=true`)
  - `revoke_all_active` (boolean, optional)
- Returns: `200` + revoke summary

### Rotate credential
- **POST** `/agents/{id}/credentials/rotate`
- or **PUT** `/agents/{id}/credentials/rotate`
- Path:
  - `id` (agent user id)
- Returns: `200` + new credential (old active credential is rotated/deactivated)

### Reveal credential
- **GET** `/agents/{id}/credentials/{key_id}/reveal`
- Path:
  - `id` (agent user id)
  - `key_id` (credential/key id)
- Returns: `200` + revealed credential value
- Notes:
  - Admin-gated
  - Reveal action is audited

### Minimal bootstrap payload (recommended)
Use this for agent handoff:

```json
{
  "api_key": "sk_prod_...",
  "api_docs_url": "https://tasklife.com/main/pages/77"
}
```

Agents should discover org/project/status ids dynamically via API responses (do not hardcode).

---

## Machine-readable spec

- OpenAPI JSON: `/docs/openapi.json`
