# Task activity API

> Unified activity and comment feed, activity_type values, JSONB content shapes, diff/revert semantics, and list/mutation endpoints.

- Repository: Paca-AI/paca
- GitHub: https://github.com/Paca-AI/paca
- Human docs: https://grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25
- Complete Markdown: https://grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/llms-full.txt

## Source Files

- `docs/api/task-activity.md`
- `services/api/internal/transport/http/handler/task_handler.go`
- `docs/architecture/database-schema.md`
- `apps/mcp/src/tools/task-activity-tools.ts`
- `apps/web/src/lib/diff-utils.ts`
- `apps/web/src/components/projects/docs/doc-activity-pane.tsx`

---

---
title: "Task activity API"
description: "Unified activity and comment feed, activity_type values, JSONB content shapes, diff/revert semantics, and list/mutation endpoints."
---

Every task exposes a single chronological feed of system-generated change events and user-authored comments. Both kinds of entry live in the `task_activities` PostgreSQL table and are returned by one list endpoint (`GET /api/v1/projects/:projectId/tasks/:taskId/activities`), so the web UI, MCP tools, and integrations can render one unified timeline. System events are published to the Valkey stream `paca.task_activities` and persisted by the `ActivityConsumer` worker; comments write directly to the database and fan out over realtime pub/sub only.

## Data model

The `task_activities` table stores one row per feed entry:

| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | Primary key |
| `task_id` | UUID | FK → `tasks(id)`, cascade on delete |
| `actor_id` | UUID (nullable) | FK → `project_members(id)`, not `users(id)` |
| `activity_type` | TEXT | Discriminator for rendering and filtering |
| `content` | JSONB | Type-specific payload; default `{}` |
| `created_at` | TIMESTAMPTZ | Sort key (oldest first in list) |
| `updated_at` | TIMESTAMPTZ | Updated on comment edits |
| `deleted_at` | TIMESTAMPTZ (nullable) | Soft-delete for comments only |

Index `(task_id, created_at)` supports efficient chronological listing. List queries join `project_members` → `users` (human actors) and `project_members` → `agents` (agent actors) to populate `actor_name` and `actor_username` in API responses.

### Actor resolution

`actor_id` always references a `project_members` row. Resolution differs by entry source:

- **Comments** — resolved at write time via `FindMemberByActor`, using the authenticated user UUID and optional agent UUID from request context.
- **System events** — the API embeds the authenticated user UUID (or agent UUID) into the Valkey stream payload; `ActivityConsumer` resolves it to `project_members.id` before inserting. If the member was removed before consumption, `actor_id` is stored as `NULL`.

## Activity types

### Core system types

| `activity_type` | Trigger |
|-----------------|---------|
| `task.created` | Task created (`POST …/tasks`) |
| `task.updated` | One or more tracked fields changed (`PATCH …/tasks/:taskId`) |
| `task.deleted` | Task soft-deleted (`DELETE …/tasks/:taskId`) |
| `task.attachment.added` | Domain constant; UI-ready, not yet emitted by attachment handlers |
| `task.attachment.removed` | Domain constant; UI-ready, not yet emitted by attachment handlers |
| `agent.session.started` | AI agent conversation started (task assignment to agent, `@agent` mention in comment, or description-write trigger) |
| `comment` | User or agent posts a comment via the comments sub-resource |

### Plugin-emitted types

Installed WASM plugins may append arbitrary `activity_type` values (for example `task.checklist.created`) through the `paca.activity_record` host function. Plugin payloads must include a `_description` string in `content` for human-readable display in the web UI fallback renderer. The host derives `actor_id` and `project_id` from the authenticated request context and rejects cross-project or spoofed actor values.

## Content shapes (JSONB)

### `task.created`

```json
{ "title": "Implement login" }
```

### `task.updated`

```json
{
  "changes": [
    { "field": "title", "old": "Old title", "new": "New title" },
    { "field": "status", "old": "Todo", "new": "In Progress" },
    { "field": "assignee", "old": "", "new": "550e8400-e29b-41d4-a716-446655440000" }
  ]
}
```

Tracked fields recorded in `changes` (field names as stored, not DB column names):

| Field | `old` / `new` value shape |
|-------|---------------------------|
| `title` | string |
| `status` | status **name** (resolved at record time) |
| `task_type` | type **name** (resolved at record time) |
| `importance` | integer |
| `story_points` | integer or null |
| `assignee` | project member UUID string, or empty string for cleared |
| `reporter` | project member UUID string, or empty string for cleared |
| `sprint` | sprint UUID string, or empty string for cleared |
| `parent_task` | parent task UUID string, or empty string for cleared |
| `start_date` | `YYYY-MM-DD` string, or empty string for cleared |
| `due_date` | `YYYY-MM-DD` string, or empty string for cleared |
| `description` | BlockNote blocks array |
| `tags` | string array |
| `custom_fields` | field present with no `old`/`new` (change detected only) |

If a patch produces no actual changes, no `task.updated` entry is recorded.

### `task.deleted`

```json
{}
```

### `task.attachment.added` / `task.attachment.removed` (documented shape)

```json
{ "file_name": "screenshot.png", "file_size": 102400 }
```

### `agent.session.started`

```json
{
  "conversation_id": "550e8400-e29b-41d4-a716-446655440000",
  "agent_id": "660e8400-e29b-41d4-a716-446655440001"
}
```

### `comment`

Preferred format — BlockNote blocks array:

```json
[
  {
    "id": "block-1",
    "type": "paragraph",
    "content": [{ "type": "text", "text": "Looks good. Ready to review." }]
  }
]
```

Legacy format (still accepted for reads and mention extraction):

```json
{ "text": "Looks good. Ready to review." }
```

Comment `content` must be a non-empty BlockNote array or a legacy `{ "text": "..." }` object. Bare strings, numbers, and empty arrays are rejected with `activity: comment content must not be empty`.

## Persistence paths

```mermaid
sequenceDiagram
    participant API as API handler
    participant Stream as Valkey stream<br/>paca.task_activities
    participant Consumer as ActivityConsumer
    participant DB as PostgreSQL<br/>task_activities
    participant RT as Valkey pub/sub<br/>paca.events

    Note over API,DB: System events (task.created, task.updated, plugins, agent.session.started)
    API->>Stream: XADD via RecordActivity
    API->>RT: Publish realtime notification
    Stream->>Consumer: XREADGROUP (api.activity_writer)
    Consumer->>DB: INSERT (actor resolved to project_members.id)

    Note over API,DB: Comments (add / update / delete)
    API->>DB: INSERT / UPDATE / soft-delete directly
    API->>RT: Publish task.comment.* only (no stream write)
```

| Operation | Database write | Valkey stream | Realtime topic |
|-----------|---------------|---------------|----------------|
| `task.created` / `task.updated` / `task.deleted` | Via consumer | `paca.task_activities` | `task.created`, `task.updated`, `task.deleted` |
| Plugin `activity_record` | Via consumer | `paca.task_activities` | Plugin `activity_type` |
| `agent.session.started` | Via consumer | `paca.task_activities` | `agent.session.started` |
| Comment add | Direct INSERT | — | `task.comment.added` |
| Comment update | Direct UPDATE | — | `task.comment.updated` |
| Comment delete | Soft-delete | — | `task.comment.deleted` |

Consumer group: `api.activity_writer`. Consumer name: `api.activity_writer.{hostname}`.

## REST endpoints

All paths are under `/api/v1/projects/:projectId/tasks/:taskId`. Authentication is required (session cookie or bearer API key). Responses use the standard `{ "success": true, "data": … }` envelope.

### List activities

:::endpoint GET /api/v1/projects/:projectId/tasks/:taskId/activities
Returns all non-deleted activities and comments for a task, sorted oldest → newest. Requires `tasks.read` on the project (or anonymous read when the project is public).
:::

<ParamField query="projectId" type="uuid" required>
Project UUID.
</ParamField>

<ParamField query="taskId" type="uuid" required>
Task UUID.
</ParamField>

<ResponseField name="items" type="ActivityResponse[]">
Chronological activity entries.
</ResponseField>

<ResponseField name="items[].id" type="uuid">
Activity entry ID. For comments, this is also the `commentId` for edit/delete.
</ResponseField>

<ResponseField name="items[].actor_id" type="uuid | null">
`project_members.id` of the actor.
</ResponseField>

<ResponseField name="items[].actor_name" type="string">
Denormalized display name (user full name or agent name).
</ResponseField>

<ResponseField name="items[].actor_username" type="string">
Denormalized username or agent handle.
</ResponseField>

<ResponseField name="items[].activity_type" type="string">
One of the activity type constants above, or a plugin-specific value.
</ResponseField>

<ResponseField name="items[].content" type="object">
JSONB payload; shape depends on `activity_type`.
</ResponseField>

<RequestExample>

```bash
curl -s -H "Authorization: Bearer $PACA_API_KEY" \
  "$PACA_URL/api/v1/projects/$PROJECT_ID/tasks/$TASK_ID/activities"
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "a1b2c3d4-…",
        "task_id": "task-uuid",
        "actor_id": "member-uuid",
        "actor_name": "Jane Doe",
        "actor_username": "jane",
        "activity_type": "task.created",
        "content": { "title": "Login screen" },
        "created_at": "2026-04-01T10:00:00Z",
        "updated_at": "2026-04-01T10:00:00Z"
      },
      {
        "id": "comment-uuid",
        "task_id": "task-uuid",
        "actor_id": "member-uuid",
        "actor_name": "Jane Doe",
        "actor_username": "jane",
        "activity_type": "comment",
        "content": [
          {
            "type": "paragraph",
            "content": [{ "type": "text", "text": "Ready for review." }]
          }
        ],
        "created_at": "2026-04-01T11:00:00Z",
        "updated_at": "2026-04-01T11:00:00Z"
      }
    ]
  }
}
```

</ResponseExample>

### Post a comment

:::endpoint POST /api/v1/projects/:projectId/tasks/:taskId/activities/comments
Creates a user comment. Requires `tasks.write`. Returns `201 Created` with the new activity entry. Triggers `@mention` notifications for human members and may start an agent conversation (recording `agent.session.started`) when an agent member is mentioned.
:::

<ParamField body="content" type="BlockNote[] | { text: string }" required>
Comment body. Prefer a BlockNote blocks array; legacy `{ "text": "…" }` is accepted.
</ParamField>

<RequestExample>

```bash
curl -s -X POST -H "Authorization: Bearer $PACA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":[{"type":"paragraph","content":[{"type":"text","text":"Ship it."}]}]}' \
  "$PACA_URL/api/v1/projects/$PROJECT_ID/tasks/$TASK_ID/activities/comments"
```

</RequestExample>

### Edit a comment

:::endpoint PATCH /api/v1/projects/:projectId/tasks/:taskId/activities/comments/:commentId
Updates comment content. Requires `tasks.write`. Only the original author (matching `project_members.id`) may edit. Returns `200 OK`.
:::

<ParamField path="commentId" type="uuid" required>
Comment activity entry ID from the list response.
</ParamField>

<ParamField body="content" type="BlockNote[] | { text: string }" required>
Replacement comment body.
</ParamField>

### Delete a comment

:::endpoint DELETE /api/v1/projects/:projectId/tasks/:taskId/activities/comments/:commentId
Soft-deletes a comment (`deleted_at` set). Requires `tasks.write`. Only the original author may delete. Returns `204 No Content`. Deleted comments are excluded from list responses.
:::

### Error cases

| Condition | Error |
|-----------|-------|
| Unauthenticated comment mutation | `401` unauthenticated |
| Missing `tasks.read` | `403` forbidden |
| Missing `tasks.write` | `403` forbidden |
| Empty or invalid comment content | `activity: comment content must not be empty` |
| Edit/delete non-comment entry | `activity: this entry is not a comment and cannot be edited` |
| Edit/delete another user's comment | `activity: only the author can modify this comment` |
| Invalid `commentId` UUID | `400` invalid comment id |

## Diff and revert semantics

Diff and revert are **client-side** features in the web UI — there is no dedicated revert API endpoint. A revert issues a normal `PATCH …/tasks/:taskId` with field values taken from the activity entry's `content.changes[].old`, which in turn records a new `task.updated` activity.

### View diff

Available for `task.updated` entries where a `description` change includes both `old` and `new` BlockNote content. The UI:

1. Flattens each BlockNote document to lines via `blockNoteToLines`.
2. Computes an LCS-based line diff (`computeLineDiff`).
3. Renders added (`+`), removed (`-`), and unchanged lines in a dialog.

Only text extracted from inline `text` nodes is compared; structural block metadata is not diffed.

### Revert

A `task.updated` entry is revertable when at least one change object includes an `old` key. The web client maps `old` values back to a task patch:

| Change field | Revert mapping |
|--------------|----------------|
| `status` | Lookup status ID by **name** from cached project statuses |
| `task_type` | Lookup type ID by **name** from cached project types |
| `title`, `importance` | Direct assignment |
| `assignee`, `reporter`, `sprint`, `parent_task` | Member/sprint UUID strings; empty → `null` |
| `start_date`, `due_date` | ISO date strings; empty → `null` |
| `description` | BlockNote array or `null` |
| `tags` | String array |
| `custom_fields` | Not revertable (`old`/`new` not stored) |

Revert requires `tasks.write` (same as editing the task). Failed reverts surface a client error; the feed is invalidated on success so the new `task.updated` entry appears.

## Realtime integration

Activity-related Socket.IO events arrive on the `paca.events` pub/sub channel after clients join a project room. Relevant `type` values:

| Realtime `type` | When emitted |
|-----------------|--------------|
| `task.created`, `task.updated`, `task.deleted` | System activity recorded |
| `task.comment.added`, `task.comment.updated`, `task.comment.deleted` | Comment mutations |
| `agent.session.started` | Agent conversation started on a task |
| Plugin `activity_type` | Plugin `activity_record` |

The web client invalidates task activity queries when it receives `agent.session.started` or any `task.*` event for the project.

## MCP tools

The `@paca-ai/paca-mcp` server exposes four task-activity tools that wrap the same REST endpoints:

| Tool | REST equivalent |
|------|-----------------|
| `list_task_activities` | `GET …/activities` |
| `add_task_comment` | `POST …/activities/comments` |
| `update_task_comment` | `PATCH …/activities/comments/:commentId` |
| `delete_task_comment` | `DELETE …/activities/comments/:commentId` |

MCP comment tools accept a Markdown `content` string and convert it to BlockNote blocks before posting. Listed activities render comment bodies as Markdown via `blocknoteToMarkdown`; system events render `content` as formatted JSON.

<Steps>
<Step title="List activities for context">

Call `list_task_activities` with `projectId` and `taskId` UUIDs (not human-readable names). Use the returned `id` of `activity_type: "comment"` entries as `commentId` for edits and deletes.

</Step>
<Step title="Post or update a comment">

Use `add_task_comment` or `update_task_comment` with Markdown content. The MCP client converts it to BlockNote blocks automatically.

</Step>
<Step title="Verify in the feed">

Re-list activities or watch realtime `task.comment.*` events to confirm the comment appears in chronological order.

</Step>
</Steps>

## Permission summary

| Action | Permission |
|--------|------------|
| List activities | `tasks.read` (or public project read) |
| Post comment | `tasks.write` |
| Edit own comment | `tasks.write` + author match |
| Delete own comment | `tasks.write` + author match |
| Revert via task patch | `tasks.write` |

Agent API keys with `tasks.read` / `tasks.write` can list and mutate comments; agent identity is resolved through `project_members` with `member_type = agent`.

## Related pages

<CardGroup cols={2}>
<Card title="REST API reference" href="/rest-api">
Versioned paths, auth envelopes, pagination, and the full endpoint catalog.
</Card>
<Card title="Realtime events" href="/realtime-events">
Socket.IO rooms, Valkey pub/sub fan-out, and activity event payloads.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Input schemas and constraints for `list_task_activities` and comment tools.
</Card>
<Card title="AI agents" href="/ai-agents">
How `@agent` mentions in comments trigger conversations and `agent.session.started` entries.
</Card>
<Card title="Plugin system" href="/plugin-system">
WASM `activity_record` host function and plugin-emitted activity types.
</Card>
</CardGroup>
