# Paca Documentation

> Reference for Paca, a self-hosted AI-native project management platform: Docker deployment, REST and Socket.IO contracts, MCP tools, WASM plugins, and OpenHands agent orchestration.

## Context Links

- [Agent index](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/llms.txt)
- [Human interactive docs](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25)
- [GitHub repository](https://github.com/Paca-AI/paca)

## Repository Metadata

- Repository: Paca-AI/paca

- Generated: 2026-06-13T22:14:29.542Z
- Updated: 2026-06-13T22:14:50.201Z
- Runtime: Grok CLI
- Format: Documentation
- Pages: 23

## Page Index

- 01. [Overview](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/01-overview.md) - What Paca exposes, runtime surfaces (web, API, realtime, MCP, ai-agent), and the shortest path from install to first project and API key.
- 02. [Installation](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/02-installation.md) - Prerequisites, install script, manual Docker Compose setup, required secrets, and optional stack scaling (external Postgres, S3, no ai-agent).
- 03. [Quickstart](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/03-quickstart.md) - First successful run: start the stack, log in as admin, create a project, generate an API key, and verify health endpoints.
- 04. [Platform architecture](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/04-platform-architecture.md) - Monorepo runtime areas, service boundaries, Valkey event decoupling, and nginx gateway routing between web, API, realtime, and storage.
- 05. [Interaction views](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/05-interaction-views.md) - Unified view and task-list model for sprint, backlog, and timeline contexts; query parameters, manual ordering, and shared REST paths.
- 06. [AI agents](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/06-ai-agents.md) - Agent members, trigger types, OpenHands conversation lifecycle, Docker isolation, Valkey streams, and how agents participate as project teammates.
- 07. [Plugin system](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/07-plugin-system.md) - WASM backend sandbox, frontend extension points, MCP plugin tools, capability permissions, and marketplace-driven install lifecycle.
- 08. [Deploy production](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/08-deploy-production.md) - Production Docker Compose topology, nginx gateway, storage backends (MinIO vs S3), secret generation, and scaling optional services.
- 09. [Local development](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/09-local-development.md) - Dev Compose stack, hot-reload per service, host-side run commands, infra-only mode, and port map through the nginx gateway.
- 10. [Connect MCP server](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/10-connect-mcp-server.md) - Configure @paca-ai/paca-mcp with npx for Claude Desktop, VS Code, or any MCP client; env vars, agent mode, and plugin tool loading.
- 11. [Claude Code skills](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/11-claude-code-skills.md) - Install /paca slash commands, wire the MCP server, available skill routes, and task-reference resolution patterns from skills/paca.
- 12. [Configure AI agents](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/12-configure-ai-agents.md) - Create agent types and project agents, set LLM and MCP config, wire repository access via plugin adapters, and control conversation lifecycle.
- 13. [Build a plugin](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/13-build-a-plugin.md) - End-to-end plugin authoring: plugin.json manifest, WASM backend, frontend extension points, migrations, build output, and local install.
- 14. [Install marketplace plugins](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/14-install-marketplace-plugins.md) - Install plugins from the Paca-AI/paca-plugins catalog via the admin UI or API; artifact layout, migrations, and filesystem install scripts.
- 15. [REST API reference](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/15-rest-api-reference.md) - Versioned /api/v1 paths, auth cookies and bearer tokens, response envelopes, pagination, and implemented endpoint catalog by resource.
- 16. [Task activity API](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/16-task-activity-api.md) - Unified activity and comment feed, activity_type values, JSONB content shapes, diff/revert semantics, and list/mutation endpoints.
- 17. [Realtime events](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/17-realtime-events.md) - Socket.IO connection auth, Valkey pub/sub fan-out, project and conversation rooms, and domain event payloads including agent monitoring.
- 18. [MCP tools reference](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/18-mcp-tools-reference.md) - All @paca-ai/paca-mcp tool names by category, input schemas, agent-mode constraints, and dynamically loaded plugin tools.
- 19. [Configuration reference](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/19-configuration-reference.md) - Environment variables across API, web gateway, realtime, and ai-agent: JWT, storage, plugins, encryption, cache TTLs, and internal keys.
- 20. [Plugin SDK reference](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/20-plugin-sdk-reference.md) - Typed APIs for @paca-ai/plugin-sdk-react, plugin-sdk-go host bridge, and @paca-ai/plugin-sdk-mcp; extension points and scoped HTTP clients.
- 21. [Troubleshooting](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/21-troubleshooting.md) - Common install and runtime failures: health checks, auth cookies, Valkey connectivity, storage misconfiguration, and MCP connection errors.
- 22. [Upgrade and migration](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/22-upgrade-and-migration.md) - Pull new images, automatic DB migrations on API startup, compose project rename volume migration, and version upgrade verification signals.
- 23. [Contributing](https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/23-contributing.md) - Repository layout, dev prerequisites, PR checklist, test surfaces (Go integration, Playwright e2e), and documentation update expectations.

## Source File Index

- `.github/PULL_REQUEST_TEMPLATE.md`
- `apps/e2e/README.md`
- `apps/mcp/.env.example`
- `apps/mcp/ALL_TOOLS.md`
- `apps/mcp/README.md`
- `apps/mcp/src/api/client.ts`
- `apps/mcp/src/index.ts`
- `apps/mcp/src/permissions.ts`
- `apps/mcp/src/plugin-loader.ts`
- `apps/mcp/src/server.ts`
- `apps/mcp/src/tools/index.ts`
- `apps/mcp/src/tools/project-tools.ts`
- `apps/mcp/src/tools/task-activity-tools.ts`
- `apps/mcp/src/tools/task-tools.ts`
- `apps/web/package.json`
- `apps/web/src/components/projects/docs/doc-activity-pane.tsx`
- `apps/web/src/lib/diff-utils.ts`
- `apps/web/src/lib/interaction-api.ts`
- `apps/web/src/lib/socket-client.ts`
- `CODE_OF_CONDUCT.md`
- `CONTRIBUTING.md`
- `deploy/.env.dev.example`
- `deploy/.env.production.example`
- `deploy/docker-compose.dev.yml`
- `deploy/docker-compose.prod.yml`
- `deploy/nginx/gateway.conf`
- `deploy/README.md`
- `docs/ai-agent/ai-agent-service.md`
- `docs/ai-agent/default-skills.md`
- `docs/ai-agent/overview.md`
- `docs/ai-agent/realtime-events.md`
- `docs/ai-agent/repository-plugin-adapter.md`
- `docs/api/http-design.md`
- `docs/api/README.md`
- `docs/api/task-activity.md`
- `docs/architecture/database-schema.md`
- `docs/architecture/interaction-views.md`
- `docs/architecture/manual-sort-algorithm.md`
- `docs/architecture/overview.md`
- `docs/architecture/repository-structure.md`
- `docs/architecture/service-boundaries.md`
- `docs/guides/claude-code-skill.md`
- `docs/guides/getting-started.md`
- `docs/guides/local-development.md`
- `docs/guides/mcp-server-setup.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/developer-guide.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/marketplace.md`
- `docs/plugins/mcp-plugin-system.md`
- `docs/plugins/overview.md`
- `docs/plugins/sdk-reference.md`
- `docs/product/overview.md`
- `docs/README.md`
- `README.md`
- `ROADMAP.md`
- `scripts/install-claude-skill.sh`
- `scripts/install-local-plugin.sh`
- `scripts/install-plugin.sh`
- `scripts/install.sh`
- `SECURITY.md`
- `services/ai-agent/.env.example`
- `services/ai-agent/src/agent/builder.py`
- `services/ai-agent/src/config.py`
- `services/ai-agent/src/main.py`
- `services/ai-agent/src/worker.py`
- `services/api/.env.example`
- `services/api/.golangci.yml`
- `services/api/docker-entrypoint.sh`
- `services/api/internal/transport/http/dto/plugin_dto.go`
- `services/api/internal/transport/http/handler/agent_handler.go`
- `services/api/internal/transport/http/handler/apikey_handler.go`
- `services/api/internal/transport/http/handler/auth_handler.go`
- `services/api/internal/transport/http/handler/conversation_handler.go`
- `services/api/internal/transport/http/handler/health_handler.go`
- `services/api/internal/transport/http/handler/plugin_handler.go`
- `services/api/internal/transport/http/handler/task_handler.go`
- `services/api/internal/transport/http/handler/view_handler.go`
- `services/api/internal/transport/http/middleware/authz.go`
- `services/api/internal/transport/http/presenter/response.go`
- `services/api/internal/transport/http/router/router.go`
- `services/api/Makefile`
- `services/realtime/.env.example`
- `services/realtime/package.json`
- `services/realtime/README.md`
- `services/realtime/src/server.ts`
- `services/realtime/src/session.ts`
- `services/realtime/src/subscriber.ts`
- `skills/paca-epic/SKILL.md`
- `skills/paca-setup/SKILL.md`
- `skills/paca/SKILL.md`

---

## 01. Overview

> What Paca exposes, runtime surfaces (web, API, realtime, MCP, ai-agent), and the shortest path from install to first project and API key.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/01-overview.md
- Generated: 2026-06-13T22:04:46.975Z

### Source Files

- `README.md`
- `docs/product/overview.md`
- `docs/architecture/overview.md`
- `docs/README.md`
- `ROADMAP.md`

---
title: "Overview"
description: "What Paca exposes, runtime surfaces (web, API, realtime, MCP, ai-agent), and the shortest path from install to first project and API key."
---

Paca is a self-hosted, open-source project management platform (Apache 2.0) where humans and AI agents collaborate on the same Scrum board. A production stack runs as Docker Compose services behind an nginx gateway on port 80: the web UI, versioned REST API at `/api/v1`, Socket.IO realtime at `/ws/socket.io`, optional object storage at `/storage/`, and an optional OpenHands-powered `ai-agent` runtime. External integrations connect through user API keys and the `@paca-ai/paca-mcp` npm package.

## What Paca exposes

| Surface | Location | Purpose |
|:--|:--|:--|
| Web UI | `http://<host>/` | Board, backlog, sprints, docs, plugins, agent chat |
| REST API | `/api/v1/*` | System of record for all product state |
| Health check | `GET /api/healthz` | API liveness (`{"status":"ok"}`) |
| Realtime | `/ws/socket.io` | Live board, task, comment, and agent events |
| Object storage | `/storage/*` | Presigned upload/download via MinIO or S3 |
| MCP server | `@paca-ai/paca-mcp` (stdio via `npx`) | Structured tool access for any MCP client |
| AI agent | Internal `ai-agent` service (port 8082 exposed optionally) | OpenHands conversations in isolated Docker containers |

The MCP server is not part of the Compose stack. You run it in your editor or agent host and point it at the Paca API with `PACA_API_KEY` and `PACA_API_URL`.

## Runtime architecture

Paca is a monorepo with five deployed runtime areas plus shared infrastructure:

```mermaid
flowchart LR
  Browser["Browser / MCP client"]
  Gateway["nginx gateway :80"]
  Web["apps/web"]
  API["services/api"]
  RT["services/realtime"]
  Agent["services/ai-agent"]
  PG["PostgreSQL"]
  VK["Valkey"]
  Store["MinIO / S3"]

  Browser --> Gateway
  Gateway --> Web
  Gateway --> API
  Gateway --> RT
  Gateway --> Store
  API --> PG
  API --> VK
  API --> Store
  RT --> VK
  Agent --> VK
  Agent --> PG
  Agent --> API
```

| Component | Stack | Role |
|:--|:--|:--|
| `apps/web` | React, TanStack Start, shadcn/ui | User-facing SPA |
| `services/api` | Go, Gin | Business logic, auth, plugins (WASM), event production |
| `services/realtime` | Node.js, Socket.IO | Consumes Valkey streams; fans out client-safe events |
| `services/ai-agent` | Python, FastAPI, OpenHands SDK | Triggered agent conversations in Docker sandboxes |
| `apps/mcp` | TypeScript, MCP SDK | Translates MCP tool calls to REST requests |

**PostgreSQL** holds transactional data. **Valkey** provides cache, coordination, and asynchronous event streams that decouple the API from Socket.IO delivery and agent triggers. State-changing logic stays in `services/api`; the realtime service does not own product state.

## Gateway routing

All public traffic enters through the nginx `gateway` container. Route prefixes are stable across dev and production:

| Path prefix | Upstream | Notes |
|:--|:--|:--|
| `/api/` | `api:8080` | Forwards prefix as-is (e.g. `/api/v1/projects`) |
| `/ws/` | `realtime:3001` | Strips `/ws/`; clients connect with `path: "/ws/socket.io"` |
| `/storage/` | `minio:9000` | Large uploads; bypasses default body-size limit |
| `/` | `web` | SPA with client-side routing |

Optional scaling flags suppress bundled services without changing the gateway contract: `--scale postgres=0` (external DB), `--scale minio=0` (AWS S3), `--scale ai-agent=0` (no agent runtime), `--scale web=0` (CDN-hosted frontend).

## Shortest path: install to first project and API key

<Steps>
<Step title="Start the stack">

<Tabs>
<Tab title="Install script (recommended)">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh | bash
```

The script downloads compose assets, generates secrets, and starts the full stack. Open `http://<your-server-ip>` when it finishes.

</Tab>
<Tab title="Docker Compose (manual)">

```bash
mkdir paca && cd paca
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
mkdir -p nginx
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf

cat > .env <<'EOF'
JWT_SECRET=<run: openssl rand -hex 32>
ADMIN_PASSWORD=<your-admin-password>
POSTGRES_PASSWORD=<run: openssl rand -hex 32>
AGENT_API_KEY=<run: openssl rand -hex 32>
INTERNAL_API_KEY=<run: openssl rand -hex 32>
ENCRYPTION_KEY=<run: openssl rand -hex 32>
PUBLIC_URL=http://localhost
COOKIE_SECURE=false
EOF

docker compose --env-file .env up -d
```

Open `http://localhost`.

</Tab>
</Tabs>

Verify the API is healthy:

<RequestExample>

```bash
curl -s http://localhost/api/healthz
```

</RequestExample>

<ResponseExample>

```json
{"status":"ok"}
```

</ResponseExample>

</Step>

<Step title="Log in as admin">

Sign in at the web UI with username `admin` and the `ADMIN_PASSWORD` from your `.env` file. The install script prompts for this value; manual setups set it explicitly.

JWT session cookies are issued on login. For programmatic access, use API keys (next step) or `Authorization: Bearer <access-token>` from `POST /api/v1/auth/login`.

</Step>

<Step title="Create a project">

In the web UI, create a project from the projects list. Alternatively, call the REST API with your session token:

<RequestExample>

```bash
curl -s -X POST http://localhost/api/v1/projects \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{"name":"My First Project","description":"Getting started"}'
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "id": "9a1d7c2b-…",
    "name": "My First Project",
    "task_id_prefix": "MFP",
    "is_public": false,
    "created_at": "2026-06-13T12:00:00Z"
  },
  "request_id": "…"
}
```

</ResponseExample>

</Step>

<Step title="Generate an API key">

API key creation requires a JWT session (not an existing API key). In the UI: **Settings → API Keys → New Key**. Via API:

<RequestExample>

```bash
curl -s -X POST http://localhost/api/v1/users/me/api-keys \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{"name":"mcp-integration"}'
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "id": "…",
    "name": "mcp-integration",
    "key": "paca_…",
    "created_at": "2026-06-13T12:00:00Z"
  },
  "request_id": "…"
}
```

</ResponseExample>

The raw `key` value is shown once at creation. Store it immediately; it cannot be retrieved later.

</Step>

<Step title="Verify API key access">

Use the key on any authenticated endpoint:

<ParamField body="Authorization" type="string">
`Authorization: ApiKey <key>` or `X-API-Key: <key>`
</ParamField>

<RequestExample>

```bash
curl -s http://localhost/api/v1/projects \
  -H "X-API-Key: paca_…"
```

</RequestExample>

</Step>
</Steps>

Database migrations run automatically on API startup. Upgrades are `docker compose pull` followed by `docker compose --env-file .env up -d`.

## Authentication model

| Credential | Used by | Header / mechanism |
|:--|:--|:--|
| JWT access token | Web UI, CLI scripts | `access_token` HttpOnly cookie or `Authorization: Bearer` |
| JWT refresh token | Session renewal | `POST /api/v1/auth/refresh` |
| User API key | MCP, scripts, integrations | `Authorization: ApiKey` or `X-API-Key` |
| `AGENT_API_KEY` | Internal `ai-agent` ↔ `api` | Pre-shared service key (not a user key) |
| `INTERNAL_API_KEY` | Service-to-service calls | `X-Internal-Key` on internal endpoints |

API key management endpoints (`GET/POST/DELETE /api/v1/users/me/api-keys`) accept JWT only — a leaked API key cannot create additional keys.

## Integration surfaces

### MCP server

The `@paca-ai/paca-mcp` package exposes tools for projects, tasks, sprints, documents, members, views, custom fields, attachments, activity, and plugin-registered tools. Configure any MCP client:

```json
{
  "command": "npx",
  "args": ["-y", "@paca-ai/paca-mcp"],
  "env": {
    "PACA_API_KEY": "paca_…",
    "PACA_API_URL": "http://localhost"
  }
}
```

`PACA_API_URL` defaults to `http://localhost:8080` when unset; through the gateway, use `http://localhost` (port 80) or your `PUBLIC_URL`.

### AI agent runtime

When enabled, `services/ai-agent` consumes trigger events from the `paca:agent:triggers` Valkey stream, spawns an OpenHands container per conversation, and publishes events to `paca:agent:events` for realtime delivery. Agents appear as project members on the board. Skip the service with `--scale ai-agent=0` if you only need human workflows and MCP access.

### Plugin system

Plugins extend the WASM backend (custom routes, host functions), frontend (board views, task panels, settings tabs), and MCP (runtime-registered tools). Install from **Settings → Plugins → Marketplace** or via the local install script. The core stays small; workflows, BDD editing, checklists, and GitHub integration ship as plugins.

## Core product capabilities

- **Unified Scrumban board** — humans and agents on one real-time board
- **Sprint lifecycle** — create, start, complete sprints with backlog ordering
- **Task management** — custom types, statuses, fields, comments, activity feed with diff/revert
- **Documentation** — per-project living docs with version history
- **In-app AI chat** — project-level agent chat for planning and task creation
- **Realtime collaboration** — Socket.IO delivery through Valkey stream decoupling
- **Self-hosted** — data stays on your infrastructure; no per-seat licensing

The P-A-C-A cycle (Plan → Act → Check → Adapt) structures how teams and agents iterate together.

## Required secrets (production)

| Variable | Purpose |
|:--|:--|
| `JWT_SECRET` | Signs access and refresh tokens |
| `ADMIN_PASSWORD` | Initial `admin` user password |
| `POSTGRES_PASSWORD` | Bundled PostgreSQL credential |
| `AGENT_API_KEY` | Authenticates `ai-agent` to `api` |
| `INTERNAL_API_KEY` | Service-to-service authentication |
| `ENCRYPTION_KEY` | Encrypts plugin secrets and agent LLM keys at rest |
| `PUBLIC_URL` | Base URL for callbacks, CORS, and presigned storage URLs |

Generate random values with `openssl rand -hex 32`.

## Repository layout

:::files
paca/
├── apps/
│   ├── web/           # React SPA
│   ├── mcp/           # @paca-ai/paca-mcp
│   └── e2e/           # Playwright tests (not deployed)
├── services/
│   ├── api/           # Go REST backend
│   ├── realtime/      # Socket.IO fan-out
│   └── ai-agent/      # OpenHands orchestration
├── deploy/            # Compose files, nginx gateway
├── docs/              # Architecture and guides
├── plugins/local/     # Installed plugin artifacts
└── skills/            # Claude Code /paca slash commands
:::

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
Prerequisites, install script, manual Compose, secrets, and optional stack scaling.
</Card>
<Card title="Quickstart" href="/quickstart">
First successful run with health checks, admin login, project creation, and API key verification.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey event decoupling, and gateway routing in depth.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Configure `@paca-ai/paca-mcp` for Claude Desktop, VS Code, or any MCP client.
</Card>
<Card title="REST API" href="/rest-api">
Versioned `/api/v1` paths, auth envelopes, pagination, and endpoint catalog.
</Card>
<Card title="AI agents" href="/ai-agents">
Agent members, triggers, OpenHands lifecycle, and Docker isolation.
</Card>
</CardGroup>

---

## 02. Installation

> Prerequisites, install script, manual Docker Compose setup, required secrets, and optional stack scaling (external Postgres, S3, no ai-agent).

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/02-installation.md
- Generated: 2026-06-13T22:04:17.693Z

### Source Files

- `README.md`
- `docs/guides/getting-started.md`
- `scripts/install.sh`
- `deploy/docker-compose.prod.yml`
- `deploy/.env.production.example`
- `deploy/nginx/gateway.conf`

---
title: "Installation"
description: "Prerequisites, install script, manual Docker Compose setup, required secrets, and optional stack scaling (external Postgres, S3, no ai-agent)."
---

Paca production installs run as a Docker Compose stack published on each GitHub release: `install.sh`, `docker-compose.yml` (from `deploy/docker-compose.prod.yml`), and `nginx/gateway.conf`. No repository clone is required. The nginx `gateway` container is the single public entrypoint on `GATEWAY_PORT` (default `80`), routing `/api/` to the Go API, `/ws/` to Socket.IO realtime, `/storage/` to MinIO, and `/` to the React SPA.

## Prerequisites

| Requirement | Details |
|---|---|
| Docker | Engine installed and daemon running (`docker info` succeeds) |
| Docker Compose | `docker compose` plugin or standalone `docker-compose` |
| Network tools | `curl` or `wget` for downloading release assets |
| Host OS | Linux server for the recommended install script path |
| Optional: Docker socket | Required only when the `ai-agent` service is enabled |

<Warning>
The `ai-agent` service mounts `/var/run/docker.sock` to spawn isolated OpenHands sandbox containers. Skip it with `--scale ai-agent=0` if the host cannot or should not expose the Docker socket.
</Warning>

## Production stack topology

```mermaid
flowchart TB
    subgraph gateway_layer["gateway (nginx)"]
        GW["gateway :80"]
    end

    subgraph app_layer["Application services"]
        WEB["web (React SPA)"]
        API["api (Go REST)"]
        RT["realtime (Socket.IO)"]
        AG["ai-agent (optional)"]
    end

    subgraph data_layer["Data services"]
        PG["postgres"]
        VK["valkey"]
        MN["minio (optional)"]
    end

    Client --> GW
    GW -->|"/"| WEB
    GW -->|"/api/"| API
    GW -->|"/ws/"| RT
    GW -->|"/storage/"| MN
    API --> PG
    API --> VK
    API --> MN
    RT --> VK
    RT --> API
    AG --> PG
    AG --> VK
    AG --> API
```

| Service | Image default | Suppress with | Notes |
|---|---|---|---|
| `postgres` | `postgres:16-alpine` | `--scale postgres=0` | Set `DATABASE_URL` for external DB |
| `valkey` | `valkey/valkey:8-alpine` | — | Cache and pub/sub; always bundled |
| `minio` | `minio/minio:latest` | `--scale minio=0` | Set `STORAGE_PROVIDER=s3` for AWS |
| `api` | `pacaai/paca-api:latest` | — | DB migrations run on startup |
| `web` | `pacaai/paca-web:latest` | `--scale web=0` | External CDN-hosted SPA |
| `realtime` | `pacaai/paca-realtime:latest` | — | Socket.IO at `/ws/socket.io` |
| `gateway` | `nginx:1.27-alpine` | — | Mounts `./nginx/gateway.conf` |
| `ai-agent` | `pacaai/paca-ai-agent:latest` | `--scale ai-agent=0` | Requires Docker socket |

## Option 1 — Install script (recommended)

The release `install.sh` downloads compose assets, prompts for configuration, generates a `.env` with strong random secrets, and starts the stack.

<Tabs>
<Tab title="Interactive">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh
bash install.sh
```

</Tab>
<Tab title="One-liner (defaults)">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh | bash
```

Uses defaults and auto-generated secrets. No prompts when stdin is a pipe.

</Tab>
</Tabs>

### Install script environment overrides

<ParamField body="PACA_DIR" type="string" default="./paca">
Installation directory. Created if missing; `nginx/` subdirectory is created inside it.
</ParamField>

<ParamField body="PACA_VERSION" type="string" default="latest">
Release tag to install. `latest` resolves to the newest GitHub release; a tag like `v1.2.3` pins images to `1.2.3`.
</ParamField>

<ParamField body="PACA_YES" type="string" default="0">
Set to `1` to skip all prompts and accept defaults.
</ParamField>

### Configuration prompts

The script walks through these decisions and writes the corresponding `.env` values and `--scale` flags:

| Prompt | Default choice | Effect |
|---|---|---|
| Installation directory | `./paca` | Working directory for compose files |
| Admin username | `admin` | `ADMIN_USERNAME` |
| Admin password | auto-generated 16-char alphanumeric | `ADMIN_PASSWORD` |
| Encryption key | auto-generated 64-char hex | `ENCRYPTION_KEY` for plugin secrets at rest |
| Database | Bundled PostgreSQL | External option sets `DATABASE_URL`, adds `--scale postgres=0` |
| Object storage | Self-hosted MinIO | AWS S3 option sets `STORAGE_PROVIDER=s3`, adds `--scale minio=0` |
| Gateway port | `80` | `GATEWAY_PORT`; derives default `PUBLIC_URL` |
| Public URL | derived from port | `PUBLIC_URL`; sets `COOKIE_SECURE` from scheme |
| Web app | Bundled container | External CDN option adds `--scale web=0` |
| AI agent | Enabled | Disabled option adds `--scale ai-agent=0` |

<Note>
When connecting to an existing database, supply the original `ENCRYPTION_KEY`. A different key makes previously encrypted plugin secrets permanently unreadable.
</Note>

<Steps>
<Step title="Run the install script">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh | bash
```

Or download first for inspection: `curl -fsSL ... -o install.sh && bash install.sh`.

</Step>

<Step title="Confirm the summary and start">

The script shows directory, version, public URL, database, storage, web, and AI agent choices. Confirm to pull images and start:

```bash
docker compose --env-file .env up -d [--scale flags] --pull always
```

</Step>

<Step title="Verify the stack">

```bash
docker compose --env-file .env ps
docker compose --env-file .env logs -f
```

Services may take up to a minute to pass health checks. Open `PUBLIC_URL` and log in with the admin credentials shown at the end (auto-generated passwords are also stored in `.env`).

</Step>
</Steps>

## Option 2 — Manual Docker Compose

Manual setup downloads the same release artifacts the install script uses, without interactive prompts.

<Steps>
<Step title="Download compose and gateway config">

```bash
mkdir paca && cd paca
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
mkdir -p nginx
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf
```

</Step>

<Step title="Create `.env` with required secrets">

<CodeGroup>
```bash title="Generate secrets"
openssl rand -hex 32   # JWT_SECRET, AGENT_API_KEY, INTERNAL_API_KEY
openssl rand -hex 32   # ENCRYPTION_KEY (64-char hex)
```

```bash title="Minimal .env"
JWT_SECRET=<openssl rand -hex 32>
ADMIN_PASSWORD=<your-admin-password>
POSTGRES_PASSWORD=<openssl rand -hex 32>
AGENT_API_KEY=<openssl rand -hex 32>
INTERNAL_API_KEY=<openssl rand -hex 32>
ENCRYPTION_KEY=<openssl rand -hex 32>
PUBLIC_URL=http://localhost
```
</CodeGroup>

</Step>

<Step title="Start the full stack">

```bash
docker compose --env-file .env up -d
```

Open `PUBLIC_URL` (default `http://localhost`) and log in as `admin` with `ADMIN_PASSWORD`.

</Step>
</Steps>

## Required secrets and configuration

These variables are enforced or required for a working production deployment.

### Always required

| Variable | Generation | Constraint |
|---|---|---|
| `JWT_SECRET` | `openssl rand -hex 32` | Compose fails without it (`:?` guard in API service) |
| `ADMIN_PASSWORD` | strong password | Compose fails without it; seeds default admin on first startup |
| `PUBLIC_URL` | your hostname | Full URL, no trailing slash; used for cookies, presigned URLs, plugin callbacks |

### Required for bundled PostgreSQL

| Variable | Default | Notes |
|---|---|---|
| `POSTGRES_DB` | `paca` | Database name |
| `POSTGRES_USER` | `paca` | Database user |
| `POSTGRES_PASSWORD` | — | Override the `changeme` compose default |

### Required for plugin secret encryption

| Variable | Generation | Constraint |
|---|---|---|
| `ENCRYPTION_KEY` | `openssl rand -hex 32` | Exactly 64 lowercase hex characters (32 bytes) |

### Required when AI agent is enabled

| Variable | Generation | Notes |
|---|---|---|
| `AGENT_API_KEY` | `openssl rand -hex 32` | Must match `PACA_API_KEY` in `ai-agent` service |
| `INTERNAL_API_KEY` | `openssl rand -hex 32` | Authenticates agent service requests; empty disables auth |

<Tip>
The install script auto-generates `AGENT_API_KEY` and `INTERNAL_API_KEY` even when the AI agent is disabled. Omit them only when running with `--scale ai-agent=0`.
</Tip>

### Cookie and JWT defaults

| Variable | Default | Notes |
|---|---|---|
| `JWT_ACCESS_TTL` | `15m` | Access token lifetime |
| `JWT_REFRESH_TTL` | `168h` | Refresh token lifetime |
| `JWT_REFRESH_SESSION_TTL` | `24h` | Session-bound refresh TTL |
| `COOKIE_SECURE` | `true` in compose; `false` for `http://` URLs | Install script sets from `PUBLIC_URL` scheme |

## Optional stack scaling

Scale flags suppress bundled containers. Connection settings in `.env` must point to external equivalents.

### External PostgreSQL

```bash
# In .env:
DATABASE_URL=postgres://user:pass@host:5432/dbname

docker compose --env-file .env up -d --scale postgres=0
```

The API defaults `DATABASE_URL` to the bundled `postgres` service when unset.

### AWS S3 instead of MinIO

```bash
# In .env:
STORAGE_PROVIDER=s3
STORAGE_ENDPOINT=          # empty for default AWS regional endpoint
STORAGE_PUBLIC_URL=       # empty; S3 presigned URLs are self-contained
STORAGE_REGION=us-east-1
STORAGE_BUCKET=your-bucket
STORAGE_ACCESS_KEY_ID=your-key
STORAGE_SECRET_ACCESS_KEY=your-secret
STORAGE_USE_SSL=true

docker compose --env-file .env up -d --scale minio=0
```

For bundled MinIO, set `STORAGE_PUBLIC_URL` to `${PUBLIC_URL}/storage` so presigned URLs route through the gateway.

### Without AI agent

```bash
docker compose --env-file .env up -d --scale ai-agent=0
```

Core project management, MCP, and realtime features remain available. Autonomous agent task execution is disabled.

### External web app (CDN)

```bash
docker compose --env-file .env up -d --scale web=0
```

The gateway still serves `/api/`, `/ws/`, `/storage/`, `/plugins/`, and `/plugins-mcp/`. Build the SPA from source and deploy `dist/` to your CDN; point the CDN API proxy at `${PUBLIC_URL}/api`.

### Combined scaling

```bash
docker compose --env-file .env up -d \
  --scale postgres=0 \
  --scale minio=0 \
  --scale ai-agent=0
```

## Pinning a release version

Set image variables in `.env` to lock to a specific release tag:

```bash
PACA_API_IMAGE=pacaai/paca-api:1.2.3
PACA_WEB_IMAGE=pacaai/paca-web:1.2.3
PACA_REALTIME_IMAGE=pacaai/paca-realtime:1.2.3
PACA_AI_AGENT_IMAGE=pacaai/paca-ai-agent:1.2.3
```

Or pass `PACA_VERSION=v1.2.3` to the install script before running.

## Verification signals

| Check | Command or URL | Expected result |
|---|---|---|
| Container health | `docker compose --env-file .env ps` | `api`, `gateway`, `realtime` healthy after ~1 minute |
| API liveness | `GET ${PUBLIC_URL}/api/healthz` | `{"status":"ok"}` (no response envelope) |
| Web UI | Open `PUBLIC_URL` | Login page loads |
| Admin login | `admin` + `ADMIN_PASSWORD` | Authenticated session |
| Logs | `docker compose --env-file .env logs -f api` | No `JWT_SECRET is required` or DB connection errors |

<Check>
Database schema migrations are embedded in the API binary and run automatically on every API startup using idempotent SQL. No manual migration step is required after install.
</Check>

## Install artifacts layout

:::files
paca/
├── docker-compose.yml      # release copy of deploy/docker-compose.prod.yml
├── .env                    # generated secrets and configuration
└── nginx/
    └── gateway.conf        # nginx routes for /api, /ws, /storage, /plugins, SPA
:::

## Reconfiguration

After editing `.env`, restart the stack:

```bash
docker compose --env-file .env up -d
```

To regenerate `.env` from scratch, delete it and re-run `install.sh`. Existing `.env` files are preserved by default; the script offers to back up and replace.

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Log in as admin, create a project, generate an API key, and verify health endpoints after install.
</Card>
<Card title="Deploy production" href="/deploy-production">
Production topology detail, secret generation reference, and operator scaling patterns.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog across API, web, realtime, and ai-agent services.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, auth cookies, Valkey connectivity, and storage misconfiguration.
</Card>
<Card title="Local development" href="/local-development">
Contributor setup with `deploy/docker-compose.dev.yml` and host-side service runs.
</Card>
</CardGroup>

---

## 03. Quickstart

> First successful run: start the stack, log in as admin, create a project, generate an API key, and verify health endpoints.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/03-quickstart.md
- Generated: 2026-06-13T22:04:54.580Z

### Source Files

- `docs/guides/getting-started.md`
- `deploy/README.md`
- `scripts/install.sh`
- `services/api/internal/transport/http/handler/health_handler.go`
- `services/api/internal/transport/http/handler/auth_handler.go`
- `services/api/internal/transport/http/handler/apikey_handler.go`

---
title: "Quickstart"
description: "First successful run: start the stack, log in as admin, create a project, generate an API key, and verify health endpoints."
---

Paca ships as a Docker Compose stack behind an nginx gateway on port 80. The API exposes an unauthenticated health probe at `GET /api/healthz`, seeds the first admin from `ADMIN_USERNAME` and `ADMIN_PASSWORD` on startup, and serves versioned REST routes under `/api/v1`. This guide walks through a first successful run: start the stack, sign in, create a project, mint an API key, and confirm both health and API-key authentication.

## Prerequisites

| Requirement | Notes |
|---|---|
| Docker + Docker Compose | Required for production quickstart paths |
| Linux server or local machine | Install script targets any Linux host with Docker |
| Open port 80 (default) | Override with `GATEWAY_PORT` in `.env` |

<Note>
If you have not installed Paca yet, see [Installation](/installation) for prerequisites, secret generation, and optional stack scaling (external Postgres, S3, no ai-agent).
</Note>

## Start the stack

<Tabs>
<Tab title="Install script (recommended)">

The release install script downloads `docker-compose.yml` and `nginx/gateway.conf`, prompts for configuration, generates secrets, and starts the stack.

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh
bash install.sh
```

Non-interactive mode uses defaults and auto-generated secrets:

```bash
PACA_YES=1 bash <(curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh)
```

Override the install directory or release tag:

```bash
PACA_DIR=./paca PACA_VERSION=latest bash install.sh
```

When the script finishes, note the printed `PUBLIC_URL`, `ADMIN_USERNAME`, and `ADMIN_PASSWORD`. Auto-generated passwords are also written to `.env`.

</Tab>
<Tab title="Manual Docker Compose">

Download release artifacts and create `.env` with required secrets:

```bash
mkdir -p paca/nginx && cd paca
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf

cat > .env <<'EOF'
JWT_SECRET=<run: openssl rand -hex 32>
ADMIN_PASSWORD=<your-admin-password>
POSTGRES_PASSWORD=<run: openssl rand -hex 32>
AGENT_API_KEY=<run: openssl rand -hex 32>
INTERNAL_API_KEY=<run: openssl rand -hex 32>
ENCRYPTION_KEY=<run: openssl rand -hex 32>
PUBLIC_URL=http://localhost
EOF

docker compose --env-file .env up -d
```

</Tab>
</Tabs>

<Steps>
<Step title="Confirm containers are running">

```bash
docker compose --env-file .env ps
```

All services should report `running` or `healthy`. The stack may take up to a minute to pass health checks after first start.

</Step>
</Steps>

## Verify health endpoints

The nginx gateway forwards `/api/` to the API service unchanged. Health checks do not require authentication.

| Endpoint | Auth | Expected response |
|---|---|---|
| `GET /api/healthz` (via gateway) | None | HTTP 200, `data.status` is `"ok"` |
| `GET /api/healthz` (API direct, dev) | None | HTTP 200 on port 8080 |

<RequestExample>

```bash
curl -s http://localhost/api/healthz
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "status": "ok"
  }
}
```

</ResponseExample>

<Tip>
Docker Compose health checks in the bundled stacks probe `http://127.0.0.1:8080/api/healthz` on the API container and `http://127.0.0.1/api/healthz` on the gateway. A passing gateway health check confirms nginx can reach a healthy API.
</Tip>

## Log in as admin

On first API startup, Paca seeds an admin account from `ADMIN_USERNAME` and `ADMIN_PASSWORD` if no user with that username exists. The seeded user receives the `SUPER_ADMIN` global role, which includes `projects.create` and full platform permissions. If the account already exists, credentials are left unchanged.

Default install values:

| Variable | Default |
|---|---|
| `ADMIN_USERNAME` | `admin` |
| `ADMIN_PASSWORD` | Value you set in `.env`, or auto-generated by `install.sh` |

<Warning>
Login passwords must be at least 8 characters (`binding:"min=8"` on the login request).
</Warning>

<Tabs>
<Tab title="Web UI">

<Steps>
<Step title="Open the login page">

Navigate to your `PUBLIC_URL` (for example `http://localhost`). The root route (`/`) renders the login form.

</Step>

<Step title="Sign in">

Enter the admin username and password from `.env`. On success, the app redirects to `/home` and sets HttpOnly `access_token` and `refresh_token` cookies. Token values are not returned in the response body.

</Step>
</Steps>

</Tab>
<Tab title="REST API">

:::endpoint POST /api/v1/auth/login
Authenticate with username and password. Sets `access_token` and `refresh_token` HttpOnly cookies on success.
:::

<ParamField body="username" type="string" required>
Admin username from `ADMIN_USERNAME`.
</ParamField>

<ParamField body="password" type="string" required>
Admin password from `ADMIN_PASSWORD`. Minimum 8 characters.
</ParamField>

<ParamField body="remember_me" type="boolean">
Optional. Controls refresh-token TTL for persistent sessions.
</ParamField>

<RequestExample>

```bash
curl -s -c cookies.txt -X POST http://localhost/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"YOUR_ADMIN_PASSWORD"}'
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "message": "logged in"
  }
}
```

</ResponseExample>

</Tab>
</Tabs>

## Create a project

Project creation requires the `projects.create` global permission. The seeded `SUPER_ADMIN` admin satisfies this requirement.

<Tabs>
<Tab title="Web UI">

<Steps>
<Step title="Open the home page">

After login, go to `/home`.

</Step>

<Step title="Create a project">

Click **New project** (available from the home page or the sidebar project switcher). Fill in:

- **Name** (required)
- **Task ID prefix** (optional; auto-suggested from the name)
- **Description** (optional)
- **Public project** toggle (optional; default is private)

Submit the dialog. On success, the project appears in your project list and default backlog/timeline views are seeded server-side.

</Step>
</Steps>

</Tab>
<Tab title="REST API">

:::endpoint POST /api/v1/projects
Create a project. Requires authentication and `projects.create` permission.
:::

<ParamField body="name" type="string" required>
Project display name.
</ParamField>

<ParamField body="description" type="string">
Optional project description.
</ParamField>

<ParamField body="task_id_prefix" type="string">
Optional task ID prefix (for example `PACA`).
</ParamField>

<ParamField body="is_public" type="boolean">
When `true`, anonymous users can read the project.
</ParamField>

<RequestExample>

```bash
# Using session cookies from login:
curl -s -b cookies.txt -X POST http://localhost/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"My First Project","description":"Quickstart project"}'
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "My First Project",
    "description": "Quickstart project",
    "task_id_prefix": "",
    "is_public": false,
    "settings": {},
    "created_by": "…",
    "created_at": "…"
  }
}
```

</ResponseExample>

</Tab>
</Tabs>

## Generate an API key

User API keys authenticate REST requests without session cookies. Keys are created through JWT session auth only — an existing API key cannot mint another key.

<Tabs>
<Tab title="Web UI">

<Steps>
<Step title="Open API Keys">

From the user menu, choose **API Keys**, or navigate to `/profile/api-keys`.

</Step>

<Step title="Create a key">

Click **New key**, enter a name (required, max 100 characters), and optionally set an expiry date. After creation, the full key is shown once in a reveal dialog. Copy it immediately — it cannot be retrieved again.

</Step>
</Steps>

</Tab>
<Tab title="REST API">

:::endpoint POST /api/v1/users/me/api-keys
Create an API key for the authenticated user. Requires JWT/cookie session auth (`RequireJWTAuth`); Bearer API keys are rejected.
:::

<ParamField body="name" type="string" required>
Human-readable key label. Must be non-empty and ≤ 100 characters.
</ParamField>

<ParamField body="expires_at" type="string">
Optional ISO 8601 expiry timestamp.
</ParamField>

<RequestExample>

```bash
curl -s -b cookies.txt -X POST http://localhost/api/v1/users/me/api-keys \
  -H "Content-Type: application/json" \
  -d '{"name":"Quickstart CLI key"}'
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "id": "…",
    "name": "Quickstart CLI key",
    "key_prefix": "paca_…",
    "key": "paca_…",
    "last_used_at": null,
    "expires_at": null,
    "created_at": "…"
  }
}
```

</ResponseExample>

<Check>
The raw `key` field is returned only on creation. Subsequent `GET /users/me/api-keys` responses expose `key_prefix` but never the full secret.
</Check>

</Tab>
</Tabs>

## Verify API key authentication

Confirm the key works by calling an authenticated endpoint. The auth middleware accepts, in order: `access_token` cookie, `Authorization: Bearer` (JWT), `Authorization: ApiKey`, or `X-API-Key`.

<Steps>
<Step title="Call GET /api/v1/users/me with the API key">

<CodeGroup>

```bash title="X-API-Key header"
curl -s http://localhost/api/v1/users/me \
  -H "X-API-Key: paca_YOUR_KEY_HERE"
```

```bash title="Authorization: ApiKey header"
curl -s http://localhost/api/v1/users/me \
  -H "Authorization: ApiKey paca_YOUR_KEY_HERE"
```

</CodeGroup>

</Step>

<Step title="Confirm the response">

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "id": "…",
    "username": "admin",
    "full_name": "Admin",
    "role": "SUPER_ADMIN"
  }
}
```

</ResponseExample>

A `200` response with your admin username confirms API-key auth is wired end-to-end.

</Step>

<Step title="Re-check health (optional)">

Health remains public and independent of API keys:

```bash
curl -s http://localhost/api/healthz
```

</Step>
</Steps>

## Quick reference

| Action | Path | Auth |
|---|---|---|
| Health check | `GET /api/healthz` | None |
| Login | `POST /api/v1/auth/login` | Credentials in body |
| List projects | `GET /api/v1/projects` | Cookie, Bearer JWT, or API key |
| Create project | `POST /api/v1/projects` | Cookie, Bearer JWT, or API key + `projects.create` |
| Create API key | `POST /api/v1/users/me/api-keys` | Cookie or Bearer JWT only |
| Verify API key | `GET /api/v1/users/me` | API key header |

All successful API responses use the standard envelope: `success`, `data`, and optional `request_id`. Errors return `success: false` with `error_code` and `error`.

<AccordionGroup>
<Accordion title="Stack not healthy after one minute">

Check container status and logs:

```bash
docker compose --env-file .env ps
docker compose --env-file .env logs -f api gateway
```

Confirm `GET /api/healthz` returns 200 before attempting login.

</Accordion>

<Accordion title="Login fails with 401">

Verify `ADMIN_USERNAME` and `ADMIN_PASSWORD` in `.env` match what you are entering. Passwords must be at least 8 characters. If the admin user was created on a previous run, changing `.env` does not reset the password — use the original password or create a new user through the admin API.

</Accordion>

<Accordion title="Cannot create API key with an API key">

`POST /users/me/api-keys` requires JWT session authentication. Log in through the web UI or `POST /api/v1/auth/login` first, then create keys with cookies or a Bearer access token.

</Accordion>

<Accordion title="API key returns 401">

Confirm the key starts with `paca_`, has not been revoked, and has not expired. Use `Authorization: ApiKey` or `X-API-Key`, not `Authorization: Bearer`, for API-key credentials.

</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` to Claude Desktop, VS Code, or any MCP client using the API key you just created.
</Card>
<Card title="REST API reference" href="/rest-api">
Full `/api/v1` endpoint catalog, auth modes, pagination, and response envelopes.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health-check failures, auth cookie issues, Valkey connectivity, and MCP connection errors.
</Card>
<Card title="Local development" href="/local-development">
Contributor dev stack with hot-reload, host-side services, and the development port map.
</Card>
</CardGroup>

---

## 04. Platform architecture

> Monorepo runtime areas, service boundaries, Valkey event decoupling, and nginx gateway routing between web, API, realtime, and storage.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/04-platform-architecture.md
- Generated: 2026-06-13T22:04:41.566Z

### Source Files

- `docs/architecture/overview.md`
- `docs/architecture/service-boundaries.md`
- `docs/architecture/repository-structure.md`
- `deploy/nginx/gateway.conf`
- `services/api/internal/transport/http/router/router.go`
- `docs/architecture/database-schema.md`

---
title: "Platform architecture"
description: "Monorepo runtime areas, service boundaries, Valkey event decoupling, and nginx gateway routing between web, API, realtime, and storage."
---

Paca ships as a single monorepo whose production stack runs behind an nginx `gateway` container on port 80. The gateway terminates public HTTP, rate-limits API traffic, proxies `/api/` to `services/api` (Go + Gin on `:8080`), `/ws/` to `services/realtime` (Socket.IO on `:3001`), `/storage/` to MinIO or an external S3 endpoint, and `/` to `apps/web`. PostgreSQL holds durable product state; Valkey carries cache, pub/sub fan-out, and durable streams that decouple write paths from realtime delivery and agent orchestration.

## Monorepo layout

Runtime code, deploy assets, and documentation live in clearly separated top-level areas:

:::files
paca/
├── apps/
│   ├── web/          # React + TanStack Start SPA
│   ├── mcp/          # @paca-ai/paca-mcp npm package
│   └── e2e/          # Playwright stack verifier (not deployed)
├── services/
│   ├── api/          # Go + Gin system of record
│   ├── realtime/     # Bun + Socket.IO fan-out edge
│   └── ai-agent/     # Python + FastAPI + OpenHands (optional)
├── plugins/local/    # On-disk WASM + frontend + MCP bundles
├── deploy/           # Compose files + nginx/gateway.conf
├── docs/             # Architecture and guides
└── scripts/          # Install and plugin helpers
:::

| Area | Deployed | Stack | Primary role |
|------|----------|-------|--------------|
| `apps/web` | Yes (or CDN) | React, TanStack Start, shadcn/ui | Browser UI, session cookies, Socket.IO client |
| `apps/mcp` | External | Node MCP server | AI tool bridge to `/api/v1` over HTTP + API key |
| `apps/e2e` | No | Playwright | Cross-stack browser verification |
| `services/api` | Yes | Go, Gin, GORM | Business logic, auth, PostgreSQL, plugins, event publishing |
| `services/realtime` | Yes | Bun, Socket.IO, ioredis | WebSocket delivery; no business-rule mutations |
| `services/ai-agent` | Optional | Python, FastAPI, OpenHands | Agent conversation execution in Docker |

<Note>
`apps/e2e` exercises the full gateway-routed stack but is versioned alongside `apps/web` and never ships as a runtime container.
</Note>

## Production topology

`deploy/docker-compose.prod.yml` defines the default service graph. All user-facing HTTP enters through `gateway`; backend services communicate on the internal Docker network.

```mermaid
flowchart TB
  subgraph clients [Clients]
    Browser[Browser]
    MCPClient[MCP client]
  end

  subgraph gateway_layer [gateway nginx :80]
    GW[gateway.conf routes]
  end

  subgraph apps_layer [Application runtimes]
    Web[apps/web :3000]
    API[services/api :8080]
    RT[services/realtime :3001]
    Agent[services/ai-agent :8080]
    MCP[apps/mcp external]
  end

  subgraph data_layer [Infrastructure]
    PG[(PostgreSQL)]
    VK[(Valkey)]
    S3[(MinIO or S3)]
  end

  Browser --> GW
  MCPClient --> MCP
  MCP --> API

  GW -->|"/"| Web
  GW -->|"/api/"| API
  GW -->|"/ws/"| RT
  GW -->|"/storage/"| S3
  GW -->|"/plugins/"| PluginFE[frontend_plugins volume]
  GW -->|"/plugins-mcp/"| PluginMCP[mcp_plugins volume]

  Web --> API
  Web --> RT
  API --> PG
  API --> VK
  API --> S3
  RT --> VK
  RT --> API
  Agent --> VK
  Agent --> PG
  Agent --> API
  API -.->|agent triggers| Agent
```

Optional scaling flags from Compose:

| Flag | Effect |
|------|--------|
| `--scale postgres=0` | Use external `DATABASE_URL` |
| `--scale minio=0` | Use `STORAGE_PROVIDER=s3` with AWS credentials |
| `--scale ai-agent=0` | Disable agent orchestration |
| `--scale web=0` | Serve SPA from CDN; gateway still handles `/api/`, `/ws/`, `/storage/` |

## Gateway routing

`deploy/nginx/gateway.conf` is mounted read-only at `/etc/nginx/conf.d/default.conf`. It is the single public entrypoint.

| Public path | Upstream | Behavior |
|-------------|----------|----------|
| `/api/` | `api:8080` | Forwards prefix unchanged (`/api/v1/...` → `/api/v1/...`). Rate limit 100 req/s per IP, burst 50. |
| `/ws/` | `realtime:3001` | Strips `/ws/` prefix; WebSocket upgrade via `$connection_upgrade` map. Read timeout 3600s. |
| `/storage/` | `minio:9000` | Rewrites `Host` to `minio:9000` for SigV4 validation. `client_max_body_size 0` for large uploads. |
| `/plugins/` | `frontend_plugins` volume | Serves module-federation bundles only (no WASM). |
| `/plugins-mcp/` | `mcp_plugins` volume | Serves ESM MCP plugin bundles. |
| `/` | `web:3000` | SPA + dev HMR WebSocket upgrade. Default `client_max_body_size 10m`. |

Socket.IO clients connect through the gateway:

```ts
import { io } from "socket.io-client";

const socket = io("http://localhost", {
  path: "/ws/socket.io",
  withCredentials: true,
});
```

Presigned object URLs returned by the API rewrite internal `minio:9000` hosts to `STORAGE_PUBLIC_URL` (for example `http://localhost/storage`) so browsers hit the gateway `/storage/` location.

## Service boundaries

Ownership rules keep transactional logic centralized and edges stateless.

### `services/api` — system of record

- Owns business workflows: projects, tasks, sprints, views, documents, members, notifications, agents, plugins.
- Exposes versioned REST at `/api/v1/*` (see router groups in `services/api/internal/transport/http/router/router.go`).
- Authenticates JWT cookies, bearer tokens, and API keys; enforces global and project-scoped permissions.
- Runs embedded SQL migrations on every startup (`services/api/migrations/`).
- Loads WASM plugins (wazero), registers proxy routes at `/api/v1/plugins/:pluginId/*path`, and coordinates S3-compatible attachments.
- Publishes domain events to Valkey; runs in-process stream consumers for activity and notification side effects.

### `services/realtime` — delivery edge

- Accepts authenticated Socket.IO connections; verifies tokens by calling `GET /api/v1/users/me/global-permissions` (no local JWT validation).
- Subscribes to Valkey Pub/Sub channel `paca.events` and emits to namespace rooms.
- Does not mutate PostgreSQL or apply business rules.

### `services/ai-agent` — agent execution (optional)

- Consumes `paca:agent:triggers` with consumer group `ai-agent-workers`.
- Spawns one OpenHands Docker container per active conversation.
- Persists `agent_conversation_events` directly to PostgreSQL; publishes live updates to `paca.events` Pub/Sub and durable entries to `paca:agent:events` stream.
- Does not bypass the API for product state changes.

### `apps/mcp` — external integration surface

- Runs outside the Compose stack as `@paca-ai/paca-mcp`.
- Translates MCP tool calls into REST requests against `services/api`.
- Loads plugin-contributed MCP tools from gateway-served `/plugins-mcp/` bundles.

<Warning>
Keep state-changing business logic in `services/api`. `services/realtime` only delivers events; `services/ai-agent` executes conversations and reports results through API-owned tables and events.
</Warning>

## Valkey event decoupling

Valkey serves three roles: namespaced cache (`paca:` prefix), immediate Pub/Sub fan-out, and durable Streams for asynchronous workers.

### Pub/Sub — realtime path

| Channel | Publisher | Consumer | Purpose |
|---------|-----------|----------|---------|
| `paca.events` | `services/api`, `services/ai-agent` | `services/realtime` | Immediate Socket.IO fan-out |

Message shape:

```json
{
  "type": "task.comment.added",
  "payload": {
    "project_id": "...",
    "task_id": "...",
    "...": "..."
  }
}
```

`services/realtime` routes by type prefix:

| Event prefix | Socket.IO room | Required permission |
|--------------|----------------|---------------------|
| `task.*`, `agent.*`, `github.*` | `project:<projectId>:tasks` | `tasks.read` |
| `doc.*` | `project:<projectId>:docs` | `docs.read` |
| `notification.*` | `user:<userId>:notifications` | (user-scoped) |

Room membership is resolved once on `join`; the subscriber performs no per-message permission checks.

### Streams — durable async pipelines

| Stream key | Producer | Consumer | Purpose |
|------------|----------|----------|---------|
| `paca.task_activities` | `services/api` | API `ActivityConsumer` (`api.activity_writer`) | Persist system task activities to PostgreSQL |
| `paca.doc_activities` | `services/api` | API `DocActivityConsumer` (`api.doc_activity_writer`) | Persist doc activities |
| `paca.task_assignments` | `services/api` | API `NotificationConsumer` (`api.notification_writer`) | Create notifications + publish `notification.created` |
| `paca:agent:triggers` | `services/api` | `services/ai-agent` (`ai-agent-workers`) | Agent trigger and control messages |
| `paca:agent:events` | `services/ai-agent` | (durable log) | Ordered conversation event stream |
| `paca.analytics` | `services/api` | External / future | Analytics and audit |

Representative domain event types published through these paths include `task.created`, `task.updated`, `doc.updated`, `notification.created`, and agent topics such as `agent.task_assigned`, `agent.conversation.started`, and `agent.message`.

### Cache and session state

The API wraps Valkey with a `paca:` namespace for entity caches (projects, sprints, tasks, roles) and refresh-token storage. Cache TTLs are configurable per resource; see the configuration reference for env vars.

```mermaid
sequenceDiagram
  participant Web as apps/web
  participant API as services/api
  participant VK as Valkey
  participant RT as services/realtime
  participant PG as PostgreSQL

  Web->>API: PATCH /api/v1/projects/:id/tasks/:taskId
  API->>PG: Commit task update
  API->>VK: XADD paca.task_activities
  API->>VK: PUBLISH paca.events (task.updated)
  VK-->>RT: message on paca.events
  RT->>Web: emit event to project:uuid:tasks
  VK-->>API: ActivityConsumer reads stream
  API->>PG: INSERT task_activities row
```

## API surface through the gateway

All authenticated REST traffic uses the `/api` prefix. Public health check:

:::endpoint GET /api/healthz
Liveness probe used by API container health checks and operations scripts.
:::

Versioned resources mount under `/api/v1`:

| Route group | Examples | Auth |
|-------------|----------|------|
| `/api/v1/auth` | `login`, `refresh`, `logout` | Public except `logout` |
| `/api/v1/users` | `/me`, `/me/api-keys` | JWT or API key |
| `/api/v1/admin` | users, global roles, plugins | Global permissions |
| `/api/v1/projects` | CRUD, members, roles | Project-scoped permissions |
| `/api/v1/projects/:projectId/tasks` | tasks, activities, attachments | `tasks.read` / `tasks.write` |
| `/api/v1/projects/:projectId/views` | sprint, backlog, timeline views | `sprints.read` / `sprints.write` |
| `/api/v1/projects/:projectId/docs` | folders, documents, snapshots | `docs.read` / `docs.write` |
| `/api/v1/projects/:projectId/agents` | agent CRUD, MCP servers, skills | `agents.read` / `agents.write` |
| `/api/v1/plugins/:pluginId/*path` | WASM plugin proxy | Per-route manifest auth |

Plugin HTTP routes and MCP tools both resolve to API paths; the gateway never executes plugin WASM—only the API container mounts `backend_plugins`.

## Storage and plugins

**Object storage.** The API generates presigned upload/download URLs against MinIO (default) or AWS S3 (`STORAGE_PROVIDER=s3`). Files are tracked in PostgreSQL `files` with `storage_key` and `bucket`; task and document attachments reference those rows.

**Plugin artifacts.** Three volumes split plugin surfaces:

| Volume mount | Served by | Contents |
|--------------|-----------|----------|
| `backend_plugins` → API `/plugins` | API only | WASM binaries, SQL migrations |
| `frontend_plugins` → gateway `/var/www/plugins` | nginx | Module-federation JS/CSS |
| `mcp_plugins` → gateway `/var/www/plugins-mcp` | nginx + MCP server | ESM MCP bundles |

WASM and migrations are not exposed over HTTP; only frontend and MCP bundles are gateway-routable.

## Agent pipeline

Agent triggers originate in the API (task assignment, comment @mention, chat message, description-write). The API appends flat fields to `paca:agent:triggers`; `services/ai-agent` reads via consumer group `ai-agent-workers`, runs OpenHands in isolated Docker containers, writes conversation history to PostgreSQL, and pushes live `agent.*` events to `paca.events` for the web monitoring UI.

Control messages (`agent.stop`) use the same trigger stream. The API remains authoritative for agent configuration, conversation listing, and stop/send-message REST endpoints under `/api/v1/projects/:projectId/conversations`.

## Verification signals

| Check | Command / endpoint | Expected |
|-------|-------------------|----------|
| Gateway up | `curl -s -o /dev/null -w "%{http_code}" http://localhost/` | `200` |
| API healthy | `GET /api/healthz` | `200` |
| Realtime healthy | `GET /ws/healthz` (proxied) | `200` |
| Valkey reachable | API and realtime containers healthy | Compose `depends_on: valkey: service_healthy` |
| Socket.IO path | Browser devtools WS to `/ws/socket.io` | `101 Switching Protocols` |
| Migrations applied | API startup logs | `schema migrations applied` |

## Related pages

<CardGroup>
  <Card title="Overview" href="/overview">
    Runtime surfaces, MCP, and the shortest path from install to first API key.
  </Card>
  <Card title="Deploy production" href="/deploy-production">
    Production Compose topology, secret generation, and optional service scaling.
  </Card>
  <Card title="Local development" href="/local-development">
    Dev Compose stack, hot-reload commands, and port map through the gateway.
  </Card>
  <Card title="Realtime events" href="/realtime-events">
    Socket.IO auth, Valkey pub/sub fan-out, rooms, and event payloads.
  </Card>
  <Card title="AI agents" href="/ai-agents">
    Agent triggers, OpenHands lifecycle, Docker isolation, and Valkey streams.
  </Card>
  <Card title="Plugin system" href="/plugin-system">
    WASM sandbox, frontend extension points, and marketplace install lifecycle.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Environment variables across API, web gateway, realtime, and ai-agent.
  </Card>
</CardGroup>

---

## 05. Interaction views

> Unified view and task-list model for sprint, backlog, and timeline contexts; query parameters, manual ordering, and shared REST paths.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/05-interaction-views.md
- Generated: 2026-06-13T22:06:01.568Z

### Source Files

- `docs/architecture/interaction-views.md`
- `docs/architecture/manual-sort-algorithm.md`
- `services/api/internal/transport/http/handler/view_handler.go`
- `services/api/internal/transport/http/handler/task_handler.go`
- `apps/web/src/lib/interaction-api.ts`

---
title: "Interaction views"
description: "Unified view and task-list model for sprint, backlog, and timeline contexts; query parameters, manual ordering, and shared REST paths."
---

Paca models sprint, product-backlog, and timeline interactions through one saved-view resource (`/api/v1/projects/:projectId/views`) and one task-list endpoint (`/api/v1/projects/:projectId/tasks`). Each view stores presentation settings and reusable filters in a JSON `config`; manual task order is persisted separately in `view_task_positions` and read or written through `/views/:viewId/task-positions`.

## Core model

A saved view belongs to exactly one project and one interaction context:

| Context | Query param | Scope | `sprint_id` on view |
|---|---|---|---|
| Sprint | `context=sprint` | Per-sprint tabs | Set (required in query) |
| Product backlog | `context=backlog` | Project-level | `null` |
| Timeline | `context=timeline` | Project-level | `null` |

Views are stored in `sprint_views` with `view_context` set to `sprint`, `backlog`, or `timeline`. Sprint-scoped rows also carry a `sprint_id`; backlog and timeline rows have `sprint_id = null` but always include `project_id`.

Supported layout types (`view_type`):

| `view_type` | UI layout | Typical use |
|---|---|---|
| `table` | Table | Backlog grouped by sprint; sprint task lists |
| `board` | Board (kanban) | Sprint status columns |
| `roadmap` | Roadmap | Timeline Epic bars |
| `plugin` | Plugin | WASM frontend extension |

```mermaid
flowchart LR
  subgraph UI["Web app"]
    IL["interaction-layout.tsx"]
    API["interaction-api.ts"]
  end
  subgraph REST["API /api/v1"]
    V["/projects/:id/views"]
    T["/projects/:id/tasks"]
    P["/views/:viewId/task-positions"]
  end
  subgraph DB["Postgres"]
    SV["sprint_views"]
    VTP["view_task_positions"]
    TK["tasks"]
  end
  IL --> API
  API --> V
  API --> T
  API --> P
  V --> SV
  P --> VTP
  T --> TK
```

<Info>
The UI keeps sprint, backlog, and timeline as distinct pages, but all three share the same REST paths. Context is selected with the `?context=` query parameter rather than separate route families.
</Info>

## View REST API

All view endpoints live under `/api/v1/projects/:projectId/views`. Sprint context additionally requires `?sprint_id=<uuid>`. When `context` is omitted, the handler defaults to `sprint`.

<ParamField body="context" type="string">
Interaction context. One of `sprint`, `backlog`, or `timeline`. Defaults to `sprint` when omitted.
</ParamField>

<ParamField body="sprint_id" type="uuid" required>
Required when `context=sprint`. Ignored for backlog and timeline.
</ParamField>

### Endpoint inventory

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/views?context=…` | List views for the context |
| `POST` | `/views?context=…` | Create a view |
| `GET` | `/views/:viewId` | Get one view |
| `PATCH` | `/views/:viewId` | Update name, type, or config |
| `DELETE` | `/views/:viewId` | Delete a view |
| `PUT` | `/views/positions?context=…` | Reorder view tabs |
| `GET` | `/views/:viewId/task-positions` | List manual positions |
| `PUT` | `/views/:viewId/task-positions` | Bulk upsert positions |
| `PUT` | `/views/:viewId/task-positions/:taskId` | Upsert one position |

:::endpoint GET /api/v1/projects/:projectId/views
List saved views for the given interaction context. Sprint context scopes to one sprint via `sprint_id`.
:::

<RequestExample>

```bash
# Sprint views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=sprint&sprint_id={sprintId}"

# Backlog views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=backlog"

# Timeline views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=timeline"
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "…",
        "sprint_id": "…",
        "project_id": "…",
        "name": "Board",
        "view_type": "board",
        "config": {
          "column_by": "status",
          "filters": {
            "sprints": { "all": false, "items": { "{sprintId}": true } },
            "task_types": { "all": false, "items": { "normal": { "all": true } } }
          }
        },
        "position": 0,
        "created_at": "…",
        "updated_at": "…"
      }
    ]
  }
}
```

</ResponseExample>

:::endpoint POST /api/v1/projects/:projectId/views
Create a saved view. Body requires `name`; `view_type` defaults to `table` when omitted.
:::

<ParamField body="name" type="string" required>
Display name for the view tab.
</ParamField>

<ParamField body="view_type" type="string">
One of `table`, `board`, `roadmap`, or `plugin`. Defaults to `table`.
</ParamField>

<ParamField body="config" type="object">
Presentation and filter settings. See [View config](#view-config).
</ParamField>

<ParamField body="position" type="number">
Tab order position. Seeded views use `0`, `1`, …
</ParamField>

:::endpoint PUT /api/v1/projects/:projectId/views/positions
Reorder all view tabs for a context. Body must list every view ID in the desired order.
:::

<RequestExample>

```json
{ "view_ids": ["uuid-a", "uuid-b", "uuid-c"] }
```

</RequestExample>

<Warning>
Returns `400 VIEW_REORDER_INVALID` when the ID list length or membership does not exactly match the views for that context.
</Warning>

:::endpoint DELETE /api/v1/projects/:projectId/views/:viewId
Delete a view. Returns `409 VIEW_IS_LAST_VIEW` when it is the only remaining view in that context.
:::

## Unified task list

All interaction pages fetch tasks through one endpoint:

:::endpoint GET /api/v1/projects/:projectId/tasks
Paginated task list with filter query parameters. Cursor-based pagination via `cursor` and `next_cursor`.
:::

<ParamField body="sprint_id" type="uuid | null">
Single sprint filter. Pass the literal `null` for unscheduled backlog items only (`sprint_id IS NULL`).
</ParamField>

<ParamField body="sprint_ids" type="string">
Comma-separated sprint UUIDs for multi-sprint saved views.
</ParamField>

<ParamField body="status_id" type="uuid">
Filter to one status.
</ParamField>

<ParamField body="status_ids" type="string">
Comma-separated status UUIDs.
</ParamField>

<ParamField body="assignee_id" type="uuid | null">
Single assignee. Pass `null` for unassigned tasks.
</ParamField>

<ParamField body="assignee_ids" type="string">
Comma-separated assignee member UUIDs.
</ParamField>

<ParamField body="task_type_ids" type="string">
Comma-separated task type UUIDs. Timeline pages filter to Epic types here.
</ParamField>

<ParamField body="task_type_id" type="uuid | null">
Single type filter. Pass `null` for tasks with no type.
</ParamField>

<ParamField body="parent_task_id" type="uuid">
Return child tasks of a parent (subtasks).
</ParamField>

<ParamField body="view_id" type="uuid">
Optional. When set, enriches each task with `view_position` and `view_group_key` from that view's manual positions.
</ParamField>

<ParamField body="page_size" type="integer">
Page size. Default `20`, max `200`.
</ParamField>

<ParamField body="cursor" type="string">
Opaque cursor from a previous response's `next_cursor`.
</ParamField>

<ResponseField name="items" type="Task[]">
Task objects. May include `view_position` and `view_group_key` when `view_id` is supplied.
</ResponseField>

<ResponseField name="page_size" type="integer">
Applied page size.
</ResponseField>

<ResponseField name="next_cursor" type="string | null">
Cursor for the next page, or `null` when no more results.
</ResponseField>

### Context conventions

| Page | Typical filter seed in `config.filters` | Task query |
|---|---|---|
| Sprint | Current sprint in `filters.sprints` | `sprint_ids` from resolved view filters |
| Backlog | All sprints; `column_by = sprint` | `sprint_ids` from resolved view filters |
| Timeline | Epic-only `task_types` | `task_type_ids` from resolved view filters |

<Note>
Presentation belongs in top-level config keys (`column_by`, `sort_by`, `slice_by`). Query constraints belong in `config.filters`. The web app reads the active view config, resolves filters client-side, and passes the resulting IDs to `GET /tasks`.
</Note>

## View config

Each view stores presentation settings and reusable filters in `config` (JSONB on `sprint_views`).

```json
{
  "fields": ["title", "status", "assignee"],
  "column_by": "status",
  "swimlanes": "assignee",
  "sort_by": "manual",
  "field_sum": "count",
  "slice_by": "none",
  "collapsed_columns": ["status-uuid"],
  "filters": {
    "sprints": { "all": false, "items": { "sprint-uuid": true } },
    "statuses": { "all": true, "items": { "done-uuid": false } },
    "assignees": { "all": false, "items": { "member-uuid": true, "__unassigned": true } },
    "task_types": { "all": false, "items": { "normal": { "all": true } } }
  }
}
```

### Filter selectors

Filters use a recursive `FilterConfig` per dimension (`task_types`, `statuses`, `assignees`, `sprints`):

| Field | Semantics |
|---|---|
| `all: true` | Include everything by default; `items` entries act as exclusions |
| `all: false` | Include nothing by default; `items` entries act as inclusions |
| Item keys | Entity UUIDs or virtual groups |

The virtual key `"normal"` in `task_types` expands client-side to all non-system type IDs. Newly created task types are included automatically without updating stored views.

The web client resolves filters in `interaction-api.ts` (`resolveFilterConfig`, `resolveTaskTypeFilter`) and maps them to task query parameters in `interaction-layout.tsx`.

### Plugin views

When `view_type = "plugin"`, config also carries:

<ParamField body="plugin_manifest_id" type="string">
Reverse-DNS plugin manifest identifier (for example `com.paca.checklist`). Stored as `plugin_id` in the domain layer; exposed as `plugin_manifest_id` in API responses.
</ParamField>

<ParamField body="plugin_component" type="string">
Registered frontend extension component name.
</ParamField>

## Default seeded views

The API seeds default views on project and sprint creation (`view_defaults.go`).

### Project creation

| Context | Name | `view_type` | Key config |
|---|---|---|---|
| Backlog | Table | `table` | `column_by: sprint`, `filters.task_types: normal` |
| Timeline | Roadmap | `roadmap` | `filters.task_types`: Epic system type only |

### Sprint creation

| Name | `view_type` | Position | Key config |
|---|---|---|---|
| Board | `board` | 0 | `column_by: status`, current sprint in `filters.sprints`, backlog-status columns collapsed |
| Table | `table` | 1 | `column_by: status`, current sprint in `filters.sprints` |

Both sprint defaults filter to non-system task types via the `"normal"` virtual group. Seeding is best-effort: project or sprint creation succeeds even if a view seed fails.

## Web app data flow

For every interaction page (`interaction-layout.tsx`):

<Steps>
<Step title="Load views">
Fetch views with `listViewsByContext(projectId, context, sprintId?)` using the context query string.
</Step>
<Step title="Select active view">
Pick the active tab (persisted in `localStorage` under `paca:active-view:{interactionKey}`).
</Step>
<Step title="Resolve filters">
Read `activeView.config.filters` and resolve to `sprint_ids`, `status_ids`, `assignee_ids`, and `task_type_ids`.
</Step>
<Step title="Fetch tasks">
Call `listAllTasks` (`GET /tasks`) with resolved filters. Column-grouped boards issue per-column queries when `column_by` is `status`, `sprint`, `assignee`, or `type`.
</Step>
<Step title="Apply manual order">
When `sort_by` is unset or `"manual"`, load positions from `GET /views/:viewId/task-positions` (or use `view_id` enrichment) and sort client-side.
</Step>
<Step title="Render layout">
Render Board, Table, Roadmap, or Plugin using the same task payload.
</Step>
</Steps>

Manual sort is active when `sort_by` is absent or equals `"manual"` (case-insensitive). Non-manual sorts use `sortTasksByConfig` on the client.

## Manual task ordering

Manual order uses fractional indexing with `DOUBLE PRECISION` positions in `view_task_positions`. Each row is unique per `(view_id, task_id)` and optionally records a `group_key` (the column/group value, such as a status UUID on a board).

### Position assignment

The web client computes positions in `handleReorderTask` (`interaction-layout.tsx`):

| Scenario | Formula |
|---|---|
| Between two tasks | `(prev + next) / 2` |
| Append after last | `(prev + POSITION_MAX) / 2` |
| Prepend before first | `next / 2` |
| No neighbours | `POSITION_MAX / 2` |

`POSITION_MAX = Number.MAX_SAFE_INTEGER` (2⁵³ − 1). Positions stay in `(0, POSITION_MAX)`.

Tasks with no recorded position (`view_position = null`) sort after all positioned tasks, ordered by `created_at` ascending. When a drag lands next to unpositioned tasks, the client assigns virtual positions and bulk-upserts all affected rows to materialize the order.

### Task-position endpoints

:::endpoint PUT /api/v1/projects/:projectId/views/:viewId/task-positions
Bulk upsert manual positions. Preferred for drag-and-drop because one drag may update multiple tasks.
:::

<RequestExample>

```json
{
  "items": [
    { "task_id": "uuid", "position": 98304.0, "group_key": "status-uuid" },
    { "task_id": "uuid", "position": 163840.0, "group_key": null }
  ]
}
```

</RequestExample>

:::endpoint PUT /api/v1/projects/:projectId/views/:viewId/task-positions/:taskId
Single-task upsert. Body: `{ "position": <float64>, "group_key": "<string|null>" }`.
:::

:::endpoint GET /api/v1/projects/:projectId/views/:viewId/task-positions
Returns `{ "items": [{ "view_id", "task_id", "position", "group_key" }] }`.
:::

<Note>
Renormalisation of collapsed float gaps is not automated. With float64 spacing, billions of insertions into the same gap are required before positions collide in practice.
</Note>

## Error codes

| Code | HTTP | When |
|---|---|---|
| `VIEW_IS_LAST_VIEW` | 409 | Deleting the only view in a context |
| `VIEW_REORDER_INVALID` | 400 | `view_ids` list does not match existing views |
| `invalid context` | 400 | `context` is not `sprint`, `backlog`, or `timeline` |
| `sprint_id is required` | 400 | Sprint context without `sprint_id` |

## Extending the model

When adding new interaction behavior:

- Extend `ViewConfig` and `ViewFilters` rather than adding context-specific route families.
- Express task scoping through `config.filters` and the shared `GET /tasks` endpoint.
- Keep `view_task_positions` as the source of truth for manual ordering metadata.
- Drive timeline behavior through `task_type_ids` filters, not dedicated timeline list endpoints.

<Warning>
Legacy handler methods `ListBacklogTasks` and `ListTimelineTasks` exist in the codebase but are not registered in the HTTP router. Integrations should use `GET /tasks` with the appropriate filter parameters.
</Warning>

## Related pages

<CardGroup>
<Card title="REST API reference" href="/rest-api">
Versioned paths, auth, pagination envelopes, and the full endpoint catalog.
</Card>
<Card title="Task activity API" href="/task-activity-api">
Activity feed and comment endpoints for tasks shown in views.
</Card>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, frontend extension points, and plugin view types.
</Card>
<Card title="Build a plugin" href="/build-plugin">
Author plugin views with `plugin_manifest_id` and `plugin_component` config.
</Card>
</CardGroup>

---

## 06. AI agents

> Agent members, trigger types, OpenHands conversation lifecycle, Docker isolation, Valkey streams, and how agents participate as project teammates.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/06-ai-agents.md
- Generated: 2026-06-13T22:06:12.655Z

### Source Files

- `docs/ai-agent/overview.md`
- `docs/ai-agent/ai-agent-service.md`
- `docs/ai-agent/default-skills.md`
- `services/ai-agent/src/main.py`
- `services/ai-agent/src/worker.py`
- `services/api/internal/transport/http/handler/agent_handler.go`

---
title: "AI agents"
description: "Agent members, trigger types, OpenHands conversation lifecycle, Docker isolation, Valkey streams, and how agents participate as project teammates."
---

Paca AI agents are project-scoped entities backed by the OpenHands Software Agent SDK. Each agent is created through `services/api`, registered as a `project_members` row with `member_type = 'agent'`, and executed by `services/ai-agent`, which consumes trigger events from the Valkey stream `paca:agent:triggers`, runs one isolated Docker container per conversation, and publishes events to `paca:agent:events` and the `paca.events` pub/sub channel for realtime fan-out.

## Agent members

Creating an agent atomically inserts both an `agents` row and a matching `project_members` entry. Agent members share the same assignment, mention, and chat surfaces as human members.

| Concept | Storage | Behavior |
|---|---|---|
| Agent | `agents` | Holds LLM config, prompts, skills, MCP servers, repo permissions |
| Agent member | `project_members` (`member_type = 'agent'`, `agent_id` set, `user_id` null) | Appears in member lists, can be task assignees, has a project role |
| Handle | `agents.handle` (unique per project) | Used for `@mention` detection in comments |

<ParamField body="member_type" type="string">
Discriminator on `project_members`. Values: `human`, `agent`.
</ParamField>

<ParamField body="agent_id" type="uuid">
Foreign key to `agents.id`. Required when `member_type = 'agent'`.
</ParamField>

<Check>
Constraint `ck_pm_member_type_ref` enforces: human members have `user_id`; agent members have `agent_id`.
</Check>

On `POST /api/v1/projects/:projectId/agents`, the API encrypts the LLM API key at rest, creates the agent record, and links it to a new `project_members` row with the supplied `project_role_id`. The returned agent includes `member_id` for use in task assignment and activity recording.

## Architecture

```mermaid
flowchart TB
  subgraph web["apps/web"]
    UI["Agent settings, chat, monitoring"]
  end

  subgraph api["services/api"]
    CRUD["Agent CRUD + triggers"]
    ConvAPI["Conversation REST"]
  end

  subgraph valkey["Valkey"]
    Triggers["paca:agent:triggers"]
    Events["paca:agent:events"]
    PubSub["paca.events pub/sub"]
  end

  subgraph aiagent["services/ai-agent"]
    Worker["worker.py consumer group"]
    Executor["executor.py OpenHands run"]
    DockerMgr["docker_workspace.py"]
  end

  subgraph realtime["services/realtime"]
    SIO["Socket.IO fan-out"]
  end

  subgraph sandbox["Agent Docker containers"]
    OH["openhands agent-server"]
  end

  UI -->|HTTP / Socket.IO| api
  CRUD -->|XADD| Triggers
  Worker -->|XREADGROUP ai-agent-workers| Triggers
  Worker --> Executor
  Executor --> DockerMgr
  DockerMgr -->|spawn per conversation| OH
  Executor -->|XADD| Events
  Executor -->|PUBLISH| PubSub
  PubSub --> SIO
  Events --> SIO
  ConvAPI -->|internal proxy| aiagent
```

| Service | Role |
|---|---|
| `services/api` | Owns agent configuration, publishes triggers, stores conversations and events, exposes public REST |
| `services/ai-agent` | Consumes triggers, runs OpenHands conversations in Docker, persists events, handles stop control |
| `services/realtime` | Routes `agent.*` events to project task rooms via Socket.IO |
| Docker host | Provides per-conversation sandbox containers |

## Trigger types

A trigger creates one `agent_conversations` row (status `queued`) and appends a flat-field message to `paca:agent:triggers`. The stream entry carries both a routing `type` (prefixed `agent.*`) and a `trigger_type` value stored on the conversation record.

| Trigger | Stream `type` | `trigger_type` | When fired |
|---|---|---|---|
| Task assignment | `agent.task_assigned` | `task_assigned` | Task `assignee_id` resolves to an agent member (via notification consumer) |
| Comment @mention | `agent.comment_mention` | `comment_mention` | Comment body mentions an agent handle (task or doc comments) |
| Direct chat | `agent.chat_message` | `chat_message` | `POST .../chat-sessions` or `POST .../chat-sessions/:sessionId/messages` |
| Description write | `agent.description_write` | `description_write` | `POST .../tasks/:taskId/write-with-ai` |

<RequestExample>
```json
{
  "conversation_id": "uuid",
  "project_id": "uuid",
  "agent_id": "uuid",
  "task_id": "uuid",
  "actor_member_id": "uuid",
  "trigger_type": "task_assigned",
  "type": "agent.task_assigned",
  "repo_plugin_ids": "com.paca.github"
}
```
</RequestExample>

<ParamField body="repo_plugin_ids" type="string">
Comma-separated plugin names (e.g. `com.paca.github`) for installed repository-capability plugins. Gathered automatically at trigger time.
</ParamField>

<ParamField body="message" type="string">
User message text. Required for `comment_mention`, `chat_message`, and `description_write`.
</ParamField>

<Note>
Task assignment does not send a human notification to agent members. The notification consumer detects `member.IsAgent()` and publishes a trigger instead.
</Note>

## Valkey streams

### Inbound: `paca:agent:triggers`

Consumer group `ai-agent-workers` with per-replica consumer name `worker-<hostname>`. The worker in `services/ai-agent/src/worker.py` reads batches up to `WORKER_CONCURRENCY` (default `10`), dispatches each message, then `XACK`s on completion.

| Field | Description |
|---|---|
| `type` | Routing topic: `agent.task_assigned`, `agent.comment_mention`, `agent.chat_message`, `agent.description_write`, or `agent.stop` |
| `trigger_type` | Conversation trigger stored in DB |
| `conversation_id` | Pre-allocated UUID used as the application conversation ID |
| `agent_id`, `project_id` | Agent and project scope |
| `task_id`, `comment_id`, `chat_session_id` | Context fields depending on trigger |
| `message` | Initial user message |
| `actor_member_id` | Human member who initiated the trigger |
| `repo_plugin_ids` | Comma-separated repository plugin names |

### Control: `agent.stop`

Stop requests publish a control message (`type = agent.stop`) rather than a new conversation. The worker sets a per-conversation `threading.Event`, the executor calls `conversation.pause()`, and status moves to `stopped`.

### Outbound: `paca:agent:events`

Each OpenHands SDK event is serialized and appended with fields `conversation_id`, `project_id`, `event_type`, `event_source`, `event_index`, `payload`, and `status`. Events are also inserted into `agent_conversation_events`.

### Realtime: `paca.events` pub/sub

The executor publishes lightweight notifications (e.g. `agent.messageevent`, `agent.conversation.finished`) to the `paca.events` channel. `services/realtime` routes any `agent.*` type to the project tasks room.

## OpenHands conversation lifecycle

```mermaid
stateDiagram-v2
  [*] --> queued: API creates conversation + publishes trigger
  queued --> running: ai-agent worker picks up trigger
  running --> finished: SDK status FINISHED
  running --> failed: SDK status ERROR or STUCK
  running --> stopped: agent.stop control or POST .../stop
  finished --> [*]
  failed --> [*]
  stopped --> [*]
```

<Steps>
<Step title="Dequeue trigger">
The worker loads agent config (LLM key decrypted with shared `ENCRYPTION_KEY`), skills, and MCP servers from PostgreSQL.
</Step>
<Step title="Build agent context">
`executor.py` assembles the OpenHands `Agent` with LLM, skills, MCP config, system prompt suffix, documentation workflow instructions, and trigger-specific prompt fields (`task_trigger_prompt`, `doc_comment_trigger_prompt`, `chat_trigger_prompt`, `description_write_trigger_prompt`).
</Step>
<Step title="Spawn Docker sandbox">
`docker_sandbox()` starts `ghcr.io/openhands/agent-server:latest-python` (overridable via `AGENT_SERVER_IMAGE`), yields a `RemoteWorkspace`, and tears down the container on exit.
</Step>
<Step title="Run conversation">
`Conversation.run(blocking=False)` starts the SDK loop. Event and token callbacks persist `MessageEvent` and action/observation events to the database and Valkey streams.
</Step>
<Step title="Finalize">
On natural completion the status becomes `finished` or `failed`. A realtime `agent.conversation.finished` or `agent.conversation.failed` event is published.
</Step>
</Steps>

### Conversation statuses

| Status | Meaning |
|---|---|
| `queued` | Created by API, waiting for worker |
| `running` | Executor active |
| `paused` | Reserved in schema; not exposed via current REST control endpoints |
| `finished` | Completed successfully |
| `failed` | SDK error, stuck state, or unhandled exception |
| `stopped` | User-initiated stop |

### Event persistence

OpenHands events are stored in `agent_conversation_events` with monotonic `event_index`. Agent `MessageEvent` text is captured via streaming token callbacks to avoid duplicate rows. Internal SDK events (`StreamingDeltaEvent`, `ConversationStateUpdateEvent`, `SystemPromptEvent`, `ConversationErrorEvent`) are filtered out of persistence.

## Docker isolation

Each active conversation gets one dedicated container managed by `docker_workspace.py`.

| Property | Value |
|---|---|
| Image | `ghcr.io/openhands/agent-server:latest-python` (configurable) |
| Lifecycle | `remove=True` — container deleted on stop |
| Labels | `paca.conversation_id`, `paca.managed=true` |
| Network (in Compose) | Joins the ai-agent service network so MCP can reach `api` and `gateway` hostnames |
| Network (local dev) | Host port from pool `PORT_POOL_START`–`PORT_POOL_START + PORT_POOL_SIZE - 1` (default `10000`–`10099`) |
| Git identity | `GIT_AUTHOR_NAME`, `GIT_COMMITTER_NAME` from agent config (defaults `paca-agent`) |

Repository tools (`list_repositories`, `clone_repository`, `push_branch`, `create_pull_request`) are injected into the sandbox via `repo_tools.py` when `can_clone_repos` is true and repository plugins are linked.

<Warning>
The ai-agent service requires access to the Docker socket (`DOCKER_SOCKET`, default `/var/run/docker.sock`). Without it, conversations cannot start.
</Warning>

## Skills and MCP

### Built-in skill templates

Four hardcoded templates ship in `services/api` and are listed at `GET /api/v1/agents/skill-templates`:

| Slug | Persona | Keyword triggers |
|---|---|---|
| `po-assistant` | Product Owner | acceptance criteria, backlog, roadmap, groom |
| `ba` | Business Analyst | requirements, functional spec, gap analysis |
| `developer` | Developer | implement, fix, bug, pr, test |
| `manual-tester` | Manual QA | test case, test plan, defect, regression |

Skills are stored per agent in `agent_skills` (`skill_source`: `inline`, `marketplace`, `github_url`) and converted to OpenHands `Skill` objects with optional `KeywordTrigger`.

### MCP servers

Per-agent MCP config lives in `agent_mcp_servers` (`transport`: `stdio`, `sse`, `http`, `oauth`). When `PACA_API_KEY` is set on the ai-agent service, a built-in `paca` MCP server is always appended:

```json
{
  "command": "npx",
  "args": ["-y", "@paca-ai/paca-mcp"],
  "env": {
    "PACA_API_KEY": "...",
    "PACA_API_URL": "http://api:8080",
    "PACA_GATEWAY_URL": "http://gateway",
    "PACA_AGENT_ID": "...",
    "PACA_PROJECT_ID": "..."
  }
}
```

LLM providers are BYOC/BYOK: any LiteLLM-compatible provider and model string (`provider/model`) with an encrypted API key reference on the agent record.

## Public REST API

All paths are under `/api/v1/projects/:projectId`.

### Agents

| Method | Path | Permission |
|---|---|---|
| `GET` | `/agents` | `agents:read` |
| `POST` | `/agents` | `agents:write` |
| `GET` | `/agents/:agentId` | `agents:read` |
| `PATCH` | `/agents/:agentId` | `agents:write` |
| `DELETE` | `/agents/:agentId` | `agents:write` |
| `GET/POST/PATCH/DELETE` | `/agents/:agentId/mcp-servers[...]` | read/write |
| `GET/POST/PATCH/DELETE` | `/agents/:agentId/skills[...]` | read/write |
| `GET` | `/agents/:agentId/chat-sessions` | `agents:read` |
| `POST` | `/agents/:agentId/chat-sessions` | `agents:read` |
| `POST` | `/agents/:agentId/chat-sessions/:sessionId/messages` | `agents:read` |

:::endpoint POST /projects/:projectId/tasks/:taskId/write-with-ai
Trigger an agent to write a task description. Records `agent.session.started` activity on success.

<ParamField body="agent_id" type="uuid" required>
Agent to run the description-write prompt.
</ParamField>

<ResponseField name="conversation" type="object">
New `agent_conversations` record with `trigger_type = description_write`.
</ResponseField>
:::

### Conversations

| Method | Path | Description |
|---|---|---|
| `GET` | `/conversations` | List with `agent_id`, `status` filters |
| `GET` | `/conversations/:conversationId` | Metadata and status |
| `GET` | `/conversations/:conversationId/events` | Paginated event history |
| `POST` | `/conversations/:conversationId/stop` | Stop running conversation |
| `POST` | `/conversations/:conversationId/messages` | Send follow-up message (requires `status = running`) |

### Global agent utilities

| Method | Path | Description |
|---|---|---|
| `GET` | `/agents/skill-templates` | Built-in skill catalog |
| `GET` | `/agents/llm-models` | Proxied from ai-agent `/llm/models` |

Internal ai-agent endpoints (`/conversations/:id`, `/conversations/:id/stop`, etc.) require header `X-Internal-Token` matching `INTERNAL_API_KEY` and are not exposed through the public gateway.

## Configuration

Key environment variables for `services/ai-agent`:

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `VALKEY_URL` | yes | `redis://valkey:6379/0` | Stream and pub/sub client |
| `DATABASE_URL` | yes | — | Conversation and event persistence |
| `INTERNAL_API_KEY` | yes | — | Internal REST auth |
| `API_BASE_URL` | yes | `http://api:8080` | Plugin and repository API calls |
| `GATEWAY_BASE_URL` | — | `http://gateway` | Plugin MCP bundle URLs |
| `PACA_API_KEY` | — | empty | Enables built-in Paca MCP injection |
| `ENCRYPTION_KEY` | — | empty | Decrypts `llm_api_key_secret` from DB |
| `DOCKER_SOCKET` | yes | `/var/run/docker.sock` | Container orchestration |
| `AGENT_SERVER_IMAGE` | — | `ghcr.io/openhands/agent-server:latest-python` | Sandbox image |
| `PORT_POOL_START` / `PORT_POOL_SIZE` | — | `10000` / `100` | Local dev port pool |
| `WORKER_CONCURRENCY` | — | `10` | Parallel trigger processing |

On the API service, set `AGENT_API_KEY` to the same value as ai-agent `PACA_API_KEY` so the built-in MCP can authenticate.

## Participating as project teammates

Agents integrate into standard project workflows:

- **Assignment**: Assign a task to an agent member; the notification consumer triggers `agent.task_assigned` instead of sending an in-app notification.
- **Comments**: Mention `@agent-handle` in a task or doc comment; `activity_service` calls `TriggerCommentMention` with the comment body.
- **Chat**: Start a persistent `agent_chat_sessions` thread per user–agent pair; each message spawns a new conversation linked to the session.
- **Monitoring**: The web UI subscribes to `agent.*` realtime events and polls conversation events for live action/observation display.
- **Activity feed**: Task assignment and description-write triggers record `agent.session.started` on the task activity timeline.

<Info>
Repository credentials never enter agent prompts. Clone and PR operations use plugin HTTP APIs with short-lived tokens; `repo_tools.py` scrubs tokens from command output.
</Info>

## Related pages

<CardGroup>
<Card title="Configure AI agents" href="/configure-ai-agents">
Create agents, set LLM and MCP config, attach skills, and wire repository access.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey decoupling, and nginx gateway routing.
</Card>
<Card title="Realtime events" href="/realtime-events">
Socket.IO auth, Valkey fan-out, and `agent.*` event payloads.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Configure `@paca-ai/paca-mcp` for external clients and understand agent-mode constraints.
</Card>
<Card title="REST API" href="/rest-api">
Versioned `/api/v1` paths, auth, and response envelopes.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog across API, web, realtime, and ai-agent.
</Card>
</CardGroup>

---

## 07. Plugin system

> WASM backend sandbox, frontend extension points, MCP plugin tools, capability permissions, and marketplace-driven install lifecycle.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/07-plugin-system.md
- Generated: 2026-06-13T22:06:41.055Z

### Source Files

- `docs/plugins/overview.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/mcp-plugin-system.md`
- `services/api/internal/transport/http/handler/plugin_handler.go`
- `docs/plugins/marketplace.md`

---
title: "Plugin system"
description: "WASM backend sandbox, frontend extension points, MCP plugin tools, capability permissions, and marketplace-driven install lifecycle."
---

Paca extends the core product through versioned `plugin.json` bundles that register into three runtime surfaces: backend logic in WASM inside `services/api` (wazero), frontend React remotes loaded by `apps/web` via Module Federation, and MCP tools merged by `apps/mcp` at server startup. Installed plugins are recorded in PostgreSQL, artifacts land under configurable local or S3 stores, and the nginx gateway serves frontend bundles at `/plugins/` and MCP bundles at `/plugins-mcp/`.

## Architecture

```mermaid
flowchart TB
  subgraph Browser["Browser — apps/web"]
    Host["Host app"]
    Registry["PluginRegistryProvider"]
    EP["ExtensionPoint / PluginSlot"]
    Remote["RemoteComponent (Module Federation)"]
    Host --> Registry --> EP --> Remote
  end

  subgraph API["services/api"]
    Handler["PluginHandler"]
    Runtime["plugin.Runtime (wazero)"]
    Store["plugin.Store"]
    Migrator["MigrationRunner"]
    Handler --> Runtime
    Runtime --> Store
    Handler --> Migrator
  end

  subgraph MCP["apps/mcp"]
    Loader["plugin-loader.ts"]
    Reg["PluginRegistry"]
    Loader --> Reg
  end

  subgraph Storage["Artifact stores"]
    WASM["PLUGINS_WASM_DIR / S3"]
    FE["PLUGINS_FRONTEND_DIR → /plugins/"]
    MCPDir["PLUGINS_MCP_DIR → /plugins-mcp/"]
  end

  subgraph Catalog["Paca-AI/paca-plugins"]
    JSON["catalog/plugins.json"]
  end

  Remote -->|"GET /api/v1/plugins"| Handler
  Remote -->|"plugin API /api/v1/plugins/{id}/…"| Handler
  Loader -->|"GET /api/v1/plugins"| Handler
  Reg -->|"PluginAPIClient"| Handler
  Catalog -->|"marketplace install"| Handler
  Handler --> WASM
  Handler --> FE
  Handler --> MCPDir
```

| Surface | Runtime | Entry manifest field | Load timing |
|---|---|---|---|
| Backend | wazero WASM in API process | `backend.routes`, `backend.eventSubscriptions` | API startup + install/upgrade reload |
| Frontend | Vite Module Federation remote | `frontend.remoteEntryUrl`, `frontend.extensionPoints` | Lazy on first extension-point render |
| MCP | Node.js dynamic `import()` | `mcp.remoteEntryUrl` | MCP server startup (cached for process lifetime) |

<Note>
Backend hot-reload without an API process restart is not supported. Cross-plugin calls are out of scope for v1.
</Note>

## Extension points

The host recognizes five frontend extension point IDs. Registrations come from each plugin's manifest and are sorted by `order`, with super-admin overrides from `plugin_extension_settings`.

| ID | Surface | Host placement |
|---|---|---|
| `sidebar.general.section` | Global left navigation | `app-sidebar.tsx` |
| `sidebar.project.section` | Project sidebar | Project section in `app-sidebar.tsx` |
| `task.detail.section` | Task drawer/page | Task detail panel |
| `project.settings.tab` | Project settings | Settings tabs |
| `view` | Main board area | View selector / board |

`<ExtensionPoint>` renders all non-hidden registrations for a point; `<PluginSlot>` renders only the first. Each remote component is wrapped in an error boundary so one failing plugin does not break the host.

Super admins hide or reorder panels system-wide via `PATCH /api/v1/admin/plugin-extension-settings`. Settings apply to all users on the next registry build.

## Plugin manifest (`plugin.json`)

Every bundle ships a `plugin.json` that drives registration across all three surfaces.

```json
{
  "id": "com.paca.checklist",
  "displayName": "Checklist",
  "version": "1.0.0",
  "description": "Adds named checklists with checkable items to tasks.",
  "permissions": ["db:read:tasks", "db:write:plugin_data", "events:emit"],
  "frontend": {
    "remoteEntryUrl": "/plugins/com.paca.checklist/assets/remoteEntry.js",
    "extensionPoints": [
      {
        "point": "task.detail.section",
        "component": "TaskDetailSection",
        "label": "Checklist",
        "order": 10
      }
    ]
  },
  "backend": {
    "routes": [
      {
        "method": "GET",
        "path": "/projects/:projectId/tasks/:taskId/items",
        "middlewares": [
          { "name": "authn" },
          { "name": "requireFreshPassword" },
          {
            "name": "requirePermissions",
            "scope": "project",
            "permissions": ["tasks.read"]
          }
        ]
      }
    ],
    "eventSubscriptions": ["task.deleted"],
    "allowedOutboundDomains": ["api.github.com"],
    "allowedConfigKeys": ["GITHUB_APP_ID"]
  },
  "mcp": {
    "remoteEntryUrl": "https://paca.example.com/plugins-mcp/com.paca.checklist/mcp.js"
  }
}
```

<ResponseField name="id" type="string">
Reverse-DNS identifier; must match the `plugins.name` column and catalog `name`.
</ResponseField>

<ResponseField name="frontend.remoteEntryUrl" type="string">
URL to the Module Federation `remoteEntry.js`. For self-hosted installs, use `/plugins/<plugin-id>/assets/remoteEntry.js` (served by nginx).
</ResponseField>

<ResponseField name="backend.routes" type="array">
HTTP routes mounted under `/api/v1/plugins/{pluginId}/`. Project-scoped routes should include `/projects/:projectId` in the path.
</ResponseField>

<ResponseField name="mcp.remoteEntryUrl" type="string">
Node.js-compatible ESM module exporting a default `PluginMCPEntry` object.
</ResponseField>

## Backend WASM sandbox

Backend plugins compile to `backend.wasm` and load into an isolated wazero module per plugin. The host registers a `paca` namespace of bridge functions; plugins communicate through linear-memory calls, not direct host memory or filesystem access.

### WASM exports and lifecycle

| Export | When called |
|---|---|
| `Init()` | After module instantiation at load time |
| `HandleRequest(ptr, len)` | Each matching HTTP request |
| `HandleEvent(ptr, len)` | Core events listed in `eventSubscriptions` |
| `Shutdown()` | Before module unload |
| `ResetAllocator()` | After each `HandleRequest` (optional, SDK helper) |

Default resource limits per module instance:

| Resource | Default |
|---|---|
| Max call duration | 5 seconds |
| Linear memory | 1024 pages (64 MiB) |
| Outbound fetch response | 50 MiB cap |

### Host function bridge

All host functions are imported from the `paca` module.

| Function group | Exports | Behavior |
|---|---|---|
| Database | `db_query`, `db_exec`, `db_tx_begin`, `db_tx_commit`, `db_tx_rollback` | Queries run in plugin schema `plugin_data_{id}`; only `SELECT` allowed in `db_query`; DDL/DCL blocked in `db_exec` |
| Key-value storage | `storage_get`, `storage_set`, `storage_delete` | Backed by `{schema}.plugin_kv` table |
| Core reads | `tasks_list`, `task_get`, `project_get`, `members_list` | Read-only access to core tables |
| HTTP (in-handler) | `http_request_body`, `http_request_headers`, `http_caller_identity`, `http_respond` | Request context from Gin proxy; caller identity from JWT |
| Events | `event_emit`; `event_subscribe` (no-op) | Subscriptions declared in manifest; emit publishes to Valkey |
| Outbound HTTP | `fetch` | Only domains in `backend.allowedOutboundDomains` |
| Config | `config_get` | Only keys in `backend.allowedConfigKeys` |
| Logging | `log` | Structured logs tagged with plugin ID |

Plugin-owned SQL migrations run in the dedicated PostgreSQL schema (`com.paca.checklist` → `plugin_data_com_paca_checklist`). The `MigrationRunner` creates the schema, `plugin_kv`, and `plugin_schema_migrations` tracking table, then applies `.sql` files in lexicographic order.

### Route proxy and middleware

All backend routes are reached through a single Gin wildcard:

```
ANY /api/v1/plugins/:pluginId/*path
```

The proxy matches `method` + `path` against `backend.routes`, applies manifest-declared middleware, then dispatches to the plugin's `HandleRequest` export.

Supported middleware names:

| Name | Effect |
|---|---|
| `authn` | Require authenticated session or API key |
| `optionalAuthn` | Attach identity when present |
| `requireFreshPassword` | Block stale-password sessions |
| `requireJWTAuth` | Reject API-key-only callers |
| `requirePermissions` | Enforce global or project permissions |

<ParamField body="requirePermissions.scope" type="string">
`global` (default) or `project`. Project scope resolves the project UUID from a route param (default `projectId`).
</ParamField>

<ParamField body="requirePermissions.permissions" type="string[]" required>
Permission keys such as `projects.read`, `tasks.write`.
</ParamField>

When `middlewares` is omitted, the host applies `optionalAuthn` + `requireFreshPassword`. An explicit empty array `[]` disables all middleware. Legacy `public: true` on a route also disables middleware.

## Frontend extension system

At startup, `PluginRegistryProvider` fetches `GET /api/v1/plugins` and builds a `Map<ExtensionPointId, PluginRegistration[]>`. Enabled plugins with both `frontend.remoteEntryUrl` and `extensionPoints` contribute registrations.

Remote components load through dynamic `import()` with a shared React scope (`react`, `react-dom`, `@tanstack/react-query`) to prevent duplicate library instances. Plugin HTTP calls go through the SDK client scoped to `/api/v1/plugins/{pluginId}/` using the same session cookie as the host (`credentials: "include"`).

For self-hosted deployments, set `frontend.remoteEntryUrl` to a path under `/plugins/`:

```
/plugins/<plugin-id>/assets/remoteEntry.js
```

The nginx gateway serves `PLUGINS_FRONTEND_DIR` at `/plugins/` and never exposes WASM binaries or migration SQL.

## MCP plugin tools

The MCP server (`apps/mcp`) loads plugin tools at startup:

1. `GET /api/v1/plugins` with `PACA_API_KEY`
2. Skip disabled plugins and plugins without `mcp.remoteEntryUrl`
3. Dynamic `import(remoteEntryUrl)` and validate default export
4. Merge tool definitions into the flat tool list alongside core Paca tools

Plugin MCP modules export a default `PluginMCPEntry`:

```typescript
import type { PluginMCPEntry } from "@paca-ai/plugin-sdk-mcp";
import { PluginAPIClient, textResult, errorResult } from "@paca-ai/plugin-sdk-mcp";

const entry: PluginMCPEntry = {
  tools: [/* MCP Tool definitions */],
  async handleToolCall(name, args, context) {
    const api = new PluginAPIClient(context);
    // call plugin backend via /api/v1/plugins/{pluginId}/…
  },
};

export default entry;
```

Tool names must be unique across all plugins and match `[a-z][a-z0-9_]*`. Use a short prefix derived from the plugin ID (for example `checklist_list_items`).

<Warning>
MCP plugin modules run in the same Node.js process as the MCP server with no sandboxing. Install MCP-capable plugins only from trusted sources. Use `https://` for `remoteEntryUrl` in production; `http://` is permitted for local development.
</Warning>

Failed plugin loads log a warning to stderr; core MCP tools remain available.

## Capability and access control

Access control spans manifest declarations and host enforcement:

| Layer | Mechanism |
|---|---|
| Manifest `permissions` | Declares required host-function scopes (documented contract for SDK and review) |
| Route middleware | Per-route authn/authz enforced by `PluginHandler` before WASM dispatch |
| DB isolation | All plugin SQL runs under `plugin_data_{pluginId}` schema via `search_path` |
| Outbound HTTP | `paca.fetch` limited to `backend.allowedOutboundDomains` (exact hostname match) |
| Secrets | `paca.config_get` limited to `backend.allowedConfigKeys`; values come from host config, not the WASM binary |
| Admin operations | Marketplace install, upgrade, delete require global `users.write` permission |

## Install lifecycle

```mermaid
stateDiagram-v2
  [*] --> CatalogEntry: PR merged to paca-plugins
  CatalogEntry --> Downloaded: POST marketplace/install
  Downloaded --> ArtifactsExtracted: tar.gz → local stores
  ArtifactsExtracted --> DBRegistered: plugins table row
  DBRegistered --> Migrated: MigrationRunner.Run
  Migrated --> RuntimeLoaded: Runtime.Load (if enabled)
  RuntimeLoaded --> Active: routes + UI + MCP available
  Active --> Upgraded: POST :pluginId/upgrade
  Active --> Disabled: PATCH enabled=false
  Active --> Uninstalled: DELETE :pluginId
  Uninstalled --> [*]
```

<Steps>
<Step title="Resolve catalog entry">

Admin UI or API calls `POST /api/v1/admin/plugins/marketplace/install` with `{ "name": "com.paca.checklist" }`. The API fetches `catalog/plugins.json` from `Paca-AI/paca-plugins` (default URL in `PLUGINS_MARKETPLACE_CATALOG_URL`).

</Step>

<Step title="Download and extract artifacts">

The installer downloads tar.gz artifacts (manifest required; backend, frontend, migrations, and MCP optional) and writes:

- `{PLUGINS_WASM_DIR}/{name}/backend.wasm`
- `{PLUGINS_WASM_DIR}/{name}/plugin.json`
- `{PLUGINS_WASM_DIR}/{name}/migrations/*.sql`
- `{PLUGINS_FRONTEND_DIR}/{name}/…`
- `{PLUGINS_MCP_DIR}/{name}/…`

</Step>

<Step title="Register, migrate, and load">

The API inserts a `plugins` row, runs `MigrationRunner`, then calls `Runtime.Load`. On failure at any step after artifact download, the DB row is deleted and artifacts are cleaned up.

</Step>

<Step title="Verify">

- `GET /api/v1/plugins` lists the plugin with `enabled: true`
- Frontend remote entry resolves at `/plugins/{name}/assets/remoteEntry.js`
- Plugin API responds at `/api/v1/plugins/{name}/projects/{projectId}/…`
- MCP server logs successful plugin load on restart

</Step>
</Steps>

### Upgrade and uninstall

`POST /api/v1/admin/plugins/:pluginId/upgrade` fetches the latest catalog version, enforces strict `X.Y.Z` semver (no pre-release or build metadata), downloads artifacts, runs idempotent migrations, reloads WASM **before** persisting the new version, and rolls back on failure.

`DELETE /api/v1/admin/plugins/:pluginId` removes the DB record, unloads the WASM module, and deletes artifact directories. Plugin schema data is retained unless the plugin provides down migrations.

## API endpoints

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `GET` | `/api/v1/plugins` | Optional | List installed plugins + extension settings |
| `ANY` | `/api/v1/plugins/:pluginId/*path` | Per-route manifest | Proxy to WASM `HandleRequest` |
| `GET` | `/api/v1/admin/plugins/marketplace` | `users.write` | List catalog entries |
| `POST` | `/api/v1/admin/plugins/marketplace/install` | `users.write` | Install from catalog |
| `POST` | `/api/v1/admin/plugins` | `users.write` | Manual install with inline manifest |
| `PATCH` | `/api/v1/admin/plugins/:pluginId` | `users.write` | Update version, manifest, or enabled flag |
| `POST` | `/api/v1/admin/plugins/:pluginId/upgrade` | `users.write` | Upgrade to latest catalog version |
| `DELETE` | `/api/v1/admin/plugins/:pluginId` | `users.write` | Uninstall plugin |
| `PATCH` | `/api/v1/admin/plugin-extension-settings` | `users.write` | Hide or reorder extension points |

:::endpoint GET /api/v1/plugins
List enabled and disabled plugins with parsed manifests and per-extension-point settings (`hidden`, `order`). Used by the web host registry and MCP plugin loader.
:::

:::endpoint POST /api/v1/admin/plugins/marketplace/install
Install a plugin by reverse-DNS `name` from the marketplace catalog. Downloads artifacts, registers the plugin, runs migrations, and loads the WASM runtime when `enabled` is true (default).
:::

:::endpoint ANY /api/v1/plugins/:pluginId/*path
Match the request against `backend.routes`, enforce route middleware, serialize the request to JSON, call the plugin's `HandleRequest` export, and return the plugin response envelope `{ status, headers, body }`.
:::

## Configuration

<ParamField body="PLUGINS_STORE" type="string" default="local">
`local` reads WASM from `PLUGINS_WASM_DIR`; `s3` reads from `STORAGE_BUCKET` + `PLUGINS_S3_PREFIX`.
</ParamField>

<ParamField body="PLUGINS_WASM_DIR" type="string" default="./plugins/local/backend">
Backend artifact root: `{dir}/{plugin-name}/backend.wasm`.
</ParamField>

<ParamField body="PLUGINS_FRONTEND_DIR" type="string" default="./plugins/local/frontend">
Frontend bundles; nginx maps to `/plugins/`.
</ParamField>

<ParamField body="PLUGINS_MCP_DIR" type="string" default="./plugins/local/mcp">
MCP ESM bundles; nginx maps to `/plugins-mcp/`.
</ParamField>

<ParamField body="PLUGINS_MARKETPLACE_CATALOG_URL" type="string" default="https://raw.githubusercontent.com/Paca-AI/paca-plugins/master/catalog/plugins.json">
Remote catalog JSON URL.
</ParamField>

<ParamField body="PLUGINS_MARKETPLACE_TIMEOUT" type="duration" default="20s">
HTTP timeout for catalog and artifact downloads.
</ParamField>

<ParamField body="PLUGINS_S3_PREFIX" type="string" default="plugins">
S3 key prefix when `PLUGINS_STORE=s3`; binaries at `{prefix}/{name}/backend.wasm`.
</ParamField>

:::files
plugins/local/
├── backend/
│   └── com.paca.checklist/
│       ├── plugin.json
│       ├── backend.wasm
│       └── migrations/
│           └── 0001_create_items.sql
├── frontend/
│   └── com.paca.checklist/
│       └── assets/
│           └── remoteEntry.js
└── mcp/
    └── com.paca.checklist/
        └── mcp.js
:::

## SDK packages

Plugin authoring uses three separate SDK repositories:

| Package | Language | Surface |
|---|---|---|
| `@paca-ai/plugin-sdk-react` | TypeScript | Frontend extension points + scoped HTTP client |
| `github.com/Paca-AI/plugin-sdk-go` | Go (TinyGo → WASM) | Backend host bridge wrappers |
| `@paca-ai/plugin-sdk-mcp` | TypeScript | MCP `PluginMCPEntry` + `PluginAPIClient` |

## Troubleshooting signals

| Symptom | Likely cause |
|---|---|
| Extension point empty after install | Plugin disabled, missing `frontend` manifest, or registration `hidden: true` |
| `remoteEntry.js` 404 | Frontend artifacts not installed or nginx `/plugins/` alias misconfigured |
| `plugin not found or disabled` on API call | Plugin name mismatch (`:pluginId` is reverse-DNS `name`, not DB UUID) or `enabled: false` |
| MCP tools missing | No `mcp.remoteEntryUrl`, MCP bundle not at `/plugins-mcp/`, or load warning in MCP stderr |
| Install rolls back | Migration failure or `Runtime.Load` error; check API logs for cleanup messages |
| `plugin is already up to date` | Installed version matches catalog; upgrade requires strictly newer `X.Y.Z` |

## Related pages

<CardGroup>
<Card title="Build a plugin" href="/build-plugin">
Author `plugin.json`, compile WASM and frontend remotes, and install locally.
</Card>
<Card title="Install marketplace plugins" href="/install-marketplace-plugins">
Install from the `Paca-AI/paca-plugins` catalog via admin UI or API.
</Card>
<Card title="Plugin SDK reference" href="/plugin-sdk-reference">
Typed APIs for React, Go host bridge, and MCP SDK packages.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` and load plugin-contributed tools.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Core and dynamically loaded plugin tool names and schemas.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog including plugin store paths.
</Card>
</CardGroup>

---

## 08. Deploy production

> Production Docker Compose topology, nginx gateway, storage backends (MinIO vs S3), secret generation, and scaling optional services.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/08-deploy-production.md
- Generated: 2026-06-13T22:06:09.389Z

### Source Files

- `deploy/README.md`
- `deploy/docker-compose.prod.yml`
- `deploy/.env.production.example`
- `deploy/nginx/gateway.conf`
- `scripts/install.sh`
- `services/api/docker-entrypoint.sh`

---
title: "Deploy production"
description: "Production Docker Compose topology, nginx gateway, storage backends (MinIO vs S3), secret generation, and scaling optional services."
---

Production deployment uses `deploy/docker-compose.prod.yml` (published as `docker-compose.yml` on GitHub releases): pre-built images from Docker Hub, a single nginx gateway on port 80, bundled PostgreSQL and Valkey, optional MinIO or AWS S3 for attachments, and optional `ai-agent`. Schema migrations are embedded in the API binary and run automatically on every API startup.

## Topology

The compose project name is `paca`. Only the `gateway` service publishes a host port (`GATEWAY_PORT`, default `80`). All browser and API traffic enters through nginx; internal services communicate on the Docker network.

```mermaid
flowchart TB
    subgraph public["Public entry"]
        GW["gateway<br/>nginx:1.27-alpine"]
    end

    subgraph app["Application services"]
        WEB["web<br/>pacaai/paca-web"]
        API["api<br/>pacaai/paca-api"]
        RT["realtime<br/>pacaai/paca-realtime"]
        AG["ai-agent<br/>pacaai/paca-ai-agent<br/>(optional)"]
    end

    subgraph data["Stateful services"]
        PG["postgres:16-alpine<br/>(optional)"]
        VK["valkey/valkey:8-alpine"]
        MINIO["minio/minio<br/>(optional)"]
    end

    Client["Clients / browsers"] --> GW
    GW -->|"/"| WEB
    GW -->|"/api/"| API
    GW -->|"/ws/"| RT
    GW -->|"/storage/"| MINIO
    GW -->|"/plugins/"| API
    GW -->|"/plugins-mcp/"| API

    API --> PG
    API --> VK
    API --> MINIO
    RT --> VK
    RT --> API
    AG --> PG
    AG --> VK
    AG --> API
    AG -.->|"/var/run/docker.sock"| HostDocker["Host Docker daemon"]
```

### Service inventory

| Service | Image | Host port | Persistent volume | Health probe |
|---|---|---|---|---|
| `gateway` | `nginx:1.27-alpine` | `${GATEWAY_PORT:-80}` | — (bind-mounts `nginx/gateway.conf`) | — |
| `api` | `${PACA_API_IMAGE:-pacaai/paca-api:latest}` | — | `backend_plugins`, `frontend_plugins`, `mcp_plugins` | `GET /api/healthz` |
| `web` | `${PACA_WEB_IMAGE:-pacaai/paca-web:latest}` | — | — | — |
| `realtime` | `${PACA_REALTIME_IMAGE:-pacaai/paca-realtime:latest}` | — | — | — |
| `postgres` | `postgres:16-alpine` | — | `postgres_data` | `pg_isready` |
| `valkey` | `valkey/valkey:8-alpine` | — | `valkey_data` | `valkey-cli ping` |
| `minio` | `minio/minio:latest` | — | `minio_data` | `mc ready local` |
| `ai-agent` | `${PACA_AI_AGENT_IMAGE:-pacaai/paca-ai-agent:latest}` | `${AI_AGENT_PORT:-8082}` | — (mounts host Docker socket) | — |

Named volumes: `postgres_data`, `valkey_data`, `minio_data`, `backend_plugins`, `frontend_plugins`, `mcp_plugins`.

<Note>
Release artifacts ship as `docker-compose.yml` and `nginx/gateway.conf`. The repository copies live under `deploy/docker-compose.prod.yml` and `deploy/nginx/gateway.conf`.
</Note>

## Deploy paths

<Tabs>
<Tab title="Install script (recommended)">

The install script downloads release artifacts, walks through database/storage/web/agent choices, generates secrets, writes `.env`, and starts the stack.

<Steps>
<Step title="Download and run">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh
bash install.sh
```

Non-interactive mode with defaults and auto-generated secrets:

```bash
bash <(curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh)
```

Environment overrides:

| Variable | Default | Effect |
|---|---|---|
| `PACA_DIR` | `./paca` | Installation directory |
| `PACA_VERSION` | `latest` | Release tag (`v1.2.3` pins image tags) |
| `PACA_YES` | `0` | Set to `1` to skip prompts |

</Step>
<Step title="Verify">

```bash
docker compose --env-file .env ps
curl -s http://localhost/api/healthz
```

Expected health response:

```json
{"status":"ok"}
```

</Step>
</Steps>

</Tab>
<Tab title="Manual setup">

<Steps>
<Step title="Download compose and gateway config">

```bash
mkdir -p paca/nginx && cd paca
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf
```

</Step>
<Step title="Create `.env`">

Copy `deploy/.env.production.example` from the repository as a reference, then set required secrets (see [Required secrets](#required-secrets)).

</Step>
<Step title="Start the stack">

```bash
docker compose --env-file .env up -d
```

</Step>
<Step title="Verify">

```bash
curl -s "${PUBLIC_URL}/api/healthz"
docker compose --env-file .env logs api --tail 20
```

The API logs `schema migrations applied` on first healthy startup.

</Step>
</Steps>

</Tab>
</Tabs>

## Required secrets

Compose enforces `JWT_SECRET` and `ADMIN_PASSWORD` at container start. The API process additionally requires `DATABASE_URL`, `REDIS_URL`, `ADMIN_USERNAME`, `STORAGE_ACCESS_KEY_ID`, and `STORAGE_SECRET_ACCESS_KEY` (supplied by compose defaults when using bundled services).

| Secret | Generate | Used by | Notes |
|---|---|---|---|
| `JWT_SECRET` | `openssl rand -hex 32` | `api` | Signs access and refresh tokens |
| `ADMIN_PASSWORD` | Strong password | `api` | Seeds default admin on first startup |
| `POSTGRES_PASSWORD` | `openssl rand -hex 16` | `postgres`, `api` | Only when bundled PostgreSQL runs |
| `ENCRYPTION_KEY` | `openssl rand -hex 32` | `api`, `ai-agent` | Exactly 64 lowercase hex chars (32 bytes, AES-256). Required for plugin secrets and LLM key decryption |
| `AGENT_API_KEY` | `openssl rand -hex 32` | `api`, `ai-agent` (`PACA_API_KEY`) | Pre-shared service key; must match across both services |
| `INTERNAL_API_KEY` | `openssl rand -hex 32` | `ai-agent` | Authenticates ai-agent → API calls. Empty value disables agent endpoint authentication |
| `STORAGE_ACCESS_KEY_ID` | Random string | `api`, `minio` (`MINIO_ROOT_USER`) | MinIO root user when self-hosted |
| `STORAGE_SECRET_ACCESS_KEY` | Random string | `api`, `minio` (`MINIO_ROOT_PASSWORD`) | MinIO root password when self-hosted |

<Warning>
Back up `ENCRYPTION_KEY` before any database migration or restore. A different key makes existing encrypted plugin secrets and LLM API keys permanently unreadable.
</Warning>

When enabling `ai-agent`, set all four agent-related keys (`AGENT_API_KEY`, `INTERNAL_API_KEY`, `ENCRYPTION_KEY`, plus matching `PACA_API_KEY` on the agent side via compose). The install script generates these automatically when the agent is included.

## Object storage

The API uses an S3-compatible client. `STORAGE_PROVIDER=minio` (default) runs a bundled MinIO container and rewrites presigned URLs through the gateway. `STORAGE_PROVIDER=s3` connects to AWS S3 directly and returns presigned URLs without gateway rewriting.

| Aspect | MinIO (default) | AWS S3 |
|---|---|---|
| Container | `minio` started | Suppress with `--scale minio=0` |
| `STORAGE_PROVIDER` | `minio` | `s3` |
| `STORAGE_ENDPOINT` | `minio:9000` | Empty (default AWS regional endpoint) |
| `STORAGE_PUBLIC_URL` | `${PUBLIC_URL}/storage` | Empty (presigned URLs are self-contained) |
| `STORAGE_USE_SSL` | `false` | `true` |
| Path style | Forced (MinIO requirement) | Virtual-hosted (AWS default) |
| Bucket creation | API calls `EnsureBucket` on startup | Operator must create the bucket |

Presigned URL lifetimes: uploads valid for 1 hour, downloads for 15 minutes. Clients upload and download directly against the storage backend; the API stays out of the data plane.

<Tabs>
<Tab title="Self-hosted MinIO">

```bash
# .env excerpt
STORAGE_PROVIDER=minio
STORAGE_ENDPOINT=minio:9000
STORAGE_PUBLIC_URL=https://paca.example.com/storage
STORAGE_REGION=us-east-1
STORAGE_BUCKET=paca
STORAGE_ACCESS_KEY_ID=<strong-access-key>
STORAGE_SECRET_ACCESS_KEY=<strong-secret-key>
STORAGE_USE_SSL=false

docker compose --env-file .env up -d
```

The gateway proxies `/storage/` to `minio:9000`, rewriting the `Host` header to `minio:9000` so AWS Signature V4 validation succeeds on presigned requests.

</Tab>
<Tab title="AWS S3">

```bash
# .env excerpt
STORAGE_PROVIDER=s3
STORAGE_ENDPOINT=
STORAGE_PUBLIC_URL=
STORAGE_REGION=us-east-1
STORAGE_BUCKET=your-s3-bucket
STORAGE_ACCESS_KEY_ID=AKIA...
STORAGE_SECRET_ACCESS_KEY=...
STORAGE_USE_SSL=true

docker compose --env-file .env up -d --scale minio=0
```

</Tab>
</Tabs>

## Nginx gateway routing

`deploy/nginx/gateway.conf` mounts as `/etc/nginx/conf.d/default.conf` inside the `gateway` container. It is the single public entrypoint.

| Path | Upstream | Behavior |
|---|---|---|
| `/api/` | `api:8080` | Rate-limited (100 req/s per IP, burst 50). Forwards prefix as-is |
| `/ws/` | `realtime:3001` | WebSocket proxy; strips `/ws/` prefix. Client path: `/ws/socket.io` |
| `/storage/` | `minio:9000` | Variable-based proxy; `client_max_body_size 0` for large uploads |
| `/plugins/` | Volume `frontend_plugins` | Static plugin micro-frontend bundles |
| `/plugins-mcp/` | Volume `mcp_plugins` | Static MCP plugin ESM bundles |
| `/` | `web:3000` | React SPA with WebSocket upgrade support |

Security headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) apply to all responses. Default request body limit is 10 MB except on `/storage/`.

<Info>
Place TLS termination in front of the gateway (reverse proxy, load balancer, or cloud ingress). Set `PUBLIC_URL` to the HTTPS origin and `COOKIE_SECURE=true` so auth cookies require TLS.
</Info>

## Scale optional services

Suppress bundled containers with `--scale <service>=0`. Flags combine in a single `up` command.

| Goal | `.env` changes | Compose flag |
|---|---|---|
| External PostgreSQL | Set `DATABASE_URL` to managed connection string | `--scale postgres=0` |
| AWS S3 | Set `STORAGE_PROVIDER=s3` and AWS credentials | `--scale minio=0` |
| Skip AI agent | — | `--scale ai-agent=0` |
| External web / CDN | Build and host SPA separately | `--scale web=0` |

<CodeGroup>
```bash title="External PostgreSQL"
docker compose --env-file .env up -d --scale postgres=0
```

```bash title="AWS S3, no agent"
docker compose --env-file .env up -d --scale minio=0 --scale ai-agent=0
```

```bash title="External DB + S3"
docker compose --env-file .env up -d --scale postgres=0 --scale minio=0
```
</CodeGroup>

### External web hosting

When `--scale web=0`, the gateway still serves `/api/`, `/ws/`, `/storage/`, `/plugins/`, and `/plugins-mcp/`. Build the SPA from source and deploy `dist/` to your CDN or static host. Point the CDN's API proxy at `${PUBLIC_URL}/api`.

### AI agent requirements

When enabled, `ai-agent` mounts `/var/run/docker.sock` and spawns OpenHands agent-server containers (`AGENT_SERVER_IMAGE`, default `ghcr.io/openhands/agent-server:latest-python`). It exposes `${AI_AGENT_PORT:-8082}` on the host for direct access, though most traffic flows through the gateway and API.

## Startup lifecycle

On API container start, `services/api/docker-entrypoint.sh` creates and chowns plugin directories (`/plugins`, `/plugins-frontend`, `/plugins-mcp`), then execs the API binary. The bootstrap sequence:

1. Applies embedded SQL migrations (`CREATE TABLE IF NOT EXISTS` / idempotent upserts).
2. Ensures the storage bucket exists (MinIO only).
3. Loads enabled plugins, runs per-plugin DB migrations, and starts WASM modules.
4. Seeds the admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD`.

No manual migration step is required for core schema upgrades.

## Pin a release version

Set image variables in `.env` to lock to a specific release:

```bash
PACA_API_IMAGE=pacaai/paca-api:1.2.3
PACA_WEB_IMAGE=pacaai/paca-web:1.2.3
PACA_REALTIME_IMAGE=pacaai/paca-realtime:1.2.3
PACA_AI_AGENT_IMAGE=pacaai/paca-ai-agent:1.2.3
```

The install script sets these automatically from `PACA_VERSION` (strips leading `v` for Docker tags).

## Upgrade

Pull new images and recreate containers:

```bash
docker compose pull
docker compose --env-file .env up -d
```

Database migrations run automatically on the next API startup. See [Upgrade and migration](/upgrade-migration) for compose project rename volume migration (`paca-prod` → `paca`).

### Volume migration from `paca-prod`

Docker Compose namespaces volumes by project name. Existing volumes prefixed `paca-prod_` are not attached to the `paca` project automatically.

<Steps>
<Step title="Stop the old stack">

```bash
docker compose -p paca-prod --env-file .env down
```

Volumes remain on disk.

</Step>
<Step title="Copy each volume">

```bash
docker volume create paca_postgres_data
docker run --rm \
  -v paca-prod_postgres_data:/from \
  -v paca_postgres_data:/to \
  alpine sh -c "cp -av /from/. /to/"
docker volume rm paca-prod_postgres_data
```

Repeat for `minio_data`, `valkey_data`, and plugin volumes as needed.

</Step>
<Step title="Start the new stack">

```bash
docker compose --env-file .env up -d
```

</Step>
</Steps>

## Verification signals

| Check | Command | Expected |
|---|---|---|
| API health | `curl -s ${PUBLIC_URL}/api/healthz` | `{"status":"ok"}` |
| Gateway routing | `curl -sI ${PUBLIC_URL}/api/healthz` | HTTP 200 |
| Container health | `docker compose --env-file .env ps` | `api`, `postgres`, `valkey`, `minio` show `healthy` |
| Migrations | `docker compose --env-file .env logs api` | `schema migrations applied` |
| Realtime | Connect via Socket.IO at `${PUBLIC_URL}` with path `/ws/socket.io` | WebSocket upgrade succeeds |

<AccordionGroup>
<Accordion title="Common production misconfigurations">

| Symptom | Likely cause |
|---|---|
| Auth cookies not set | `COOKIE_SECURE=true` but `PUBLIC_URL` uses `http://` |
| Attachment upload fails (MinIO) | `STORAGE_PUBLIC_URL` does not match `${PUBLIC_URL}/storage` |
| Realtime disconnects | `CORS_ORIGINS` (defaults to `PUBLIC_URL`) does not match browser origin |
| Agent cannot decrypt LLM keys | `ENCRYPTION_KEY` mismatch between `api` and `ai-agent` |
| Plugin secrets unreadable after restore | `ENCRYPTION_KEY` changed from original value |

</Accordion>
</AccordionGroup>

## Key environment variables

<ParamField body="PUBLIC_URL" type="string">
Externally reachable base URL (no trailing slash). Used for plugin callbacks, CORS defaults, and MinIO `STORAGE_PUBLIC_URL` derivation.
</ParamField>

<ParamField body="GATEWAY_PORT" type="number" default="80">
Host port mapped to the nginx gateway.
</ParamField>

<ParamField body="COOKIE_SECURE" type="boolean" default="true">
Set `false` only when serving over plain HTTP with no TLS termination. Install script sets this from whether `PUBLIC_URL` starts with `https://`.
</ParamField>

<ParamField body="DATABASE_URL" type="string">
PostgreSQL connection string. Defaults to bundled `postgres` service when unset in compose.
</ParamField>

<ParamField body="REDIS_URL" type="string">
Valkey/Redis URL. Defaults to `redis://valkey:6379/0`.
</ParamField>

<ParamField body="CORS_ORIGINS" type="string">
Comma-separated origins for the realtime service. Defaults to `PUBLIC_URL`.
</ParamField>

<ParamField body="PLUGINS_STORE" type="string" default="local">
`local` reads WASM from filesystem volumes; `s3` reads from the configured `STORAGE_*` bucket under `PLUGINS_S3_PREFIX`.
</ParamField>

For the full variable catalog across all services, see [Configuration reference](/configuration-reference).

## Related pages

<CardGroup>
<Card title="Installation" href="/installation">
Prerequisites, install script overview, and first-time secret requirements.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey event decoupling, and gateway routing context.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Complete environment variable catalog for API, web, realtime, and ai-agent.
</Card>
<Card title="Upgrade and migration" href="/upgrade-migration">
Image pulls, automatic DB migrations, and volume migration procedures.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, storage misconfiguration, and auth cookie issues.
</Card>
</CardGroup>

---

## 09. Local development

> Dev Compose stack, hot-reload per service, host-side run commands, infra-only mode, and port map through the nginx gateway.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/09-local-development.md
- Generated: 2026-06-13T22:09:16.309Z

### Source Files

- `docs/guides/local-development.md`
- `deploy/docker-compose.dev.yml`
- `deploy/.env.dev.example`
- `services/api/Makefile`
- `apps/web/package.json`
- `services/realtime/package.json`

---
title: "Local development"
description: "Dev Compose stack, hot-reload per service, host-side run commands, infra-only mode, and port map through the nginx gateway."
---

Paca's contributor stack is defined in `deploy/docker-compose.dev.yml` (project name `paca-dev`). One Compose file runs PostgreSQL, Valkey, MinIO, the Go API, Bun web and realtime services, the Python ai-agent worker, and an nginx gateway on port 80. Application services bind-mount the monorepo source tree and reload on change; the gateway is the single browser entrypoint at `http://localhost`.

## Prerequisites

<Note>
Docker with the Compose plugin is the only hard requirement for the full containerized stack. Each application service builds from a `Dockerfile.dev` in its own directory.
</Note>

For host-side service development (optional), install:

| Tool | Used by |
|---|---|
| Go 1.23+ | `services/api` |
| [Bun](https://bun.sh) | `apps/web`, `services/realtime` |
| Python 3.12+ with [uv](https://docs.astral.sh/uv/) | `services/ai-agent` |
| [air](https://github.com/air-verse/air) (optional) | API hot-reload on the host |

## Start the full dev stack

<Steps>
<Step title="Launch containers">

```bash
docker compose -f deploy/docker-compose.dev.yml up -d
```

</Step>
<Step title="Open the app">

Browse to `http://localhost`. The nginx gateway on port 80 proxies all browser traffic to the correct upstream.

</Step>
<Step title="Verify API health">

```bash
curl -s http://localhost/api/healthz
```

Expect a successful response from the Go API through the gateway.

</Step>
</Steps>

Stop the stack:

```bash
docker compose -f deploy/docker-compose.dev.yml down
```

Remove data volumes (destructive — wipes Postgres, Valkey, and MinIO data):

```bash
docker compose -f deploy/docker-compose.dev.yml down -v
```

## Dev stack services

| Service | Technology | Container port | Host exposure | Hot-reload |
|---|---|---|---|---|
| `gateway` | nginx 1.27 | 80 | **80** | — |
| `web` | React + TanStack Start + Vite | 3000 | via gateway | Vite HMR (`bun run dev`) |
| `api` | Go + Gin | 8080 | via gateway | [air](https://github.com/air-verse/air) |
| `realtime` | Bun + Socket.IO | 3001 | via gateway (`/ws/`) | `bun --watch` |
| `ai-agent` | Python + FastAPI + OpenHands | 8080 | **8082** | uvicorn `--reload` |
| `postgres` | postgres:16-alpine | 5432 | **5432** | — |
| `valkey` | valkey/valkey:8-alpine | 6379 | **6379** | — |
| `minio` | minio/minio | 9000 / 9001 | **9000** / **9001** | — |

<Info>
The `web` and `realtime` containers are not published on host ports directly. Browser clients reach them only through the gateway at port 80.
</Info>

## Gateway routing

The gateway config at `deploy/nginx/gateway.conf` is the public entrypoint. All browser clients (`apps/web` API client and Socket.IO client) use `window.location.origin`, so paths below must resolve on the same host the user opens.

```mermaid
flowchart LR
  Browser["Browser :80"]
  GW["gateway nginx"]
  API["api :8080"]
  RT["realtime :3001"]
  WEB["web :3000"]
  MINIO["minio :9000"]

  Browser --> GW
  GW -->|"/api/*"| API
  GW -->|"/ws/*"| RT
  GW -->|"/storage/*"| MINIO
  GW -->|"/plugins/*"| Plugins["plugins/local/frontend"]
  GW -->|"/plugins-mcp/*"| MCP["plugins/local/mcp"]
  GW -->|"/"| WEB
```

| Gateway path | Upstream | Behavior |
|---|---|---|
| `/api/` | `api:8080` | Forwards `/api/v1/…` and `/api/healthz` unchanged |
| `/ws/` | `realtime:3001` | Strips `/ws/` prefix; Socket.IO path is `/ws/socket.io` |
| `/storage/` | `minio:9000` | Presigned upload/download URLs rewritten to `STORAGE_PUBLIC_URL` |
| `/plugins/` | Static volume | Local plugin frontend bundles (`remoteEntry.js`) |
| `/plugins-mcp/` | Static volume | MCP plugin ESM bundles |
| `/` | `web:3000` | SPA + Vite HMR WebSocket upgrade |

Socket.IO client configuration in `apps/web` connects to `window.location.origin` with path `/ws/socket.io` and `withCredentials: true`.

## Hot-reload per service

### API (`services/api`)

Inside the container, the `api` service runs `air` with `.air.toml` configured for Docker Desktop bind mounts: `poll = true` at 500 ms because inotify events do not cross the macOS VM boundary. `GOFLAGS=-p=1` limits parallel compilation to avoid OOM kills.

On the host, `make run` executes `go run ./cmd/api/main.go` (no file watcher). For container-equivalent reload, run `air` from `services/api` after installing it.

### Web (`apps/web`)

The container runs `bun install && bun run dev --host 0.0.0.0 --port 3000`. When `DOCKER=true`, Vite enables filesystem polling and sets `hmr.clientPort: 3000` so HMR works through the gateway.

### Realtime (`services/realtime`)

The container runs `bun install && bun run --watch src/index.ts`. The `dev` script in `package.json` is identical: `bun run --watch src/index.ts`.

### AI agent (`services/ai-agent`)

`Dockerfile.dev` starts `uvicorn src.main:app --host 0.0.0.0 --port 8080 --reload`. The service is mapped to host port **8082** (`8082:8080`). Default settings port is 8080.

## Infra-only mode

Run only shared dependencies when you want application services on the host:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d postgres valkey
```

PostgreSQL applies migrations automatically on first start — SQL files from `services/api/migrations/` are mounted at `/docker-entrypoint-initdb.d`.

To include object storage without running app services:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d postgres valkey minio
```

<Warning>
Without the gateway, a host-run `apps/web` dev server on port 3000 cannot reach `/api` or `/ws` on the same origin. Use the full compose stack (or run all app services and access through port 80) for end-to-end browser testing.
</Warning>

## Host-side run commands

Copy environment files on first setup and point connection strings at `localhost` instead of Docker service names.

### API

```bash
cd services/api
cp .env.example .env   # DATABASE_URL=postgres://paca:paca@localhost:5432/paca
make run               # or: air
```

<ParamField body="DATABASE_URL" type="string" required>
`postgres://paca:paca@localhost:5432/paca?sslmode=disable` when using compose Postgres.
</ParamField>

<ParamField body="REDIS_URL" type="string" required>
`redis://localhost:6379/0`
</ParamField>

<ParamField body="STORAGE_ENDPOINT" type="string">
`localhost:9000` with `STORAGE_PUBLIC_URL=http://localhost/storage` when MinIO runs in compose.
</ParamField>

### Web

```bash
cd apps/web
bun install
bun run dev            # Vite at http://localhost:3000
```

### Realtime

```bash
cd services/realtime
bun install
bun run dev
```

Required environment variables:

<ParamField body="API_URL" type="string" required>
Internal API base URL, e.g. `http://localhost:8080` (bypasses nginx).
</ParamField>

<ParamField body="REDIS_URL" type="string" required>
`redis://localhost:6379/0`
</ParamField>

<ParamField body="CORS_ORIGINS" type="string">
Comma-separated allowed origins. Default: `http://localhost:3000`.
</ParamField>

### AI agent

```bash
cd services/ai-agent
uv sync
cp .env.example .env   # adjust hostnames to localhost
uv run uvicorn src.main:app --reload --port 8080
```

Update `.env` for host-side infra:

| Variable | Container default | Host-side value |
|---|---|---|
| `VALKEY_URL` | `redis://valkey:6379/0` | `redis://localhost:6379/0` |
| `DATABASE_URL` | `postgres://paca:paca@postgres:5432/paca` | `postgres://paca:paca@localhost:5432/paca` |
| `API_BASE_URL` | `http://api:8080` | `http://localhost:8080` |
| `GATEWAY_BASE_URL` | `http://gateway` | `http://localhost` |
| `PACA_API_KEY` | `dev-agent-api-key-change-in-production` | Must match `AGENT_API_KEY` on the API |

<Note>
`apps/mcp` is stateless. Run it with `npx @paca-ai/paca-mcp` pointed at the running API — see the Connect MCP page.
</Note>

## Exposing through a tunnel or reverse proxy

Copy `deploy/.env.dev.example` to `deploy/.env.dev` and set public host variables:

```bash
cp deploy/.env.dev.example deploy/.env.dev
# Edit PUBLIC_HOST and VITE_ALLOWED_HOST
docker compose --env-file deploy/.env.dev -f deploy/docker-compose.dev.yml up -d
```

<ParamField body="PUBLIC_HOST" type="string">
Full public URL without trailing slash, e.g. `https://abc123.ngrok-free.app`. Drives `STORAGE_PUBLIC_URL`, `PUBLIC_URL`, and `CORS_ORIGINS` in compose.
</ParamField>

<ParamField body="VITE_ALLOWED_HOST" type="string">
Hostname only (no scheme or port). Passed to Vite `server.allowedHosts` so the dev server accepts tunnel requests.
</ParamField>

## Migrations

SQL migrations live in `services/api/migrations/` and run lexicographically. Fresh Postgres containers apply them at init.

To apply manually against a running database:

```bash
cd services/api
DATABASE_URL=postgres://paca:paca@localhost:5432/paca?sslmode=disable make migrate-up
```

Destructive reset:

```bash
make migrate-down   # drops and recreates public schema
```

## Local plugin stores

The dev compose file mounts `plugins/local/` into API and gateway containers:

| Mount | API path | Gateway path | Contents |
|---|---|---|---|
| `plugins/local/backend` | `/plugins` | — | WASM binaries + SQL migrations |
| `plugins/local/frontend` | `/plugins-frontend` | `/var/www/plugins` | Module-federation `remoteEntry.js` |
| `plugins/local/mcp` | `/plugins-mcp` | `/var/www/plugins-mcp` | MCP ESM bundles |

Backend plugin artifacts are API-only; the gateway serves frontend and MCP bundles at `/plugins/` and `/plugins-mcp/`.

## Default dev credentials

| Resource | Value |
|---|---|
| App login | `admin` / `adminpassword` |
| PostgreSQL | `paca:paca@localhost:5432/paca` |
| MinIO console | `minioadmin` / `minioadmin` at `http://localhost:9001` |
| Agent API key | `dev-agent-api-key-change-in-production` (`AGENT_API_KEY` on API, `PACA_API_KEY` on ai-agent) |
| Internal API key | `dev-internal-key-change-in-production` (ai-agent → API) |
| JWT secret | `dev-change-in-production` |

<Warning>
These credentials are intentionally weak. Never use them outside local development.
</Warning>

## Architecture notes

- `services/api` owns persistent state and publishes domain events to Valkey Streams.
- `services/realtime` consumes those events and fans them out over Socket.IO.
- `services/ai-agent` reads agent trigger events from a separate Valkey Stream and manages Docker containers for OpenHands conversations (requires `/var/run/docker.sock`).
- Compose caches Go build output (`go_build_cache`) and Bun `node_modules` in named volumes so restarts do not cold-start dependency installs.

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
First successful run: admin login, project creation, API key, and health checks.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey event decoupling, and gateway routing model.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Environment variables across API, web, realtime, and ai-agent.
</Card>
<Card title="Contributing" href="/contributing">
Repository layout, PR checklist, and test surfaces.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, Valkey connectivity, storage misconfiguration, and MCP errors.
</Card>
</CardGroup>

---

## 10. Connect MCP server

> Configure @paca-ai/paca-mcp with npx for Claude Desktop, VS Code, or any MCP client; env vars, agent mode, and plugin tool loading.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/10-connect-mcp-server.md
- Generated: 2026-06-13T22:07:44.697Z

### Source Files

- `docs/guides/mcp-server-setup.md`
- `apps/mcp/README.md`
- `apps/mcp/src/server.ts`
- `apps/mcp/src/index.ts`
- `apps/mcp/.env.example`
- `apps/mcp/src/plugin-loader.ts`

---
title: "Connect MCP server"
description: "Configure @paca-ai/paca-mcp with npx for Claude Desktop, VS Code, or any MCP client; env vars, agent mode, and plugin tool loading."
---

`@paca-ai/paca-mcp` is a stdio MCP server that authenticates to the Paca REST API with `X-API-Key`, optionally impersonates a project agent via `X-Agent-ID`, filters core tools by permissions at startup, and merges plugin-contributed tools from `GET /api/v1/plugins`. MCP clients launch it with `npx -y @paca-ai/paca-mcp` and pass configuration through environment variables.

## Prerequisites

<Steps>
<Step title="Run Paca and create an API key">

- A running Paca stack (local or deployed). See [Installation](/installation) and [Quickstart](/quickstart).
- Node.js 18+ with `npx` on `PATH`.
- A Paca API key from **Settings → API Keys** in the web UI (user mode), or the server `AGENT_API_KEY` value (agent mode).

</Step>
<Step title="Confirm API reachability">

```bash
curl -s "${PACA_API_URL:-http://localhost:8080}/api/healthz"
```

A healthy API returns HTTP 200. If you use the nginx gateway on port 80, set `PACA_API_URL` to that gateway base URL instead of the internal API port.

</Step>
</Steps>

## Package and launch command

| Field | Value |
|---|---|
| npm package | `@paca-ai/paca-mcp` |
| Launch command | `npx -y @paca-ai/paca-mcp` |
| Transport | stdio (Model Context Protocol) |
| Version check | `npx @paca-ai/paca-mcp --version` |

<Note>
The package is published to npm. No local clone or build step is required for client integration.
</Note>

## Client configuration

Every MCP client needs the same subprocess shape: `command` = `npx`, `args` = `["-y", "@paca-ai/paca-mcp"]`, plus the environment variables in the next section.

<Tabs>
<Tab title="Claude Desktop">

Edit the Claude Desktop config:

- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "paca": {
      "command": "npx",
      "args": ["-y", "@paca-ai/paca-mcp"],
      "env": {
        "PACA_API_KEY": "your-api-key-here",
        "PACA_API_URL": "http://localhost:8080"
      }
    }
  }
}
```

Restart Claude Desktop after saving.

</Tab>
<Tab title="VS Code">

Add to user `settings.json` or workspace `.vscode/mcp.json`:

```json
{
  "mcp": {
    "servers": {
      "paca": {
        "command": "npx",
        "args": ["-y", "@paca-ai/paca-mcp"],
        "env": {
          "PACA_API_KEY": "your-api-key-here",
          "PACA_API_URL": "http://localhost:8080"
        }
      }
    }
  }
}
```

Reload the window or restart the MCP host after editing.

</Tab>
<Tab title="Claude Code">

**Project-level** (recommended for teams): create `.claude/mcp.json` in the repo root with the same `mcpServers.paca` block as Claude Desktop.

**Global CLI**:

```bash
claude mcp add paca \
  --env PACA_API_KEY=<key> \
  --env PACA_API_URL=<url> \
  -- npx -y @paca-ai/paca-mcp
```

<Warning>
Do not commit API keys. Add `.claude/mcp.json` to `.gitignore` when it contains secrets, or inject keys from the shell environment.
</Warning>

</Tab>
<Tab title="Any MCP client">

```json
{
  "name": "paca",
  "command": "npx",
  "args": ["-y", "@paca-ai/paca-mcp"],
  "env": {
    "PACA_API_KEY": "your-api-key-here",
    "PACA_API_URL": "http://localhost:8080"
  }
}
```

Programmatic clients can use `@modelcontextprotocol/sdk` with `StdioClientTransport` pointed at the same command and `env` map.

</Tab>
</Tabs>

## Environment variables

<ParamField body="PACA_API_KEY" type="string" required>
API key sent as `X-API-Key` on every Paca request. In user mode, use a personal key from **Settings → API Keys**. In agent mode, use the server-wide `AGENT_API_KEY` configured on the Paca API service.
</ParamField>

<ParamField body="PACA_API_URL" type="string" default="http://localhost:8080">
Base URL for Paca REST calls (`/api/v1/...`). Defaults to `http://localhost:8080` when unset.
</ParamField>

<ParamField body="PACA_GATEWAY_URL" type="string">
Optional base URL for resolving plugin MCP bundle paths (for example `/plugins-mcp/<id>/mcp.js`). In Docker deployments the nginx gateway serves these bundles, not the API service. When set, plugin `remoteEntryUrl` values resolve against this URL instead of `PACA_API_URL`.
</ParamField>

<ParamField body="PACA_AGENT_ID" type="string (UUID)">
Agent UUID forwarded as `X-Agent-ID` on API requests. Requires `PACA_PROJECT_ID`. Only honored when `PACA_API_KEY` is the server `AGENT_API_KEY`.
</ParamField>

<ParamField body="PACA_PROJECT_ID" type="string (UUID)">
Scopes the MCP server to one project. Required when `PACA_AGENT_ID` is set. In single-project mode, tool calls that pass a different `projectId` argument are rejected.
</ParamField>

| Variable | Required | Default | Used for |
|---|---|---|---|
| `PACA_API_KEY` | Yes | — | `X-API-Key` authentication |
| `PACA_API_URL` | No | `http://localhost:8080` | REST API base URL |
| `PACA_GATEWAY_URL` | No | `PACA_API_URL` | Plugin MCP bundle URL resolution |
| `PACA_AGENT_ID` | No* | — | Agent impersonation (`X-Agent-ID`) |
| `PACA_PROJECT_ID` | No* | — | Single-project scope and permission fetch |

\* `PACA_PROJECT_ID` is required when `PACA_AGENT_ID` is set. The server exits at startup if agent ID is present without project ID.

## Operating modes

The MCP server derives its mode from `PACA_AGENT_ID` and `PACA_PROJECT_ID`, then fetches permissions once at startup to filter the core tool list.

| Mode | Env trigger | `PACA_API_KEY` | Permission source | Tool scope |
|---|---|---|---|---|
| User global | Neither ID set | Personal API key | Filtering disabled (all core tools exposed) | All projects; project-scoped tools may fail without `projectId` |
| User single-project | `PACA_PROJECT_ID` only | Personal API key | `GET /api/v1/users/me/global-permissions` + `GET /api/v1/projects/{id}/members/me/permissions` | One project |
| Agent single-project | Both IDs set | Server `AGENT_API_KEY` | `GET /api/v1/projects/{id}/members/me/permissions` with `X-Agent-ID` | One project; `projectId` arguments locked |

### Agent mode configuration

AI agents inside Paca run in single-project mode. The global agent key is configured on the API service, not per agent:

```bash
# On the Paca server
echo "$AGENT_API_KEY"
```

<CodeGroup>
```json Claude Desktop / Claude Code
{
  "mcpServers": {
    "paca": {
      "command": "npx",
      "args": ["-y", "@paca-ai/paca-mcp"],
      "env": {
        "PACA_API_KEY": "<AGENT_API_KEY from server>",
        "PACA_API_URL": "http://localhost:8080",
        "PACA_AGENT_ID": "550e8400-e29b-41d4-a716-446655440000",
        "PACA_PROJECT_ID": "660e8400-e29b-41d4-a716-446655440001"
      }
    }
  }
}
```
```json Docker stack (with plugin bundles)
{
  "mcpServers": {
    "paca": {
      "command": "npx",
      "args": ["-y", "@paca-ai/paca-mcp"],
      "env": {
        "PACA_API_KEY": "<AGENT_API_KEY from server>",
        "PACA_API_URL": "http://api:8080",
        "PACA_GATEWAY_URL": "http://gateway",
        "PACA_AGENT_ID": "<agent-uuid>",
        "PACA_PROJECT_ID": "<project-uuid>"
      }
    }
  }
}
```
</CodeGroup>

<Tip>
Set `PACA_PROJECT_ID` even in user mode to reduce exposed tools and avoid project-scoped calls without context.
</Tip>

### Permission filtering behavior

- Core tools map to permission keys such as `tasks.read`, `tasks.write`, `docs.read`, and `project.members.write`. Tools without a mapping are allowed by default.
- Plugin tools are **not** filtered at the MCP layer; the Paca API enforces authorization when plugin handlers call back into the platform.
- If permission fetching fails, core tools remain available for backward compatibility.
- Restart the MCP subprocess after changing roles, project membership, or agent permissions so the startup cache refreshes.

## Plugin tool loading

At startup the server calls `GET /api/v1/plugins`, selects enabled plugins whose manifest includes `mcp.remoteEntryUrl`, dynamically imports each entry module, validates the `PluginMCPEntry` contract (`tools` array + `handleToolCall`), and merges definitions into `ListTools`.

```mermaid
sequenceDiagram
    participant Client as MCP client
    participant MCP as @paca-ai/paca-mcp
    participant API as Paca API
    participant GW as nginx gateway
    participant Plugin as Plugin mcp.js

    Client->>MCP: spawn npx -y @paca-ai/paca-mcp (stdio)
    MCP->>API: GET /api/v1/plugins (X-API-Key)
    API-->>MCP: enabled plugins + remoteEntryUrl
    alt relative bundle URL
        MCP->>GW: fetch /plugins-mcp/{id}/mcp.js
    else https remoteEntryUrl
        MCP->>Plugin: import(remoteEntryUrl)
    end
    MCP->>API: fetch project/global permissions
    Client->>MCP: tools/list
    MCP-->>Client: core tools + plugin tools
    Client->>MCP: tools/call
    alt plugin-owned tool name
        MCP->>Plugin: handleToolCall
        Plugin->>API: plugin-scoped HTTP
    else core tool
        MCP->>API: /api/v1/...
    end
```

<AccordionGroup>
<Accordion title="Plugin entry URL rules">

- Relative paths such as `/plugins-mcp/my-plugin/mcp.js` resolve against `PACA_GATEWAY_URL` when set, otherwise `PACA_API_URL`.
- Allowed schemes: `https://`, `file://`, and `http://` (localhost, loopback, or the configured gateway host only).
- `https://` hostnames are DNS-checked to block private/internal IPs (SSRF guard).
- A broken third-party plugin is logged and skipped; other plugins and core tools still load.

</Accordion>
<Accordion title="Duplicate tool names">

If two plugins register the same tool name, the first loaded plugin wins and the duplicate is skipped with a warning.

</Accordion>
</AccordionGroup>

## Verify the connection

<Steps>
<Step title="Check package version">

```bash
npx @paca-ai/paca-mcp --version
```

</Step>
<Step title="Restart the MCP host">

Restart Claude Desktop, VS Code, or Claude Code so the client spawns a fresh MCP subprocess with your env vars.

</Step>
<Step title="Exercise a tool from the client">

Ask the connected model:

- "List my Paca projects"
- "What Paca MCP tools are available?"

In user single-project or agent mode, project-scoped tools such as `list_tasks` should appear only when permissions and `PACA_PROJECT_ID` align.

</Step>
</Steps>

<RequestExample>
```text User prompt
List all projects in my Paca workspace
```
</RequestExample>

<ResponseExample>
```json Tool result (abbreviated)
{
  "content": [
    {
      "type": "text",
      "text": "[{\"id\":\"...\",\"name\":\"My Project\",\"task_id_prefix\":\"PAC\"}]"
    }
  ]
}
```
</ResponseExample>

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `PACA_API_KEY environment variable is required` | Missing env in MCP client config | Add `PACA_API_KEY` to the `env` block; restart client |
| `PACA_PROJECT_ID ... required when using PACA_AGENT_ID` | Agent mode without project scope | Set both `PACA_AGENT_ID` and `PACA_PROJECT_ID` |
| Connection refused / fetch failed | Wrong `PACA_API_URL` or API down | Confirm `curl $PACA_API_URL/api/healthz`; match gateway vs direct API port |
| 401 Unauthorized | Invalid, revoked, or wrong key type | Regenerate personal key; for agent mode use server `AGENT_API_KEY` |
| Agent actions run as user, not agent | User API key with `PACA_AGENT_ID` | `X-Agent-ID` is ignored unless the key is `AGENT_API_KEY` |
| `projectId must be <uuid> in single-project agent mode` | Tool args use a different project | Pass the configured `PACA_PROJECT_ID` in every project-scoped call |
| No plugin tools listed | Plugin disabled or missing `mcp.remoteEntryUrl` | Install/enable plugin; set `PACA_GATEWAY_URL` in gateway deployments |
| `npx: command not found` | Node.js not installed | Install Node.js 18+ and ensure `npx` is on `PATH` |
| Client shows no Paca tools | JSON syntax error or stale subprocess | Validate config JSON; fully quit and restart the host app |

<Info>
MCP server diagnostics are written to stderr (for example `[server]`, `[permissions]`, `[plugin-loader]` prefixes). Run with `DEBUG="*"` in the environment for verbose SDK logging when debugging outside a GUI client.
</Info>

For local development and MCP Inspector testing, clone the repo and run `npm run inspector` from `apps/mcp`.

## Security notes

- Store API keys in environment variables or OS-specific secret stores, not in version control.
- Use HTTPS for production `PACA_API_URL` and remote plugin `https://` entry URLs.
- Scope agent MCP configs to a single project with `PACA_PROJECT_ID` to prevent cross-project tool calls.
- Rotate personal API keys and `AGENT_API_KEY` on the same schedule as other service secrets.

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Start the stack, create a project, and generate the API key used by MCP.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Core tool names, input schemas, and agent-mode constraints.
</Card>
<Card title="Configure AI agents" href="/configure-ai-agents">
Create agents and wire MCP config for in-platform OpenHands conversations.
</Card>
<Card title="Plugin system" href="/plugin-system">
How plugins declare `mcp.remoteEntryUrl` and contribute runtime tools.
</Card>
<Card title="Claude Code skills" href="/claude-code-skills">
Install `/paca` slash commands on top of the MCP server.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Broader install and runtime failures including MCP connection errors.
</Card>
</CardGroup>

---

## 11. Claude Code skills

> Install /paca slash commands, wire the MCP server, available skill routes, and task-reference resolution patterns from skills/paca.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/11-claude-code-skills.md
- Generated: 2026-06-13T22:07:48.530Z

### Source Files

- `docs/guides/claude-code-skill.md`
- `skills/paca/SKILL.md`
- `skills/paca-setup/SKILL.md`
- `scripts/install-claude-skill.sh`
- `skills/paca-epic/SKILL.md`
- `apps/mcp/src/tools/index.ts`

---
title: "Claude Code skills"
description: "Install /paca slash commands, wire the MCP server, available skill routes, and task-reference resolution patterns from skills/paca."
---

Paca ships eleven Agent Skills under `skills/<name>/SKILL.md`. The installer at `scripts/install-claude-skill.sh` strips YAML frontmatter from each skill and writes the body to `~/.claude/commands/<name>.md`, which Claude Code exposes as slash commands. Every skill assumes the `@paca-ai/paca-mcp` server is connected; without it, agents must not fall back to local task lists or markdown files.

## How skills and MCP fit together

```mermaid
flowchart LR
  A[Claude Code session] --> B["Slash command<br/>/paca, /paca-epic, …"]
  B --> C["~/.claude/commands/*.md<br/>skill prompt body"]
  C --> D["@paca-ai/paca-mcp<br/>via npx"]
  D --> E["Paca API<br/>/api/v1"]
  E --> F["Tasks · Sprints · Docs · Comments"]
```

Skills are portable prompt files in the repository. The MCP server is the runtime bridge to Paca. Slash commands load skill instructions; MCP tools perform mutations and reads against your workspace.

| Layer | Location | Role |
|---|---|---|
| Skill source | `skills/<name>/SKILL.md` | Canonical Agent Skills definitions with YAML frontmatter |
| Installed commands | `~/.claude/commands/<name>.md` | Frontmatter-stripped bodies Claude Code loads per slash command |
| MCP server | `@paca-ai/paca-mcp` | Authenticated tool surface (`PACA_API_KEY`, `PACA_API_URL`) |
| Paca backend | API at `PACA_API_URL` | Projects, tasks, sprints, docs, activity |

## Prerequisites

Before installing skills or wiring MCP:

- A running Paca instance (local default `http://localhost:8080`, or your hosted URL)
- Node.js 18+ (`node --version`)
- A Paca API key from **Settings → API Keys** in the Paca UI

<ParamField body="PACA_API_KEY" type="string" required>
API key sent as the `X-API-Key` header on every MCP tool call. Generate in Paca user settings.
</ParamField>

<ParamField body="PACA_API_URL" type="string">
Base URL of your Paca API. Defaults to `http://localhost:8080` when unset.
</ParamField>

<ParamField body="PACA_PROJECT_ID" type="string">
Optional project UUID. When set, project-scoped tools are filtered to that project. Without it, agents call `list_projects` to resolve context.
</ParamField>

<ParamField body="PACA_AGENT_ID" type="string">
Optional agent UUID for agent-mode MCP. Requires a user API key with permission to impersonate the agent. See [Configure AI agents](/configure-ai-agents).
</ParamField>

## Install slash commands

<Steps>
<Step title="Run the installer">

<Tabs>
<Tab title="Remote (curl)">

```bash
curl -fsSL https://raw.githubusercontent.com/Paca-AI/paca/master/scripts/install-claude-skill.sh | bash
```

Review the script before piping to `bash` — remote execution runs code directly from GitHub.

</Tab>
<Tab title="Local clone">

```bash
bash scripts/install-claude-skill.sh
```

When run from a clone, the script detects `skills/` adjacent to `scripts/` and installs locally instead of fetching from GitHub.

</Tab>
</Tabs>

The installer creates `~/.claude/commands/` if needed, then installs these eleven commands:

| Slash command | Skill source | Purpose |
|---|---|---|
| `/paca` | `skills/paca/SKILL.md` | General task, doc, and sprint operations |
| `/paca-setup` | `skills/paca-setup/SKILL.md` | Interactive MCP connection wizard |
| `/paca-epic` | `skills/paca-epic/SKILL.md` | Requirements → epic, child stories, spec doc |
| `/paca-clarify` | `skills/paca-clarify/SKILL.md` | Clarify vague tasks or specs |
| `/paca-breakdown` | `skills/paca-breakdown/SKILL.md` | Decompose tasks into sub-tasks |
| `/paca-sprint` | `skills/paca-sprint/SKILL.md` | Plan a sprint from backlog and velocity |
| `/paca-estimate` | `skills/paca-estimate/SKILL.md` | Fibonacci story-point estimates |
| `/paca-prioritize` | `skills/paca-prioritize/SKILL.md` | Rank backlog by value, urgency, effort |
| `/paca-do` | `skills/paca-do/SKILL.md` | Execute a task end-to-end |
| `/paca-test` | `skills/paca-test/SKILL.md` | Verify acceptance criteria, record results |
| `/paca-doc` | `skills/paca-doc/SKILL.md` | Write or update Paca Docs |

</Step>

<Step title="Verify installation">

Restart Claude Code or start a new session. Type `/paca-setup` to confirm the command is recognized.

Expected installer output ends with a table of available commands and a prompt to configure MCP.

</Step>
</Steps>

### Uninstall

Remove installed command files:

```bash
rm ~/.claude/commands/paca*.md
```

This removes all eleven Paca slash commands installed by the script.

## Wire the MCP server

Skills require MCP tools. Run `/paca-setup` for a step-by-step wizard, or configure manually.

<Steps>
<Step title="Choose a config scope">

<Tabs>
<Tab title="Project-level (teams)">

Create `.claude/mcp.json` in the repository root:

```json
{
  "mcpServers": {
    "paca": {
      "command": "npx",
      "args": ["-y", "@paca-ai/paca-mcp"],
      "env": {
        "PACA_API_KEY": "<your-api-key>",
        "PACA_API_URL": "http://localhost:8080"
      }
    }
  }
}
```

Do not commit API keys. Add `.claude/mcp.json` to `.gitignore`, or inject `PACA_API_KEY` from the shell environment.

</Tab>
<Tab title="Claude Code global">

```bash
claude mcp add paca \
  --env PACA_API_KEY=<your-api-key> \
  --env PACA_API_URL=<your-paca-url> \
  -- npx -y @paca-ai/paca-mcp
```

</Tab>
<Tab title="Claude Desktop">

Add the same `mcpServers.paca` block to your OS config file:

- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`

Restart Claude Desktop after saving.

</Tab>
</Tabs>

</Step>

<Step title="Verify connectivity">

Restart Claude Code or Claude Desktop, then ask:

> List my Paca projects

If tools return project data, setup is complete. On failure, check:

1. JSON syntax is valid
2. API key has no trailing spaces
3. Paca is reachable: `curl <PACA_API_URL>/api/v1/health`
4. `npx` and Node.js are on `PATH`

</Step>
</Steps>

For agent mode, optional env vars, permission filtering, and plugin tool loading, see [Connect MCP server](/connect-mcp) and [MCP tools reference](/mcp-tools-reference).

## `/paca` — general operations and skill routing

`/paca <request>` handles mixed project-management requests in plain English. Before acting, specialized skills load project documentation from Paca Docs so context comes from the workspace, not local files.

### Route to specialized skills

When a request clearly matches a workflow, `/paca` suggests the dedicated slash command:

| User intent | Suggested command |
|---|---|
| Turn requirements into an epic with child stories | `/paca-epic <requirements>` |
| Clarify or improve a vague task or spec | `/paca-clarify #<number>` |
| Break a task into smaller sub-tasks | `/paca-breakdown #<number>` |
| Plan a sprint from the backlog | `/paca-sprint` |
| Estimate story points | `/paca-estimate #<number>` |
| Set priorities across the backlog | `/paca-prioritize` |
| Execute a task end-to-end | `/paca-do #<number>` |
| Test or verify a task | `/paca-test #<number>` |
| Write or update documentation | `/paca-doc #<number>` |

Simple or mixed requests — create a task, list the sprint, mark a task done — are handled directly inside `/paca` without switching commands.

<RequestExample>

```text
/paca Fix the login redirect bug, assign to sprint 3
/paca What's in the current sprint?
/paca Mark task #42 as done
/paca ABC-17 is blocked — add a comment: needs design review
```

</RequestExample>

### Task-reference resolution

`/paca` scans the user message for task references before choosing tools. Resolution runs in Step 1 of the skill workflow.

| Pattern | Example | Resolution |
|---|---|---|
| `#<number>` or number in task context | `#42`, `close #7`, `task 42 is done` | `get_task_by_number(projectId, 42)` |
| `PREFIX-<number>` | `ABC-42`, `PAC-7` | `list_projects` → match `task_id_prefix` → `get_task_by_number` |
| Paca URL | `http://…/projects/{id}/tasks/{id}` | Parse both IDs → `get_task(projectId, taskId)` |
| UUID | `550e8400-e29b-41d4-a716-446655440000` | `get_task(projectId, uuid)` |

When a reference is found, fetch the task first, then apply the requested action.

### Infer action and resolve project

After resolving references, `/paca` maps intent to MCP tools:

| Intent | Primary tools |
|---|---|
| Track work — bug, feature, to-do, ticket | `create_task`, `update_task`, `list_tasks` |
| Write content — guide, spec, design, notes | `write_doc` (path-based; see below) |
| See status — board, sprint, in progress | `list_sprints`, `list_tasks` |
| Plan an iteration | `create_sprint`, `update_sprint` |
| Comment on a task | `add_task_comment` |
| Close / complete work | `update_task` (done status) |
| Break work into pieces | `create_task` × N with parent reference |

If no project is in context, call `list_projects` and pick the most relevant match. Ask the user only when two projects are equally plausible.

### MCP unavailable

When Paca MCP tools are not connected, every skill responds with:

> Paca MCP tools are not available. Run `/paca-setup` to configure the connection.

Agents must not create local TODO files, markdown docs, or task lists as a fallback.

## Specialized slash commands

Each specialized command follows a multi-step workflow defined in its `skills/<name>/SKILL.md` file. All workflows read Paca Docs for context and write results back to Paca.

### `/paca-epic`

Turns requirements into a structured epic: parent task, 3–8 child stories (confirm before creating more than 10), and a spec document. Loads `list_task_types`, `list_task_statuses`, and existing epics to avoid duplication. Child tasks reference the parent as `Part of #<epic-number>`.

<RequestExample>

```text
/paca-epic As a user I want to reset my password via email
/paca-epic #12
```

</RequestExample>

### `/paca-clarify`

Identifies ambiguities (scope gaps, edge cases, undefined terms, missing acceptance criteria), asks up to six targeted questions, then rewrites the task or doc in Paca. Updates existing records — does not create duplicate documents.

### `/paca-breakdown`

Proposes vertical-slice sub-tasks (1–2 day size) with done conditions and dependency notes. Confirms with the user before calling `create_task` for each item. Sub-tasks reference `Part of #<parent-number>`.

### `/paca-sprint`

Plans the next sprint: detects carryover from the previous sprint, infers velocity from the last 2–3 completed sprints, ranks backlog tasks, and assigns them via `update_task` (`sprintId`). Optionally writes a sprint planning note to Paca Docs.

<RequestExample>

```text
/paca-sprint
/paca-sprint next sprint, 30 points capacity
/paca-sprint sprint 4, goal: ship the auth flow
```

</RequestExample>

### `/paca-estimate`

Estimates tasks on the Fibonacci scale (1, 2, 3, 5, 8, 13). Calibrates against recently completed reference tasks. Writes estimates via `update_task` (description prefix or custom field when `list_custom_fields` shows one). Tasks at 13 points trigger a `/paca-breakdown` recommendation.

### `/paca-prioritize`

Scores backlog tasks on business value, urgency, effort, and dependencies. Assigns Critical / High / Medium / Low labels and writes them via `update_task` (`priority` field).

### `/paca-do`

End-to-end task execution: marks in progress, reads docs and `list_task_activities`, does the work (code, writing, research), comments results, updates affected docs, sets done status. Stops early if acceptance criteria are missing and offers `/paca-clarify`.

### `/paca-test`

Derives test cases from acceptance criteria and related docs. Records pass/fail in an `add_task_comment` markdown table. All pass → advance status; any fail → revert to in progress. Preserves repeatable procedures in Paca Docs.

### `/paca-doc`

Writes or updates documentation in Paca Docs — guides, references, architecture docs, BDD specs, runbooks. Checks for duplicates via `list_docs` before creating. Links docs to tasks via `add_task_comment` when applicable.

### `/paca-setup`

Interactive wizard: prerequisites → config file choice (project, global CLI, or Desktop) → snippet generation → verification → optional global skill install. Confirms each step before proceeding.

## Documentation tools in skills vs MCP

Skills describe document operations conceptually (`list_documents`, `get_document`, `create_document`, `update_document`). The current MCP server exposes path-based filesystem tools:

| Skill instruction (conceptual) | MCP tool (actual) | Notes |
|---|---|---|
| `list_documents` | `list_docs` | Tree view of folders and documents |
| `get_document` | `read_doc` | Read by path, e.g. `Architecture/API Design` |
| `create_document` / `update_document` | `write_doc` | Create or update at path; folders auto-created |
| `delete_document` | `delete_doc` | Delete document or folder by path |
| `list_doc_folders` / `create_doc_folder` | Path segments in `write_doc` | Folders created automatically on write |

Agents following skill workflows should prefer Paca Docs over local markdown files. Repository docs (README, CONTRIBUTING) remain in the codebase.

## Make Paca the project default

To bias Claude toward Paca tools without typing `/paca` on every request, add this to the project `CLAUDE.md`:

```markdown
## Project management

This project uses Paca for all project management. When working in this codebase:

- **Tasks and to-dos** → use `create_task` / `list_tasks` via the Paca MCP tools. Do not create local TODO files or add TODO comments.
- **Documentation** → use `write_doc` via Paca MCP. Do not create standalone `.md` docs unless they belong in the repository (e.g. README, CONTRIBUTING).
- **Sprint planning** → use `create_sprint` / `list_sprints` via Paca MCP.

If Paca MCP tools are not available, say so and ask the user to run `/paca-setup`.
```

## Core MCP tools used by skills

Skills route to subsets of the MCP tool surface. Full schemas and permission keys are in [MCP tools reference](/mcp-tools-reference).

| Category | Tools |
|---|---|
| Projects | `list_projects`, `get_project`, `create_project`, `update_project` |
| Tasks | `create_task`, `list_tasks`, `get_task`, `get_task_by_number`, `update_task`, `delete_task` |
| Sprints | `create_sprint`, `list_sprints`, `get_sprint`, `update_sprint`, `complete_sprint` |
| Docs | `list_docs`, `read_doc`, `write_doc`, `delete_doc`, `move_doc` |
| Comments | `add_task_comment`, `update_task_comment`, `list_task_activities` |
| Metadata | `list_task_types`, `list_task_statuses`, `list_custom_fields` |
| Views | `bulk_move_tasks`, `move_task`, `list_views` |

## Troubleshooting

<AccordionGroup>
<Accordion title="Slash command not found">

Re-run `bash scripts/install-claude-skill.sh` and confirm files exist under `~/.claude/commands/`. Start a new Claude Code session after install.

</Accordion>

<Accordion title="MCP tools missing or empty">

Run `/paca-setup`. Verify `PACA_API_KEY` is set, `PACA_API_URL` is reachable, and Node.js 18+ is installed. Check `curl <PACA_API_URL>/api/v1/health`.

</Accordion>

<Accordion title="Permission denied on project-scoped tools">

Ensure your API key has the required permissions (`tasks.read`, `tasks.write`, `docs.read`, `docs.write`, etc.). In agent mode, the impersonated agent must be a project member with the right role. See [Connect MCP server](/connect-mcp).

</Accordion>

<Accordion title="Agent creates local files instead of using Paca">

Confirm MCP is connected. Add the `CLAUDE.md` project-management block. Use `/paca` or a specialized command so the skill's no-local-files rule is loaded.

</Accordion>

<Accordion title="PREFIX-N task reference not resolving">

Call `list_projects` and confirm the project's `task_id_prefix` matches the prefix in the reference (e.g. `ABC` for `ABC-17`).

</Accordion>
</AccordionGroup>

More failure modes: [Troubleshooting](/troubleshooting).

## Related pages

<CardGroup cols={2}>
<Card title="Connect MCP server" href="/connect-mcp">
Configure `@paca-ai/paca-mcp` for Claude Code, Desktop, VS Code, and other MCP clients.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Full tool catalog, input schemas, agent-mode constraints, and plugin tools.
</Card>
<Card title="Quickstart" href="/quickstart">
Start Paca, create a project, and generate your first API key.
</Card>
<Card title="Configure AI agents" href="/configure-ai-agents">
Wire agent members, LLM config, and MCP for autonomous teammates.
</Card>
</CardGroup>

---

## 12. Configure AI agents

> Create agent types and project agents, set LLM and MCP config, wire repository access via plugin adapters, and control conversation lifecycle.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/12-configure-ai-agents.md
- Generated: 2026-06-13T22:09:02.677Z

### Source Files

- `docs/ai-agent/overview.md`
- `docs/ai-agent/ai-agent-service.md`
- `docs/ai-agent/repository-plugin-adapter.md`
- `docs/ai-agent/default-skills.md`
- `services/ai-agent/src/config.py`
- `services/ai-agent/src/agent/builder.py`
- `services/api/internal/transport/http/handler/conversation_handler.go`

---
title: "Configure AI agents"
description: "Create agent types and project agents, set LLM and MCP config, wire repository access via plugin adapters, and control conversation lifecycle."
---

Project agents are configured through `services/api` REST endpoints and the project Agents UI. Each agent is a project member (`member_type = 'agent'`) with its own LLM credentials, system prompt, per-trigger prompts, skills, MCP servers, and repository capability flags. When triggered, `services/ai-agent` loads that configuration from PostgreSQL, builds an OpenHands SDK conversation in an isolated Docker sandbox, and streams events back through Valkey.

## Configuration model

| Layer | Storage | Purpose |
|---|---|---|
| Agent preset | Frontend catalog (`AGENT_PRESETS`) | Seeds name, default LLM, and system prompt at creation time |
| Skill template | `GET /api/v1/agents/skill-templates` | Reusable `SKILL.md` bodies (developer, ba, manual-tester, po-assistant) |
| Project agent | `agents` table | Per-project runtime config: LLM, prompts, limits, git identity |
| MCP servers | `agent_mcp_servers` table | Per-agent `mcpServers` entries (stdio, sse, http) |
| Skills | `agent_skills` table | Per-agent AgentSkills content and keyword triggers |
| Conversations | `agent_conversations` table | One row per trigger invocation; tracks status and outputs |

<Note>
Agent presets and skill templates replace a separate `agent_types` database table. There is no `agent_types` migration or REST route in the current codebase — configure personas by choosing a preset at creation and attaching skill templates afterward.
</Note>

```mermaid
stateDiagram-v2
    [*] --> queued: API creates conversation + publishes trigger
    queued --> running: ai-agent worker starts OpenHands run
    running --> finished: conversation completes normally
    running --> failed: ERROR or STUCK status / unhandled exception
    running --> stopped: user calls stop endpoint
    finished --> [*]
    failed --> [*]
    stopped --> [*]
```

## Prerequisites

Before creating agents, ensure the stack includes the ai-agent service and shared secrets:

| Variable | Service | Purpose |
|---|---|---|
| `AGENT_API_KEY` | `api` | Pre-shared key for agent-authenticated plugin API calls |
| `PACA_API_KEY` | `ai-agent` | Must match `AGENT_API_KEY`; enables built-in `@paca-ai/paca-mcp` injection |
| `ENCRYPTION_KEY` | `api`, `ai-agent` | AES-256-GCM key (64-char hex) for LLM API keys at rest |
| `INTERNAL_API_KEY` | `ai-agent` | Authenticates internal conversation control routes |
| `GATEWAY_BASE_URL` | `ai-agent` | Gateway address for plugin MCP bundle URLs (`/plugins-mcp/`) |

Generate secrets with `openssl rand -hex 32` for `AGENT_API_KEY` and `ENCRYPTION_KEY`.

<Warning>
If `ENCRYPTION_KEY` is set on `api` but missing on `ai-agent`, the worker cannot decrypt LLM API keys and conversations fail at the provider call.
</Warning>

## Create a project agent

<Steps>
<Step title="Choose a preset and identity">

In the web UI (**Project → Agents → Create agent**), pick a preset (`software-engineer`, `code-reviewer`, `qa-engineer`, `planner`, `business-analyst`, or `custom`). Set display `name`, `@mention` `handle`, and `project_role_id` so the agent appears in the member list with the correct permissions.

</Step>
<Step title="Configure the LLM">

Select `llm_provider` and `llm_model`. The verified model list comes from `GET /api/v1/agents/llm-models`, which proxies the OpenHands SDK `VERIFIED_MODELS` catalog from `services/ai-agent`.

Supply a provider API key in `llm_api_key` (required at creation). The API encrypts it when `ENCRYPTION_KEY` is configured. Set `llm_base_url` for Azure, local OpenAI-compatible endpoints, or providers such as `glm`, `nvidia`, and `qwen` (the ai-agent builder injects known compatible base URLs automatically).

</Step>
<Step title="Attach default trigger prompts">

Creation seeds four per-trigger prompt fields from `TRIGGER_PROMPTS` in the web client. These append to the system prompt at invocation time based on how the agent was triggered.

</Step>
<Step title="Verify the agent member">

A successful `POST /api/v1/projects/:projectId/agents` returns `201` with the agent object including `member_id`. The agent is assignable to tasks and @mentionable by `handle`.

</Step>
</Steps>

:::endpoint POST /api/v1/projects/:projectId/agents
Create a project agent and its `project_members` row.

<ParamField body="name" type="string" required>
Display name shown in member lists.
</ParamField>

<ParamField body="handle" type="string" required>
Unique @mention handle per project (e.g. `dev-bot`).
</ParamField>

<ParamField body="llm_provider" type="string" required>
LiteLLM provider prefix (e.g. `anthropic`, `openai`, `azure`).
</ParamField>

<ParamField body="llm_model" type="string" required>
Model name within the provider.
</ParamField>

<ParamField body="llm_api_key" type="string" required>
Provider API key; stored encrypted when `ENCRYPTION_KEY` is set.
</ParamField>

<ParamField body="llm_base_url" type="string">
Optional custom endpoint for OpenAI-compatible providers.
</ParamField>

<ParamField body="system_prompt" type="string">
Base role instructions appended as `system_message_suffix`.
</ParamField>

<ParamField body="task_trigger_prompt" type="string">
Appended when triggered by task assignment or task-comment @mention.
</ParamField>

<ParamField body="doc_comment_trigger_prompt" type="string">
Appended when triggered by documentation-comment @mention (no `task_id`).
</ParamField>

<ParamField body="chat_trigger_prompt" type="string">
Appended for direct chat sessions.
</ParamField>

<ParamField body="description_write_trigger_prompt" type="string">
Appended for **Write with AI** task description generation.
</ParamField>

<ParamField body="can_clone_repos" type="boolean">
Default `true`. When `true` and repository plugins are installed, the executor injects repository tools.
</ParamField>

<ParamField body="can_create_prs" type="boolean">
Default `true`. Stored on the agent; PR creation is enforced by repository plugin tools.
</ParamField>

<ParamField body="max_iterations" type="integer">
OpenHands step cap per conversation. Default `500`, hard cap `500`.
</ParamField>

<ParamField body="timeout_minutes" type="integer">
Wall-clock timeout hint. Default `30`, max `480`.
</ParamField>

<ParamField body="git_committer_name" type="string">
Git commit author name in the sandbox. Default `paca-agent`.
</ParamField>

<ParamField body="git_committer_email" type="string">
Git commit author email. Default `280579135+paca-agent@users.noreply.github.com`.
</ParamField>

<ParamField body="project_role_id" type="uuid" required>
Project role assigned to the agent member.
</ParamField>

<RequestExample>

```bash title="Create a developer agent"
curl -X POST "$PACA_URL/api/v1/projects/$PROJECT_ID/agents" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Dev Bot",
    "handle": "dev-bot",
    "llm_provider": "anthropic",
    "llm_model": "claude-sonnet-4-6",
    "llm_api_key": "sk-ant-...",
    "system_prompt": "You are a senior software engineer.",
    "can_clone_repos": true,
    "can_create_prs": true,
    "project_role_id": "'"$ROLE_ID"'"
  }'
```

</RequestExample>

<ResponseExample>

```json title="201 Created"
{
  "data": {
    "id": "uuid",
    "project_id": "uuid",
    "member_id": "uuid",
    "name": "Dev Bot",
    "handle": "dev-bot",
    "llm_provider": "anthropic",
    "llm_model": "claude-sonnet-4-6",
    "can_clone_repos": true,
    "max_iterations": 500,
    "timeout_minutes": 30,
    "git_committer_name": "paca-agent",
    "git_committer_email": "280579135+paca-agent@users.noreply.github.com"
  }
}
```

</ResponseExample>
:::

## Agent presets and skill templates

### Presets (creation-time templates)

The web UI ships six presets that pre-fill LLM defaults and `system_prompt`:

| Preset ID | Label | Default model |
|---|---|---|
| `software-engineer` | Software Engineer | `anthropic` / `claude-sonnet-4-6` |
| `code-reviewer` | Code Reviewer | `anthropic` / `claude-sonnet-4-6` |
| `qa-engineer` | QA Engineer | `anthropic` / `claude-sonnet-4-6` |
| `planner` | Planner | `anthropic` / `claude-sonnet-4-6` |
| `business-analyst` | Business Analyst | `anthropic` / `claude-sonnet-4-6` |
| `custom` | Custom | Empty — fill all fields manually |

Presets are frontend-only; they do not create a separate database record.

### Skill templates (reusable SKILL.md catalog)

`GET /api/v1/agents/skill-templates` returns four built-in templates with full `SKILL.md` content and keyword triggers:

| Slug | Name | Example triggers |
|---|---|---|
| `developer` | Developer | `implement`, `fix`, `refactor`, `pr` |
| `ba` | Business Analyst | `requirements`, `gap analysis`, `use case` |
| `manual-tester` | Manual Tester | `test case`, `test plan`, `QA` |
| `po-assistant` | Product Owner Assistant | `acceptance criteria`, `backlog`, `roadmap` |

Attach a template to an agent with `POST /api/v1/projects/:projectId/agents/:agentId/skills` using `skill_source: "inline"` and the template `content` as `skill_content`.

## LLM configuration

### Provider selection

`services/ai-agent/src/agent/builder.py` constructs the OpenHands `LLM` object:

- Without `llm_base_url`: model string is `{provider}/{model}` (LiteLLM routing).
- With `llm_base_url`: model string becomes `openai/{model}` for OpenAI-compatible endpoints.
- Auto-injected base URLs exist for `glm`, `nvidia`, and `qwen`.

Any LiteLLM-supported provider works — the stack does not require a specific vendor.

### Update LLM settings

`PATCH /api/v1/projects/:projectId/agents/:agentId` accepts partial updates. Omit `llm_api_key` to keep the existing encrypted secret; include it to rotate.

### Discover models

```bash
curl "$PACA_URL/api/v1/agents/llm-models" \
  -H "Authorization: Bearer $TOKEN"
```

Returns a map of provider → verified model IDs from the deployed OpenHands SDK.

## MCP server configuration

Each agent stores MCP servers in `agent_mcp_servers`. At conversation start, `build_mcp_config` assembles the `mcpServers` map for the OpenHands SDK.

### User-configured servers

:::endpoint POST /api/v1/projects/:projectId/agents/:agentId/mcp-servers
Add an MCP server to an agent.

<ParamField body="server_name" type="string" required>
Key in the `mcpServers` map.
</ParamField>

<ParamField body="transport" type="string" required>
One of `stdio`, `sse`, or `http`.
</ParamField>

<ParamField body="command" type="string">
Required for `stdio` transport.
</ParamField>

<ParamField body="args" type="string[]">
Arguments for `stdio` transport.
</ParamField>

<ParamField body="url" type="string">
Server URL for `sse` or `http` transport.
</ParamField>

<ParamField body="env" type="object">
Environment variables injected into the MCP process. Secret-like keys are redacted (`***`) in API responses.
</ParamField>
:::

<RequestExample>

```json title="stdio MCP server"
{
  "server_name": "fetch",
  "transport": "stdio",
  "command": "uvx",
  "args": ["mcp-server-fetch"],
  "env": {}
}
```

</RequestExample>

Manage servers with `GET`, `PATCH` (enable/disable, update command/args/url/env), and `DELETE` on `/mcp-servers/:serverId`.

### Built-in Paca MCP server

When `PACA_API_KEY` is set on `ai-agent`, the executor always appends a `paca` stdio server **after** user entries (so it cannot be overridden):

```json
{
  "command": "npx",
  "args": ["-y", "@paca-ai/paca-mcp"],
  "env": {
    "PACA_API_KEY": "<AGENT_API_KEY>",
    "PACA_API_URL": "<api internal URL>",
    "PACA_GATEWAY_URL": "<gateway URL>",
    "PACA_AGENT_ID": "<agent uuid>",
    "PACA_PROJECT_ID": "<project uuid>"
  }
}
```

This gives every agent project-scoped Paca tools (tasks, docs, comments) without per-agent MCP configuration. Set `AGENT_API_KEY` on `api` and the matching `PACA_API_KEY` on `ai-agent`.

## Skills configuration

Skills follow the [Agent Skills](https://agentskills.io/specification) format. Content is stored in PostgreSQL and loaded into the OpenHands `Skill` list at conversation start. Disabled skills (`is_enabled: false`) are skipped.

:::endpoint POST /api/v1/projects/:projectId/agents/:agentId/skills
Add a skill to an agent.

<ParamField body="skill_name" type="string" required>
Unique identifier for this agent.
</ParamField>

<ParamField body="skill_source" type="string" required>
`inline`, `marketplace`, or `github_url`.
</ParamField>

<ParamField body="skill_content" type="string">
Full `SKILL.md` body for `inline` skills.
</ParamField>

<ParamField body="source_url" type="string">
URL or marketplace ID for non-inline sources.
</ParamField>

<ParamField body="triggers" type="string[]">
Keyword triggers mapped to OpenHands `KeywordTrigger`.
</ParamField>
:::

## Wire repository access

Repository access does not require per-agent credential configuration. Install a plugin with the `repository` capability (for example GitHub or GitLab from the marketplace), link repositories to the project, and enable `can_clone_repos` on the agent.

### How plugins are discovered

When publishing triggers, `services/api` calls `FindByCapability(ctx, "repository")` and passes all matching plugin names as `repo_plugin_ids` on the Valkey stream entry `paca:agent:triggers`.

### Runtime tools injected in the sandbox

When `can_clone_repos` is `true` and at least one repository plugin is installed, `services/ai-agent` adds custom tools alongside the OpenHands defaults:

| Tool | Purpose |
|---|---|
| `list_repositories` | Lists linked repos across all repository plugins |
| `clone_repository` | Fetches short-lived clone credentials from the plugin API |
| `push_branch` | Pushes a feature branch with a fresh token |
| `create_pull_request` | Opens a PR and links it to the current task |

Clone credentials are fetched at runtime from plugin-scoped API routes — agents never store VCS tokens:

```
GET /api/v1/plugins/:pluginId/projects/:projectId/repositories
GET /api/v1/plugins/:pluginId/projects/:projectId/repositories/:repoId/clone-info
```

The clone-info response includes a short-lived `token` and `clone_url`. The executor embeds the token in the git URL and scrubs it from command output.

<Info>
The executor also appends repository workflow instructions to the system prompt (clone → branch → commit → push → PR) when repositories are available.
</Info>

### Git identity

Set `git_committer_name` and `git_committer_email` on the agent so commits in the sandbox use a consistent author. Defaults apply when omitted.

## Trigger types and per-invocation prompts

| Trigger | How it fires | `trigger_type` value |
|---|---|---|
| Task assignment | Task `assignee_id` points to agent member | `task_assigned` |
| Task comment @mention | Comment body contains `@handle` on a task | `comment_mention` |
| Doc comment @mention | Comment on a doc page contains `@handle` | `comment_mention` |
| Direct chat | `POST .../chat-sessions` or follow-up message | `chat_message` |
| Write with AI | `POST .../tasks/:taskId/write-with-ai` | `description_write` |

At conversation start the executor builds `system_message_suffix` from:

1. Agent `system_prompt`
2. Hardcoded documentation workflow instructions (always use Paca MCP for docs)
3. Repository workflow instructions (when repos are linked and `can_clone_repos` is true)
4. The matching per-trigger prompt field (`task_trigger_prompt`, `doc_comment_trigger_prompt`, `chat_trigger_prompt`, or `description_write_trigger_prompt`)

Edit trigger prompts on the agent **Overview** tab in the web UI or via `PATCH /agents/:agentId`.

## Conversation lifecycle control

Conversations are created by `services/api` (status `queued`), executed by `services/ai-agent` (status `running`), and settle to `finished`, `failed`, or `stopped`.

### Public control endpoints

| Method | Path | Behavior |
|---|---|---|
| `GET` | `/api/v1/projects/:projectId/conversations` | List conversations; filter with `?agent_id=&status=` |
| `GET` | `/api/v1/projects/:projectId/conversations/:conversationId` | Conversation metadata |
| `GET` | `/api/v1/projects/:projectId/conversations/:conversationId/events` | Paginated OpenHands event history |
| `POST` | `/api/v1/projects/:projectId/conversations/:conversationId/stop` | Stop run; publishes `agent.stop` to Valkey; destroys sandbox |
| `POST` | `/api/v1/projects/:projectId/conversations/:conversationId/messages` | Send follow-up message to a **running** conversation |

:::endpoint POST /api/v1/projects/:projectId/conversations/:conversationId/stop
Stop a running or queued conversation. Sets status to `stopped` and signals the ai-agent worker via the `agent.stop` control message on `paca:agent:triggers`.

<RequestExample>

```bash
curl -X POST "$PACA_URL/api/v1/projects/$PROJECT_ID/conversations/$CONV_ID/stop" \
  -H "Authorization: Bearer $TOKEN"
```

</RequestExample>

<ResponseExample>

```json
{ "data": { "message": "conversation stopped" } }
```

</ResponseExample>
:::

:::endpoint POST /api/v1/projects/:projectId/conversations/:conversationId/messages
Send an additional message to a running conversation.

<ParamField body="message" type="string" required>
Follow-up instruction for the agent.
</ParamField>
:::

### Realtime monitoring

`services/ai-agent` publishes conversation events to `paca:agent:events` and pushes realtime notifications on `paca.events`. Connected clients receive `agent.conversation.finished`, `agent.conversation.failed`, and per-event updates through Socket.IO project rooms.

### Chat sessions

Direct chat uses persistent `agent_chat_sessions`. Each message creates a new `agent_conversations` row:

- `POST /api/v1/projects/:projectId/agents/:agentId/chat-sessions` — start session with initial message
- `POST /api/v1/projects/:projectId/agents/:agentId/chat-sessions/:sessionId/messages` — follow-up in existing session

## Agent management endpoints

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/v1/projects/:projectId/agents` | List project agents |
| `GET` | `/api/v1/projects/:projectId/agents/:agentId` | Get agent with MCP servers and skills |
| `PATCH` | `/api/v1/projects/:projectId/agents/:agentId` | Update configuration |
| `DELETE` | `/api/v1/projects/:projectId/agents/:agentId` | Soft-delete agent and member |
| `GET` | `/api/v1/agents/llm-models` | Verified LLM catalog |
| `GET` | `/api/v1/agents/skill-templates` | Built-in skill template catalog |

All project-scoped routes require JWT or API-key authentication and project authorization.

## Verification checklist

<Check>
After configuration, confirm these signals before assigning production work:
</Check>

1. `GET /api/v1/agents/llm-models` returns providers and the agent's chosen model appears in the list.
2. `GET /api/v1/projects/:projectId/agents` shows the agent with a populated `member_id`.
3. `PACA_API_KEY` on `ai-agent` matches `AGENT_API_KEY` on `api` (built-in Paca MCP appears in conversation tool calls).
4. Repository plugins with the `repository` capability are installed and repositories are linked to the project.
5. A test trigger (assign a simple task or send a chat message) produces a conversation that moves from `queued` → `running` → `finished`.
6. `GET .../conversations/:id/events` returns `MessageEvent`, `CmdRunAction`, or other OpenHands event types.

## Related pages

<CardGroup>
<Card title="AI agents" href="/ai-agents">
Agent members, trigger types, OpenHands lifecycle, Docker isolation, and Valkey streams.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Configure `@paca-ai/paca-mcp` for external MCP clients and understand agent-mode constraints.
</Card>
<Card title="Install marketplace plugins" href="/install-marketplace-plugins">
Install repository plugins that supply clone credentials and PR creation.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog across api, ai-agent, gateway, and realtime.
</Card>
<Card title="Realtime events" href="/realtime-events">
Socket.IO rooms and agent conversation event payloads.
</Card>
<Card title="REST API reference" href="/rest-api">
Auth envelopes, pagination, and the complete endpoint catalog.
</Card>
</CardGroup>

---

## 13. Build a plugin

> End-to-end plugin authoring: plugin.json manifest, WASM backend, frontend extension points, migrations, build output, and local install.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/13-build-a-plugin.md
- Generated: 2026-06-13T22:09:25.858Z

### Source Files

- `docs/plugins/developer-guide.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/sdk-reference.md`
- `scripts/install-local-plugin.sh`
- `services/api/internal/transport/http/handler/plugin_handler.go`

---
title: "Build a plugin"
description: "End-to-end plugin authoring: plugin.json manifest, WASM backend, frontend extension points, migrations, build output, and local install."
---

A Paca plugin ships as a versioned `plugin.json` manifest plus a WASM backend (`backend.wasm`), a Module Federation frontend bundle (`remoteEntry.js`), and optional SQL migrations under `migrations/`. The API loads WASM modules through wazero at startup, runs pending migrations into a per-plugin PostgreSQL schema, and proxies HTTP traffic to `/api/v1/plugins/{pluginId}/*`. The web host fetches enabled plugins from `GET /api/v1/plugins` and lazy-loads remote components at registered extension points.

<Note>
A full reference implementation lives at [github.com/Paca-AI/paca-plugin-example](https://github.com/Paca-AI/paca-plugin-example). SDK packages are published separately: `@paca-ai/plugin-sdk-react`, `github.com/Paca-AI/plugin-sdk-go`, and `@paca-ai/plugin-sdk-mcp`.
</Note>

## Prerequisites

| Requirement | Version / notes |
|---|---|
| Go | 1.21+ with `GOOS=wasip1 GOARCH=wasm` build support |
| Node.js runtime | 20+ with **bun** (used by `scripts/install-local-plugin.sh`) or pnpm |
| Running Paca stack | Local dev Compose or host-side services — see [Local development](/local-development) |
| Admin API key | Required for plugin registration; needs `users.write` global permission |

## Repository layout

Develop in a standalone repository or under `plugins/` in the monorepo.

:::files
my-plugin/
  plugin.json                 # manifest (source of truth)
  backend/
    go.mod
    main.go                   # WASM entry — plugin.Run(&myPlugin{})
    handler.go                # route and event handlers
    migrations/
      0001_create_my_table.sql
  frontend/
    package.json
    vite.config.ts
    src/
      TaskDetailSection.tsx   # extension-point component
      index.ts
  dist/                       # build output (gitignored)
:::

After local install, artifacts land in the Paca plugin store:

:::files
plugins/local/
  backend/<plugin-id>/
    plugin.json
    backend.wasm
    migrations/*.sql
  frontend/<plugin-id>/
    assets/remoteEntry.js     # Vite federation output
    assets/*.js               # chunks
  mcp/<plugin-id>/            # optional MCP ESM bundle
    mcp.js
:::

<Info>
Environment defaults: `PLUGINS_WASM_DIR=./plugins/local/backend`, `PLUGINS_FRONTEND_DIR=./plugins/local/frontend`, `PLUGINS_MCP_DIR=./plugins/local/mcp`.
</Info>

## Write the manifest

`plugin.json` is stored in the `plugins` table as JSONB and drives route registration, extension-point rendering, and MCP tool loading.

### Top-level fields

<ParamField body="id" type="string" required>
Reverse-DNS identifier (e.g. `com.example.my-plugin`). Must match the `name` field on install and stay stable after first release. Becomes part of API paths and the database schema name.
</ParamField>

<ParamField body="displayName" type="string" required>
Human-readable name shown in the admin UI and extension-point labels.
</ParamField>

<ParamField body="version" type="string" required>
Strict semver `X.Y.Z` (no pre-release or build metadata).
</ParamField>

<ParamField body="description" type="string">
Short summary of plugin behavior.
</ParamField>

<ParamField body="permissions" type="string[]">
Host-function capability scopes the WASM module may call (e.g. `db:read:tasks`, `db:write:plugin_data`, `http:register_routes`, `events:subscribe:task.*`, `events:emit`).
</ParamField>

<ParamField body="backend" type="object">
WASM route and event declarations.
</ParamField>

<ParamField body="frontend" type="object">
Module Federation `remoteEntryUrl` and extension-point registrations.
</ParamField>

<ParamField body="mcp" type="object">
Optional MCP entry module URL for AI client tool loading.
</ParamField>

### Backend section

```json
{
  "backend": {
    "routes": [
      { "method": "GET", "path": "/projects/:projectId/tasks/:taskId/my-items" },
      {
        "method": "POST",
        "path": "/projects/:projectId/tasks/:taskId/my-items",
        "middlewares": [
          { "name": "authn" },
          { "name": "requireFreshPassword" },
          {
            "name": "requirePermissions",
            "scope": "project",
            "permissions": ["tasks.write"]
          }
        ]
      },
      {
        "method": "POST",
        "path": "/webhook",
        "middlewares": [{ "name": "optionalAuthn" }]
      }
    ],
    "eventSubscriptions": ["task.deleted"],
    "allowedOutboundDomains": ["api.example.com"],
    "allowedConfigKeys": ["greeting.prefix"]
  }
}
```

| Route field | Behavior |
|---|---|
| `method` | `GET`, `POST`, `PATCH`, `PUT`, or `DELETE` |
| `path` | Relative to `/api/v1/plugins/{pluginId}/`. Include `/projects/:projectId/` for project-scoped handlers. Supports `:param` and trailing `*rest` wildcards. |
| `middlewares` | Ordered host middleware chain. `null` (omitted) applies defaults; `[]` disables all middleware. |
| `public` | Legacy flag equivalent to an empty middleware chain |

**Default middleware** when `middlewares` is omitted and `public` is false:

- `optionalAuthn`
- `requireFreshPassword`

Supported middleware names: `authn`, `optionalAuthn`, `requireFreshPassword`, `requireJWTAuth`, `requirePermissions` (with `scope`, `projectParam`, `permissions`).

### Frontend section

```json
{
  "frontend": {
    "remoteEntryUrl": "/plugins/com.example.my-plugin/assets/remoteEntry.js",
    "extensionPoints": [
      {
        "point": "task.detail.section",
        "component": "TaskDetailSection",
        "label": "My Feature",
        "order": 50
      }
    ]
  }
}
```

| Extension point ID | Surface |
|---|---|
| `sidebar.general.section` | Global left navigation |
| `sidebar.project.section` | Project sidebar |
| `task.detail.section` | Task detail drawer/page |
| `project.settings.tab` | Project settings tabs |
| `view` | Full board/view replacement |

For local dev through the nginx gateway, set `remoteEntryUrl` to `/plugins/<plugin-id>/assets/remoteEntry.js`. Production plugins use an HTTPS CDN origin allowlisted in server config.

### MCP section (optional)

```json
{
  "mcp": {
    "remoteEntryUrl": "/plugins-mcp/com.example.my-plugin/mcp.js"
  }
}
```

The Paca MCP server loads this ESM bundle at startup and merges exported tools. Prefix tool names (e.g. `my_plugin_list_items`) to avoid collisions.

## Build the WASM backend

### Go module and entry point

```go
//go:build wasip1

package main

import plugin "github.com/Paca-AI/plugin-sdk-go"

type myPlugin struct {
    db  *plugin.DB
    kv  *plugin.KV
    log *plugin.Logger
}

func (p *myPlugin) Init(ctx *plugin.Context) error {
    p.db  = ctx.DB()
    p.kv  = ctx.KV()
    p.log = ctx.Log()

    ctx.Route("GET",  "/projects/:projectId/tasks/:taskId/my-items", p.listItems)
    ctx.Route("POST", "/projects/:projectId/tasks/:taskId/my-items", p.createItem)
    ctx.On("task.deleted", p.onTaskDeleted)
    return nil
}

func (p *myPlugin) Shutdown() {}

func init() { plugin.Run(&myPlugin{}) }
func main() {}
```

Routes registered in `Init` must match paths declared in `plugin.json`. The host dispatches matching HTTP requests to the plugin's WASM `HandleRequest` export.

### Compile

<CodeGroup>
```bash title="Standard Go (recommended locally)"
cd backend
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o backend.wasm .
```

```bash title="TinyGo (smaller binary)"
cd backend
GOOS=wasip1 GOARCH=wasm tinygo build -o backend.wasm -target wasip1 .
```
</CodeGroup>

Output filename must be `backend.wasm` — the host reads `{PLUGINS_WASM_DIR}/{plugin-id}/backend.wasm`.

### Database migrations

Place additive SQL files in `backend/migrations/` using lexicographic names (`0001_create_items.sql`, `0002_add_column.sql`). The host discovers files from the filesystem; they are **not** listed in `plugin.json`.

On apply, the migration runner:

1. Creates schema `plugin_data_{plugin_id}` (dots → underscores, e.g. `com.paca.checklist` → `plugin_data_com_paca_checklist`)
2. Ensures `plugin_kv` and `plugin_schema_migrations` tables exist
3. Runs pending `.sql` files in order with `search_path` set to the plugin schema

```sql
-- backend/migrations/0001_create_my_items.sql
CREATE TABLE my_items (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id    UUID NOT NULL,
    project_id UUID NOT NULL,
    title      TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON my_items (task_id);
```

<Warning>
Migrations are additive only. Never drop or rename columns in place; ship a new migration file instead.
</Warning>

## Build the frontend

### Vite Module Federation config

```ts
import federation from "@originjs/vite-plugin-federation";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "com_example_my_plugin",
      filename: "remoteEntry.js",
      exposes: {
        "./TaskDetailSection": "./src/TaskDetailSection.tsx",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.3.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.3.0" },
        "@paca-ai/plugin-sdk-react": { singleton: true },
      },
    }),
  ],
  build: {
    target: "esnext",
    outDir: "../dist",
    emptyOutDir: false,
  },
});
```

### Extension-point component

```tsx
import type { TaskDetailSectionProps } from "@paca-ai/plugin-sdk-react";
import {
  PluginQueryClientProvider,
  usePluginQuery,
} from "@paca-ai/plugin-sdk-react";

export default function TaskDetailSection(props: TaskDetailSectionProps) {
  return (
    <PluginQueryClientProvider>
      <MyFeaturePanel {...props} />
    </PluginQueryClientProvider>
  );
}

function MyFeaturePanel({ api, meta, taskId, projectId }: TaskDetailSectionProps) {
  const { data: items = [], isLoading } = usePluginQuery(
    meta.pluginId,
    ["my-items", taskId],
    () =>
      api.pluginGet(
        meta.pluginId,
        `projects/${projectId}/tasks/${taskId}/my-items`,
      ),
  );

  if (isLoading) return <div>Loading…</div>;
  return (
    <section>
      <h3>My Feature</h3>
      <ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
    </section>
  );
}
```

```bash
cd frontend
bun install
bun run build
```

Vite emits `dist/assets/remoteEntry.js` plus hashed chunks. Copy the entire `dist/` tree into `plugins/local/frontend/<plugin-id>/`.

## Build output summary

| Artifact | Source | Install path | Consumed by |
|---|---|---|---|
| `backend.wasm` | Go/TinyGo compile | `plugins/local/backend/<id>/backend.wasm` | API wazero runtime |
| `plugin.json` | Project root | `plugins/local/backend/<id>/plugin.json` | API store + DB manifest |
| `migrations/*.sql` | `backend/migrations/` | `plugins/local/backend/<id>/migrations/` | `MigrationRunner` on API startup |
| `assets/remoteEntry.js` | Vite federation build | `plugins/local/frontend/<id>/assets/` | nginx gateway → web host |
| `mcp.js` | Vite lib build (optional) | `plugins/local/mcp/<id>/mcp.js` | `@paca-ai/paca-mcp` |

```mermaid
flowchart LR
  subgraph author [Plugin author repo]
    MJ[plugin.json]
    WASM[backend.wasm]
    FE[remoteEntry.js]
    SQL[migrations/*.sql]
  end

  subgraph store [plugins/local]
    BE[backend/id/]
    FF[frontend/id/]
  end

  subgraph runtime [Paca runtime]
    API[services/api wazero]
    WEB[apps/web Module Federation]
    MCP[paca-mcp loader]
  end

  MJ --> BE
  WASM --> BE
  SQL --> BE
  FE --> FF
  BE --> API
  FF --> WEB
  BE --> MCP
```

## Install locally

<Steps>
<Step title="Build and copy artifacts">

Use the install script (builds backend WASM, frontend bundle, copies into `plugins/local/`, then registers via API):

```bash
export API_KEY=<admin-api-key>
./scripts/install-local-plugin.sh /path/to/my-plugin \
  --api-url http://localhost \
  --api-key "$API_KEY"
```

Flags: `--skip-build` (install only), `--skip-install` (build only), `--paca-dir` (override monorepo root).

Or follow the manual sequence in `scripts/QUICK_START.md`: compile WASM, copy to `plugins/local/backend/<id>/`, build frontend, copy to `plugins/local/frontend/<id>/`.

</Step>

<Step title="Register the plugin in the database">

`POST /api/v1/admin/plugins` requires authentication and `users.write` permission. Use session cookie or `X-API-Key` header.

<RequestExample>
```bash
curl -X POST http://localhost/api/v1/admin/plugins \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"com.example.my-plugin\",
    \"version\": \"0.1.0\",
    \"manifest\": $(cat /path/to/my-plugin/plugin.json),
    \"enabled\": true
  }"
```
</RequestExample>

<ResponseExample>
```json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "com.example.my-plugin",
    "version": "0.1.0",
    "enabled": true,
    "manifest": { "id": "com.example.my-plugin", "displayName": "My Plugin", "version": "0.1.0" }
  }
}
```
</ResponseExample>

To update an existing plugin, `PATCH /api/v1/admin/plugins/:pluginId` with new `version` and `manifest`.

<Note>
Unlike marketplace install, the admin install endpoint only writes the DB record. Migrations and WASM loading happen on the next API process start.
</Note>

</Step>

<Step title="Restart API services">

Restart `services/api` (or the full Compose stack). On startup the host:

1. Runs pending migrations for each enabled plugin
2. Loads `backend.wasm` into wazero and calls `Init()`
3. Registers plugin routes on the Gin router

The web app picks up the plugin on the next page load via `GET /api/v1/plugins`.

</Step>

<Step title="Verify">

**List installed plugins:**

```bash
curl -s http://localhost/api/v1/plugins -H "X-API-Key: $API_KEY" | jq '.data.plugins'
```

**Call a plugin route:**

```bash
curl -s http://localhost/api/v1/plugins/com.example.my-plugin/projects/{pid}/tasks/{tid}/my-items \
  -H "X-API-Key: $API_KEY" | jq .
```

**Frontend:** open a task detail panel — the `task.detail.section` component should render in order alongside other plugins.

</Step>
</Steps>

## Versioning and publishing checklist

- Follow semver: patch for fixes, minor for new extension points or routes, major for breaking API changes.
- Set `minCoreVersion` in `plugin.json` to the lowest Paca version you tested against.
- Keep all plugin tables inside the auto-created `plugin_data_*` schema.
- Never embed secrets in WASM or JS bundles; read config through host `paca.config_get` with `allowedConfigKeys`.
- Point `frontend.remoteEntryUrl` at HTTPS in production; sign WASM for third-party distribution.
- Add unit tests with `plugintest` for backend routes and events.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| Plugin missing from UI | `enabled: false`, no `frontend.remoteEntryUrl`, or no extension-point registrations |
| `404` on plugin route | Route path mismatch between `plugin.json` and `ctx.Route`, or API not restarted after install |
| Migration failure on startup | Non-additive SQL, syntax error, or migration file not copied to `plugins/local/backend/<id>/migrations/` |
| Remote entry load error | Wrong `remoteEntryUrl` path — local installs need `/plugins/<id>/assets/remoteEntry.js` |
| `401`/`403` on install | API key lacks `users.write`; use an admin key |
| WASM load error | Binary not at `backend.wasm`, wrong build target, or missing `-buildmode=c-shared` with standard Go |

## Related pages

<CardGroup>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, extension points, MCP tools, capability permissions, and marketplace lifecycle.
</Card>
<Card title="Plugin SDK reference" href="/plugin-sdk-reference">
Typed APIs for React, Go, and MCP SDK packages.
</Card>
<Card title="Install marketplace plugins" href="/install-marketplace-plugins">
Install from the Paca-AI/paca-plugins catalog via admin UI or API.
</Card>
<Card title="Local development" href="/local-development">
Dev Compose stack, hot-reload, and nginx gateway port map.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` and load plugin MCP tools in AI clients.
</Card>
</CardGroup>

---

## 14. Install marketplace plugins

> Install plugins from the Paca-AI/paca-plugins catalog via the admin UI or API; artifact layout, migrations, and filesystem install scripts.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/14-install-marketplace-plugins.md
- Generated: 2026-06-13T22:09:14.484Z

### Source Files

- `docs/plugins/marketplace.md`
- `docs/plugins/overview.md`
- `scripts/install-plugin.sh`
- `scripts/install-local-plugin.sh`
- `services/api/internal/transport/http/handler/plugin_handler.go`
- `deploy/.env.production.example`

---
title: "Install marketplace plugins"
description: "Install plugins from the Paca-AI/paca-plugins catalog via the admin UI or API; artifact layout, migrations, and filesystem install scripts."
---

Paca installs marketplace plugins by fetching `catalog/plugins.json` from the public [Paca-AI/paca-plugins](https://github.com/Paca-AI/paca-plugins) repository, downloading HTTPS artifact tarballs, writing them into configured local plugin directories, registering the plugin in PostgreSQL, running per-plugin SQL migrations, and loading the WASM runtime module. Admins trigger installs from **Admin → Plugin Settings → Marketplace** or from `POST /api/v1/admin/plugins/marketplace/install`.

## Prerequisites

| Requirement | Detail |
|---|---|
| Admin access | Global `users.write` permission (super-admin role or equivalent) |
| Authentication | Valid JWT session cookie or `X-API-Key` header |
| Writable plugin stores | API container must write to `PLUGINS_WASM_DIR`, `PLUGINS_FRONTEND_DIR`, and optionally `PLUGINS_MCP_DIR` |
| Store mode | Marketplace installer writes to the local filesystem; set `PLUGINS_STORE=local` |
| Outbound HTTPS | API must reach the catalog URL and all artifact URLs (HTTPS only; private IPs blocked) |
| Gateway | Nginx must serve frontend bundles at `/plugins/` and MCP bundles at `/plugins-mcp/` |

<Note>
Marketplace installation is separate from local plugin authoring. Use `scripts/install-local-plugin.sh` when you are building a plugin from source on disk, not when installing a catalog entry.
</Note>

## Catalog source

The marketplace catalog is a JSON document published in the `Paca-AI/paca-plugins` GitHub repository.

| Field | Value |
|---|---|
| Repository | `Paca-AI/paca-plugins` |
| Catalog path | `catalog/plugins.json` |
| Default URL | `https://raw.githubusercontent.com/Paca-AI/paca-plugins/master/catalog/plugins.json` |
| Publish model | Plugin developers open pull requests to add or update entries after publishing release artifacts |

### Catalog entry shape

Each plugin entry includes metadata and downloadable artifact URLs:

```json
{
  "schema_version": 1,
  "source": "https://github.com/Paca-AI/paca-plugins",
  "generated_at": "2026-05-08T00:00:00Z",
  "plugins": [
    {
      "name": "com.paca.checklist",
      "display_name": "Checklist",
      "description": "Adds named checklists with checkable items to tasks.",
      "version": "0.1.0",
      "avatar_url": "https://raw.githubusercontent.com/Paca-AI/paca-plugins/main/assets/checklist.png",
      "repository_url": "https://github.com/Paca-AI/paca-plugin-checklist",
      "artifacts": {
        "backend_tar_gz_url": "https://github.com/.../backend.tar.gz",
        "frontend_tar_gz_url": "https://github.com/.../frontend.tar.gz",
        "migrations_tar_gz_url": "https://github.com/.../migrations.tar.gz",
        "manifest_tar_gz_url": "https://github.com/.../manifest.tar.gz",
        "mcp_tar_gz_url": "https://github.com/.../mcp.tar.gz"
      }
    }
  ]
}
```

| Field | Required | Notes |
|---|---|---|
| `name` | Yes | Reverse-DNS identifier (e.g. `com.paca.checklist`) |
| `version` | Yes | Strict `X.Y.Z` semver for upgrade comparisons |
| `description` | Recommended | Shown in the admin marketplace panel |
| `display_name` | Optional | Human-readable label |
| `avatar_url` | Optional | Plugin icon in the UI |
| `repository_url` | Optional | Source link in the UI |
| `artifacts.manifest_tar_gz_url` | Yes | Must contain `plugin.json` |
| `artifacts.backend_tar_gz_url` | Optional | WASM binary tarball |
| `artifacts.frontend_tar_gz_url` | Optional | Module-federation frontend bundle |
| `artifacts.migrations_tar_gz_url` | Optional | SQL migration files |
| `artifacts.mcp_tar_gz_url` | Optional | MCP ESM bundle for `@paca-ai/paca-mcp` |

All artifact URLs must use HTTPS and resolve to public IP addresses. The API validates URLs at catalog fetch time to reduce SSRF risk.

## Install lifecycle

```mermaid
sequenceDiagram
    participant Admin as Admin UI / API client
    participant API as API service
    participant Catalog as paca-plugins catalog
    participant FS as Plugin filesystem stores
    participant DB as PostgreSQL
    participant RT as WASM runtime

    Admin->>API: POST /admin/plugins/marketplace/install
    API->>Catalog: GET catalog/plugins.json
    Catalog-->>API: Plugin entry + artifact URLs
    API->>Catalog: Download manifest/backend/frontend/migrations/mcp tar.gz
    API->>FS: Extract and copy artifacts
    API->>DB: INSERT plugins row
    API->>DB: Run plugin SQL migrations (per-plugin schema)
    API->>RT: Load WASM module
    alt Any step after artifact write fails
        API->>FS: Remove downloaded artifacts
        API->>DB: Delete plugin row (if created)
    end
    API-->>Admin: 201 Created + PluginResponse
```

On failure after artifacts are written but before the install completes, the handler removes downloaded files and deletes any partially created database record. Migration or runtime load failures roll back the DB registration.

## Install via admin UI

<Steps>
<Step title="Open Plugin Settings">

Log in as an admin user with `users.write` permission and navigate to **Admin → Plugin Settings**. The page is at `/admin/plugins` and defaults to the **Marketplace** tab.

</Step>
<Step title="Browse the catalog">

The panel calls `GET /api/v1/admin/plugins/marketplace` and renders each catalog entry with version, description, capability badges (Backend, Frontend, Migrations, MCP), and install state.

</Step>
<Step title="Install a plugin">

Click **Install** on the desired plugin card. The UI posts:

```json
{ "name": "com.paca.checklist", "enabled": true }
```

Installation is synchronous. The card shows **Installing...** until the API returns.

</Step>
<Step title="Verify the install">

Confirm the plugin card shows **Installed**. Check `GET /api/v1/plugins` for the new entry with `enabled: true`. If the plugin declares frontend extension points, reload the web app and confirm the plugin UI surfaces appear.

</Step>
</Steps>

Installed plugins with a newer catalog version show an **Update available** badge. Click **Upgrade to {version}** to call `POST /api/v1/admin/plugins/:pluginId/upgrade`.

## Install via API

All marketplace admin endpoints require authentication plus global `users.write` permission.

### List catalog entries

:::endpoint GET /api/v1/admin/plugins/marketplace
Fetch the validated marketplace catalog and return plugin entries for the admin UI or automation.
:::

<RequestExample>

```bash
curl -s http://localhost/api/v1/admin/plugins/marketplace \
  -H "X-API-Key: $API_KEY"
```

</RequestExample>

<ResponseExample>

```json
{
  "data": {
    "plugins": [
      {
        "name": "com.paca.checklist",
        "display_name": "Checklist",
        "description": "Adds named checklists with checkable items to tasks.",
        "version": "0.1.0",
        "avatar_url": "https://raw.githubusercontent.com/.../checklist.png",
        "repository_url": "https://github.com/Paca-AI/paca-plugin-checklist",
        "artifacts": {
          "backend_tar_gz_url": "https://github.com/.../backend.tar.gz",
          "frontend_tar_gz_url": "https://github.com/.../frontend.tar.gz",
          "migrations_tar_gz_url": "https://github.com/.../migrations.tar.gz",
          "manifest_tar_gz_url": "https://github.com/.../manifest.tar.gz"
        }
      }
    ]
  }
}
```

</ResponseExample>

### Install from marketplace

:::endpoint POST /api/v1/admin/plugins/marketplace/install
Resolve a catalog entry by reverse-DNS name, download artifacts, register the plugin, run migrations, and load the runtime.
:::

<ParamField body="name" type="string" required>
Reverse-DNS plugin identifier matching a catalog entry `name` field (e.g. `com.paca.checklist`).
</ParamField>

<ParamField body="enabled" type="boolean">
Whether the plugin is active immediately after install. Defaults to `true` when omitted.
</ParamField>

<RequestExample>

```bash
curl -s -X POST http://localhost/api/v1/admin/plugins/marketplace/install \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"com.paca.checklist","enabled":true}'
```

</RequestExample>

<ResponseExample>

```json
{
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "com.paca.checklist",
    "version": "0.1.0",
    "manifest": { "id": "com.paca.checklist", "displayName": "Checklist", "version": "0.1.0" },
    "enabled": true,
    "installed_at": "2026-06-13T12:00:00Z",
    "updated_at": "2026-06-13T12:00:00Z"
  }
}
```

</ResponseExample>

| HTTP status | Error code | Cause |
|---|---|---|
| 404 | `PLUGIN_NOT_FOUND` | Name not present in catalog |
| 400 | `BAD_REQUEST` | Artifact download, extraction, or manifest ID mismatch |
| 409 | `PLUGIN_NAME_TAKEN` | Plugin already installed (use upgrade instead) |
| 500 | `INTERNAL_ERROR` | Migration failure or WASM runtime load failure |
| 403 | — | Caller lacks `users.write` permission |

### Upgrade an installed plugin

:::endpoint POST /api/v1/admin/plugins/:pluginId/upgrade
Fetch the latest catalog version for the installed plugin name, verify semver ordering, replace artifacts, run new migrations, reload runtime, then update the database record.
:::

| HTTP status | Error code | Cause |
|---|---|---|
| 409 | `PLUGIN_ALREADY_UP_TO_DATE` | Catalog version equals installed version |
| 400 | `PLUGIN_DOWNGRADE_NOT_ALLOWED` | Catalog version is older than installed version |
| 400 | `BAD_REQUEST` | Downloaded manifest version does not match catalog version |

Upgrade only accepts strict `X.Y.Z` versions. Pre-release identifiers (`1.0.0-beta.1`) and build metadata (`1.0.0+001`) are rejected.

### Uninstall a plugin

:::endpoint DELETE /api/v1/admin/plugins/:pluginId
Delete the database record, unload the WASM module, and remove backend, frontend, and MCP artifacts from the filesystem.
:::

The admin UI **Uninstall** button calls this endpoint with the plugin UUID.

## Artifact layout

After a marketplace install, artifacts land in three filesystem stores configured by environment variables.

:::files
PLUGINS_WASM_DIR/                    # default: /plugins (prod) or ./plugins/local/backend (dev)
  <plugin-id>/
    backend.wasm                     # WASM binary from backend tarball
    plugin.json                      # manifest (id must match catalog name)
    migrations/
      001_init.sql                   # optional SQL files

PLUGINS_FRONTEND_DIR/                # default: /plugins-frontend (prod) or ./plugins/local/frontend (dev)
  <plugin-id>/
    assets/
      remoteEntry.js                 # module-federation entry
    ...                              # remaining built assets

PLUGINS_MCP_DIR/                     # default: /plugins-mcp (prod) or ./plugins/local/mcp (dev)
  <plugin-id>/
    mcp.js                           # ESM bundle for paca-mcp plugin loader
:::

### HTTP serving paths

| Store | Container path (prod) | Public URL |
|---|---|---|
| Frontend | `/plugins-frontend/<id>/` | `/plugins/<id>/assets/remoteEntry.js` via nginx |
| MCP | `/plugins-mcp/<id>/` | `/plugins-mcp/<id>/mcp.js` via nginx |
| Backend | `/plugins/<id>/` | Not served over HTTP (API-only) |

Set `remoteEntryUrl` in `plugin.json` to `/plugins/<plugin-id>/assets/remoteEntry.js`. Set MCP `remoteEntryUrl` to `${PUBLIC_URL}/plugins-mcp/<plugin-id>/mcp.js`.

<Warning>
The nginx gateway serves only frontend and MCP directories. WASM binaries and migration SQL files are mounted exclusively on the API container and are not reachable over HTTP.
</Warning>

### Download and extraction limits

| Constraint | Value |
|---|---|
| Max download size | 100 MB per artifact |
| Max file size in archive | 50 MB |
| Max files per archive | 10,000 |
| Archive format | `.tar.gz` |

The installer validates that `plugin.json` `id` matches the catalog `name` before writing files.

## Database migrations

Plugins with `migrations_tar_gz_url` artifacts receive a dedicated PostgreSQL schema namespace.

1. `CREATE SCHEMA IF NOT EXISTS "<plugin-name>"` (quoted identifier)
2. `CREATE TABLE IF NOT EXISTS plugin_kv` for plugin storage host functions
3. `CREATE TABLE IF NOT EXISTS plugin_schema_migrations` to track applied files
4. Apply pending `.sql` files in lexicographic filename order inside per-file transactions

Migrations are idempotent: already-applied filenames are skipped. The migration runner also runs on API startup for all enabled plugins.

<Info>
Plugin data lives in per-plugin schemas and is retained on uninstall unless the plugin implements explicit cleanup. Uninstall removes filesystem artifacts and the `plugins` registry row but does not drop plugin schemas.
</Info>

## Local filesystem install scripts

For plugin development (not marketplace catalog installs), use the repository install scripts to build artifacts locally and register them via the admin API.

<Tabs>
<Tab title="install-plugin.sh">

`scripts/install-plugin.sh` is a thin wrapper that delegates to `install-local-plugin.sh`:

```bash
./scripts/install-plugin.sh /path/to/paca-plugin-example --api-key $API_KEY
```

</Tab>
<Tab title="install-local-plugin.sh">

`scripts/install-local-plugin.sh` performs a full local build-and-register workflow:

1. Build `backend.wasm` with `GOOS=wasip1 GOARCH=wasm go build`
2. Build frontend with `bun run build`
3. Copy artifacts into `plugins/local/backend/<id>/` and `plugins/local/frontend/<id>/`
4. Register or update the plugin via the admin API

```bash
export API_KEY=your-api-key
./scripts/install-local-plugin.sh /path/to/plugin \
  --api-url http://localhost \
  --api-key $API_KEY
```

| Flag | Purpose |
|---|---|
| `--skip-build` | Copy existing build output only |
| `--skip-install` | Build artifacts without API registration |
| `--paca-dir` | Override Paca repo root (default: parent of `scripts/`) |
| `--api-url` | API base URL (default: `http://localhost`) |

</Tab>
<Tab title="remove-plugin.sh">

`scripts/remove-plugin.sh` removes a plugin by reverse-DNS ID:

```bash
./scripts/remove-plugin.sh com.paca.example --api-key $API_KEY
```

| Flag | Purpose |
|---|---|
| `--unregister-only` | Delete DB record; keep local artifact files |
| `--remove-artifacts-only` | Delete local files; keep DB record |

</Tab>
</Tabs>

### Manual registration after copying artifacts

If you populate the local stores manually, register the plugin with `POST /api/v1/admin/plugins`:

```bash
curl -X POST http://localhost/api/v1/admin/plugins \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"com.paca.example\",
    \"version\": \"0.1.0\",
    \"manifest\": $(cat /path/to/plugin.json),
    \"enabled\": true
  }"
```

<Tip>
After a filesystem-only install, restart Paca services or ensure the API reloads the plugin runtime so WASM modules pick up new binaries.
</Tip>

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `PLUGINS_MARKETPLACE_CATALOG_URL` | `https://raw.githubusercontent.com/Paca-AI/paca-plugins/master/catalog/plugins.json` | Catalog fetch URL |
| `PLUGINS_MARKETPLACE_TIMEOUT` | `20s` | HTTP timeout for catalog fetch and artifact downloads |
| `PLUGINS_STORE` | `local` | WASM load source (`local` or `s3`); marketplace installer requires writable local dirs |
| `PLUGINS_WASM_DIR` | `/plugins` (prod), `./plugins/local/backend` (dev) | Backend WASM, manifest, migrations |
| `PLUGINS_FRONTEND_DIR` | `/plugins-frontend` (prod), `./plugins/local/frontend` (dev) | Frontend bundles |
| `PLUGINS_MCP_DIR` | `/plugins-mcp` (prod), `./plugins/local/mcp` (dev) | MCP ESM bundles |
| `PLUGINS_S3_PREFIX` | `plugins` | S3 key prefix when `PLUGINS_STORE=s3` (runtime load only) |
| `PUBLIC_URL` | — | Base URL for MCP `remoteEntryUrl` resolution |

Override the catalog URL to point at a fork or pinned branch when testing custom marketplace entries:

```bash
PLUGINS_MARKETPLACE_CATALOG_URL=https://raw.githubusercontent.com/your-org/paca-plugins/main/catalog/plugins.json
```

## Troubleshooting

| Symptom | Likely cause | Resolution |
|---|---|---|
| Marketplace tab empty or loading forever | Catalog URL unreachable or invalid JSON | Verify `PLUGINS_MARKETPLACE_CATALOG_URL`; check API logs for fetch errors |
| `marketplace installer not configured` | Marketplace client or installer not wired | Ensure API started with plugin subsystem enabled (default in compose stacks) |
| `failed to install plugin artifacts` | Artifact URL not HTTPS, private IP, or download failure | Confirm release URLs are public HTTPS; check outbound network from API container |
| `manifest id does not match catalog name` | `plugin.json` `id` differs from catalog `name` | Fix the plugin manifest or catalog entry |
| `failed to run plugin migrations` | Invalid SQL or schema conflict | Inspect API logs for the failing filename; fix migration in plugin release |
| `failed to load plugin runtime` | Missing or corrupt `backend.wasm` | Verify backend artifact tarball; check `PLUGINS_WASM_DIR` permissions |
| Plugin UI does not appear | Frontend not served or `remoteEntryUrl` wrong | Confirm nginx `/plugins/` alias; verify `PLUGINS_FRONTEND_DIR` mount |
| MCP tools missing after install | No `mcp_tar_gz_url` or MCP bundle not served | Install MCP artifact; set `remoteEntryUrl` under `PUBLIC_URL/plugins-mcp/` |
| `PLUGIN_NAME_TAKEN` on install | Plugin already registered | Use **Upgrade** or uninstall first |
| 403 on admin endpoints | Missing `users.write` | Use a super-admin account or API key with admin permissions |

<Check>
Verify a successful marketplace install by confirming three signals: the plugin appears in `GET /api/v1/plugins` with `enabled: true`, artifact files exist under `PLUGINS_WASM_DIR/<name>/`, and the plugin's extension points render in the web UI (for frontend plugins).
</Check>

## Related pages

<CardGroup>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, extension points, capability permissions, and the full plugin lifecycle.
</Card>
<Card title="Build a plugin" href="/build-plugin">
Author `plugin.json`, backend WASM, frontend bundles, migrations, and release artifacts for catalog publication.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
All `PLUGINS_*` environment variables across API, web gateway, and MCP services.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` to load dynamically installed MCP plugin bundles.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Stack-wide health checks, auth, Valkey, storage, and MCP connection failures.
</Card>
</CardGroup>

---

## 15. REST API reference

> Versioned /api/v1 paths, auth cookies and bearer tokens, response envelopes, pagination, and implemented endpoint catalog by resource.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/15-rest-api-reference.md
- Generated: 2026-06-13T22:10:41.533Z

### Source Files

- `docs/api/http-design.md`
- `docs/api/README.md`
- `services/api/internal/transport/http/router/router.go`
- `services/api/internal/transport/http/handler/auth_handler.go`
- `services/api/internal/transport/http/presenter/response.go`
- `services/api/internal/transport/http/middleware/authz.go`

---
title: "REST API reference"
description: "Versioned /api/v1 paths, auth cookies and bearer tokens, response envelopes, pagination, and implemented endpoint catalog by resource."
---

The Paca API is served by `services/api` (Gin) and exposed at `/api` through the nginx gateway. Product routes live under `/api/v1`; infrastructure probes use `/api/healthz`. All `/api/v1` JSON responses use a standard envelope with a per-request `request_id` echoed in the `X-Request-ID` response header.

## Base URL and routing

| Surface | Path prefix | Notes |
|---|---|---|
| Health | `/api/healthz` | Minimal `{ "status": "ok" }` — no envelope |
| Product API | `/api/v1` | Standard success/error envelope |
| Plugin proxy | `/api/v1/plugins/:pluginId/*` | Forwards to WASM plugin handlers per manifest |

In Docker Compose, the gateway listens on port **80** and forwards `/api/` unchanged to the API service (`api:8080`).

<Info>
Resource paths use plural nouns and UUID identifiers (`:projectId`, `:taskId`, …). Nested resources express ownership (`/projects/:projectId/tasks/:taskId`).
</Info>

## Authentication

Paca supports three credential sources, checked in order:

1. **HttpOnly cookie** `access_token` (set by login/refresh)
2. **`Authorization: Bearer <access-jwt>`** for CLI and integrations
3. **API key** via `Authorization: ApiKey <key>` or `X-API-Key: <key>`

Refresh tokens are stored in the HttpOnly `refresh_token` cookie scoped to path `/api/v1/auth/refresh` only.

### Session auth (web UI)

:::endpoint POST /api/v1/auth/login
Validate username/password and set `access_token` + `refresh_token` cookies. Token values are never returned in the response body.
:::

<ParamField body="username" type="string" required>
Account username (not email).
</ParamField>

<ParamField body="password" type="string" required>
Plain-text password.
</ParamField>

<ParamField body="remember_me" type="boolean">
When `true`, extends refresh-token lifetime for a persistent session.
</ParamField>

<RequestExample>
```bash Login (session cookies)
curl -c cookies.txt -X POST https://your-host/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"secret","remember_me":false}'
```
</RequestExample>

<ResponseExample>
```json Success
{
  "success": true,
  "data": { "message": "logged in" },
  "request_id": "9a1d7c2b-..."
}
```
</ResponseExample>

:::endpoint POST /api/v1/auth/refresh
Read `refresh_token` from cookie, rotate both tokens, write new cookies. No request body.
:::

:::endpoint POST /api/v1/auth/logout
Requires authentication. Revokes the session family and clears both auth cookies.
:::

### Bearer token auth (API clients)

<RequestExample>
```bash Authenticated request
curl https://your-host/api/v1/users/me \
  -H "Authorization: Bearer <access-jwt>"
```
</RequestExample>

### API key auth

User-created API keys authenticate like JWTs for most routes. Create keys via session auth only (API keys cannot manage other API keys).

<RequestExample>
```bash API key request
curl https://your-host/api/v1/projects \
  -H "Authorization: ApiKey paca_xxxxxxxx"
```
</RequestExample>

<ResponseField name="key" type="string">
Returned once from `POST /api/v1/users/me/api-keys`. Not retrievable afterward.
</ResponseField>

### Agent API key mode

When using the static agent API key, pass `X-Agent-ID: <agent-uuid>` so authorization resolves agent-scoped project permissions instead of the key owner's user permissions.

### Fresh password gate

Most routes require a **fresh** access token: `must_change_password` must be `false` in the JWT. When `true`, protected routes return `403` with `AUTH_PASSWORD_CHANGE_REQUIRED`. The exception is `PATCH /api/v1/users/me/password`, which is always reachable so forced password changes can be completed.

## Authorization

Authorization is **permission-based**, enforced per route in middleware. Permissions are resolved from:

- Legacy role compatibility (`ADMIN` / `USER`)
- Assigned global roles
- Project-scoped roles (when a `:projectId` is present)

<Warning>
Permission strings in API enforcement use dotted names such as `project.members.read` and `project.roles.write`, not shortened aliases.
</Warning>

### Global permissions

| Permission | Grants |
|---|---|
| `users.read` / `users.write` / `users.delete` | Admin user management |
| `global_roles.read` / `global_roles.write` / `global_roles.assign` | Global role CRUD and assignment |
| `projects.create` | Create projects |
| `projects.read` / `projects.write` / `projects.delete` | Global project access |
| `*` | Superuser (ADMIN role default) |

### Project-scoped permissions

| Permission | Grants |
|---|---|
| `project.members.read` / `project.members.write` | Member list and mutations |
| `project.roles.read` / `project.roles.write` | Custom project roles |
| `tasks.read` / `tasks.write` | Task configuration and work items |
| `sprints.read` / `sprints.write` | Sprints and interaction views |
| `docs.read` / `docs.write` | Project documentation |
| `agents.read` / `agents.write` | AI agents and conversations |

Wildcard forms (`users.*`, `tasks.*`, `agents.*`, …) satisfy any permission in that domain.

### Public projects

Read-only project routes use optional authentication. When `is_public = true` on a project, unauthenticated callers can read permitted resources (tasks, docs, members, etc.) without credentials. Write operations always require authentication and the matching permission.

## Response envelope

### Success (`200` / `201`)

```json
{
  "success": true,
  "data": {},
  "request_id": "9a1d7c2b-..."
}
```

`204 No Content` responses (logout-adjacent deletes, some mutations) return an empty body with no envelope.

### Error

```json
{
  "success": false,
  "error_code": "TASK_NOT_FOUND",
  "error": "descriptive message",
  "request_id": "9a1d7c2b-..."
}
```

Clients should branch on `error_code`, not HTTP status or message text alone. Internal errors (`500`) return a generic message while details are logged server-side.

## Pagination and filtering

Paca uses two pagination models.

### Offset pagination

Used by `GET /api/v1/projects` and `GET /api/v1/admin/users`.

<ParamField query="page" type="integer" default="1">
1-based page number.
</ParamField>

<ParamField query="page_size" type="integer" default="20">
Items per page. Values outside `1–100` clamp to `20`.
</ParamField>

```json
{
  "success": true,
  "data": {
    "items": [],
    "total": 42,
    "page": 1,
    "page_size": 20
  },
  "request_id": "..."
}
```

### Cursor pagination (tasks)

Used by `GET /api/v1/projects/:projectId/tasks`.

<ParamField query="page_size" type="integer" default="20">
Items per page. Values outside `1–200` clamp to `20`.
</ParamField>

<ParamField query="cursor" type="string">
Opaque cursor from a previous response's `next_cursor`. Omit for the first page.
</ParamField>

```json
{
  "success": true,
  "data": {
    "items": [],
    "page_size": 20,
    "next_cursor": "encoded-cursor-or-null"
  },
  "request_id": "..."
}
```

### Task list filters

| Query param | Description |
|---|---|
| `sprint_id` | UUID, or `null` for backlog-only tasks |
| `sprint_ids` | Comma-separated UUIDs |
| `status_id` / `status_ids` | Filter by workflow status |
| `assignee_id` / `assignee_ids` | Filter by assignee; `assignee_id=null` for unassigned |
| `task_type_id` / `task_type_ids` | Filter by task type |
| `parent_task_id` | Child tasks of a parent |
| `view_id` | Enriches each task with `view_position` and `view_group_key` from manual ordering |

### View context query param

Interaction views share one path with a required context:

<ParamField query="context" type="string" default="sprint">
One of `sprint`, `backlog`, or `timeline`.
</ParamField>

<ParamField query="sprint_id" type="uuid" required>
Required when `context=sprint`.
</ParamField>

## Endpoint catalog

Paths are relative to `/api/v1`. **Auth** column abbreviations: `—` = public; `session` = cookie or Bearer JWT; `key` = API key also accepted; `fresh` = session/key + fresh password; `perm:<name>` = requires that permission (global or project-scoped as routed).

### Health

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/healthz` | — | Liveness probe |

### Auth

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/auth/login` | — | Login; set cookies |
| `POST` | `/auth/refresh` | — | Rotate tokens from refresh cookie |
| `POST` | `/auth/logout` | session | Revoke session; clear cookies |

### Users (self-service)

| Method | Path | Auth | Description |
|---|---|---|---|
| `PATCH` | `/users/me/password` | session/key | Change own password |
| `GET` | `/users/me` | fresh | Own profile |
| `PATCH` | `/users/me` | fresh | Update `full_name` |
| `GET` | `/users/me/global-permissions` | fresh | Effective global permissions |
| `GET` | `/users/me/api-keys` | fresh + JWT only | List API keys |
| `POST` | `/users/me/api-keys` | fresh + JWT only | Create API key (raw key returned once) |
| `DELETE` | `/users/me/api-keys/:keyId` | fresh + JWT only | Revoke API key |
| `GET` | `/users/me/notifications` | fresh | Recent notifications + unread count |
| `PATCH` | `/users/me/notifications/:notificationId/read` | fresh | Mark one read |
| `POST` | `/users/me/notifications/read-all` | fresh | Mark all read |

### Admin — users and global roles

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/admin/users` | fresh + `users.read` | Paginated user list |
| `POST` | `/admin/users` | fresh + `users.write` | Create user (`must_change_password=true`) |
| `GET` | `/admin/users/:userId` | fresh + `users.read` | User by ID |
| `PATCH` | `/admin/users/:userId` | fresh + `users.write` | Update `full_name` or role |
| `PATCH` | `/admin/users/:userId/password` | fresh + `users.write` | Admin password reset |
| `DELETE` | `/admin/users/:userId` | fresh + `users.delete` | Soft-delete user |
| `GET` | `/admin/global-roles` | fresh + `global_roles.read` | List global roles |
| `POST` | `/admin/global-roles` | fresh + `global_roles.write` | Create global role |
| `PATCH` | `/admin/global-roles/:roleId` | fresh + `global_roles.write` | Update global role |
| `DELETE` | `/admin/global-roles/:roleId` | fresh + `global_roles.write` | Delete global role |
| `PUT` | `/admin/users/:userId/global-roles` | fresh + `global_roles.assign` | Replace user's global role |

### Projects

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects` | fresh | List projects (global `projects.read` → all; else member projects) |
| `POST` | `/projects` | fresh + `projects.create` | Create project (seeds backlog + timeline views) |
| `GET` | `/projects/:projectId` | optional + read perm or public | Project detail |
| `PATCH` | `/projects/:projectId` | fresh + `projects.write` | Update name/description |
| `DELETE` | `/projects/:projectId` | fresh + `projects.delete` | Delete project |

### Project members and roles

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/members` | optional + read or public | List members |
| `POST` | `/projects/:projectId/members` | fresh + `project.members.write` | Add member |
| `GET` | `/projects/:projectId/members/me/permissions` | fresh | Caller's project permissions |
| `PATCH` | `/projects/:projectId/members/:memberId` | fresh + `project.members.write` | Change member role |
| `DELETE` | `/projects/:projectId/members/:memberId` | fresh + `project.members.write` | Remove member |
| `GET` | `/projects/:projectId/roles` | optional + read or public | List project roles |
| `POST` | `/projects/:projectId/roles` | fresh + `project.roles.write` | Create project role |
| `PATCH` | `/projects/:projectId/roles/:roleId` | fresh + `project.roles.write` | Update project role |
| `DELETE` | `/projects/:projectId/roles/:roleId` | fresh + `project.roles.write` | Delete project role |

### Task configuration

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/task-types` | optional + `tasks.read` or public | List task types |
| `POST` | `/projects/:projectId/task-types` | fresh + `tasks.write` | Create task type |
| `PATCH` | `/projects/:projectId/task-types/:typeId` | fresh + `tasks.write` | Update task type |
| `DELETE` | `/projects/:projectId/task-types/:typeId` | fresh + `tasks.write` | Delete task type |
| `PUT` | `/projects/:projectId/task-types/:typeId/set-default` | fresh + `tasks.write` | Set default task type |
| `GET` | `/projects/:projectId/task-statuses` | optional + `tasks.read` or public | List statuses |
| `POST` | `/projects/:projectId/task-statuses` | fresh + `tasks.write` | Create status |
| `PATCH` | `/projects/:projectId/task-statuses/:statusId` | fresh + `tasks.write` | Update status |
| `DELETE` | `/projects/:projectId/task-statuses/:statusId` | fresh + `tasks.write` | Delete status |
| `PUT` | `/projects/:projectId/task-statuses/:statusId/set-default` | fresh + `tasks.write` | Set default status |
| `GET` | `/projects/:projectId/custom-fields` | optional + `tasks.read` or public | List custom field definitions |
| `POST` | `/projects/:projectId/custom-fields` | fresh + `tasks.write` | Create custom field |
| `GET` | `/projects/:projectId/custom-fields/:fieldId` | optional + `tasks.read` or public | Get custom field |
| `PATCH` | `/projects/:projectId/custom-fields/:fieldId` | fresh + `tasks.write` | Update custom field |
| `DELETE` | `/projects/:projectId/custom-fields/:fieldId` | fresh + `tasks.write` | Delete custom field |

### Sprints

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/sprints` | optional + `sprints.read` or public | List sprints |
| `POST` | `/projects/:projectId/sprints` | fresh + `sprints.write` | Create sprint (seeds default views) |
| `GET` | `/projects/:projectId/sprints/:sprintId` | optional + `sprints.read` or public | Sprint detail |
| `PATCH` | `/projects/:projectId/sprints/:sprintId` | fresh + `sprints.write` | Update metadata/status |
| `DELETE` | `/projects/:projectId/sprints/:sprintId` | fresh + `sprints.write` | Delete sprint |
| `POST` | `/projects/:projectId/sprints/:sprintId/complete` | fresh + `sprints.write` | Complete sprint; move incomplete tasks |

### Interaction views

All view routes accept `?context=sprint|backlog|timeline` (plus `sprint_id` when `context=sprint`).

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/views` | optional + `sprints.read` or public | List views for context |
| `POST` | `/projects/:projectId/views` | fresh + `sprints.write` | Create view |
| `PUT` | `/projects/:projectId/views/positions` | fresh + `sprints.write` | Reorder all views in context |
| `GET` | `/projects/:projectId/views/:viewId` | optional + `sprints.read` or public | Get view |
| `PATCH` | `/projects/:projectId/views/:viewId` | fresh + `sprints.write` | Update view name/config |
| `DELETE` | `/projects/:projectId/views/:viewId` | fresh + `sprints.write` | Delete view |
| `GET` | `/projects/:projectId/views/:viewId/task-positions` | optional + `tasks.read` or public | List manual task positions |
| `PUT` | `/projects/:projectId/views/:viewId/task-positions` | fresh + `tasks.write` | Bulk-upsert positions |
| `PUT` | `/projects/:projectId/views/:viewId/task-positions/:taskId` | fresh + `tasks.write` | Set single task position |

### Tasks

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/tasks` | optional + `tasks.read` or public | Cursor-paginated task list with filters |
| `POST` | `/projects/:projectId/tasks` | fresh + `tasks.write` | Create task |
| `GET` | `/projects/:projectId/tasks/by-number/:taskNumber` | optional + `tasks.read` or public | Lookup by project task number |
| `GET` | `/projects/:projectId/tasks/:taskId` | optional + `tasks.read` or public | Task detail |
| `PATCH` | `/projects/:projectId/tasks/:taskId` | fresh + `tasks.write` | Partial update |
| `DELETE` | `/projects/:projectId/tasks/:taskId` | fresh + `tasks.write` | Soft-delete task |
| `POST` | `/projects/:projectId/tasks/:taskId/write-with-ai` | fresh + `tasks.write` | AI-assisted description writing |

Task `description` is a JSON array of BlockNote block objects, not a plain string. Nullable fields use three-state PATCH semantics: absent = unchanged, `null` = cleared, value = set.

### Task activities and comments

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/tasks/:taskId/activities` | optional + `tasks.read` or public | Activity feed |
| `POST` | `/projects/:projectId/tasks/:taskId/activities/comments` | fresh + `tasks.write` | Add comment |
| `PATCH` | `/projects/:projectId/tasks/:taskId/activities/comments/:commentId` | fresh + `tasks.write` | Edit comment |
| `DELETE` | `/projects/:projectId/tasks/:taskId/activities/comments/:commentId` | fresh + `tasks.write` | Delete comment |

See the dedicated task activity page for `activity_type` values, JSONB content shapes, and diff/revert semantics.

### Task attachments

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/tasks/:taskId/attachments` | optional + `tasks.read` or public | List attachments |
| `POST` | `/projects/:projectId/tasks/:taskId/attachments/initiate-upload` | fresh + `tasks.write` | Start presigned or multipart upload |
| `POST` | `/projects/:projectId/tasks/:taskId/attachments/complete-upload` | fresh + `tasks.write` | Finalize upload |
| `GET` | `/projects/:projectId/tasks/:taskId/attachments/:attachmentId/download-url` | optional + `tasks.read` or public | Presigned download URL |
| `DELETE` | `/projects/:projectId/tasks/:taskId/attachments/:attachmentId` | fresh + `tasks.write` | Delete attachment |

### Project documentation (`/docs`)

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/docs/folders` | optional + `docs.read` or public | List folders |
| `POST` | `/projects/:projectId/docs/folders` | fresh + `docs.write` | Create folder |
| `PATCH` | `/projects/:projectId/docs/folders/:folderId` | fresh + `docs.write` | Update folder |
| `DELETE` | `/projects/:projectId/docs/folders/:folderId` | fresh + `docs.write` | Delete folder |
| `GET` | `/projects/:projectId/docs` | optional + `docs.read` or public | List documents |
| `POST` | `/projects/:projectId/docs` | fresh + `docs.write` | Create document |
| `GET` | `/projects/:projectId/docs/:docId` | optional + `docs.read` or public | Document detail |
| `PATCH` | `/projects/:projectId/docs/:docId` | fresh + `docs.write` | Update document |
| `DELETE` | `/projects/:projectId/docs/:docId` | fresh + `docs.write` | Delete document |
| `GET` | `/projects/:projectId/docs/:docId/snapshots` | optional + `docs.read` or public | Version history |
| `GET` | `/projects/:projectId/docs/:docId/snapshots/:snapshotId` | optional + `docs.read` or public | Snapshot content |
| `GET` | `/projects/:projectId/docs/:docId/activities` | optional + `docs.read` or public | Doc activity log |
| `POST` | `/projects/:projectId/docs/:docId/comments` | fresh + `docs.write` | Add doc comment |
| `PATCH` | `/projects/:projectId/docs/:docId/comments/:commentId` | fresh + `docs.write` | Edit doc comment |
| `DELETE` | `/projects/:projectId/docs/:docId/comments/:commentId` | fresh + `docs.write` | Delete doc comment |
| `POST` | `/projects/:projectId/docs/:docId/files/initiate-upload` | fresh + `docs.write` | Start doc file upload |
| `POST` | `/projects/:projectId/docs/:docId/files/complete-upload` | fresh + `docs.write` | Complete doc file upload |
| `GET` | `/projects/:projectId/docs/:docId/files/:fileId/download-url` | optional + `docs.read` or public | Doc file download URL |
| `DELETE` | `/projects/:projectId/docs/:docId/files/:fileId` | fresh + `docs.write` | Delete doc file |

### AI agents (global)

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/agents/llm-models` | fresh | Verified LLM provider/model list |
| `GET` | `/agents/skill-templates` | fresh | Available agent skill templates |

### AI agents (project-scoped)

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/agents` | fresh + `agents.read` | List project agents |
| `POST` | `/projects/:projectId/agents` | fresh + `agents.write` | Create agent |
| `GET` | `/projects/:projectId/agents/:agentId` | fresh + `agents.read` | Agent detail |
| `PATCH` | `/projects/:projectId/agents/:agentId` | fresh + `agents.write` | Update agent |
| `DELETE` | `/projects/:projectId/agents/:agentId` | fresh + `agents.write` | Delete agent |
| `GET/POST/PATCH/DELETE` | `/projects/:projectId/agents/:agentId/mcp-servers[...]` | `agents.read` / `agents.write` | MCP server config |
| `GET/POST/PATCH/DELETE` | `/projects/:projectId/agents/:agentId/skills[...]` | `agents.read` / `agents.write` | Agent skills |
| `GET` | `/projects/:projectId/agents/:agentId/chat-sessions` | fresh + `agents.read` | List chat sessions |
| `POST` | `/projects/:projectId/agents/:agentId/chat-sessions` | fresh + `agents.read` | Start chat session |
| `POST` | `/projects/:projectId/agents/:agentId/chat-sessions/:sessionId/messages` | fresh + `agents.read` | Send chat message |

### Conversations

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/projects/:projectId/conversations` | fresh + `agents.read` | List agent conversations |
| `GET` | `/projects/:projectId/conversations/:conversationId` | fresh + `agents.read` | Conversation detail |
| `GET` | `/projects/:projectId/conversations/:conversationId/events` | fresh + `agents.read` | Conversation event stream |
| `POST` | `/projects/:projectId/conversations/:conversationId/stop` | fresh + `agents.write` | Stop running conversation |
| `POST` | `/projects/:projectId/conversations/:conversationId/messages` | fresh + `agents.read` | Send message to conversation |

### Plugins

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/plugins` | optional | List installed plugins |
| `*` | `/plugins/:pluginId/*path` | per manifest | Proxy to plugin WASM routes |
| `GET` | `/admin/plugins/marketplace` | fresh + `users.write` | List marketplace catalog |
| `POST` | `/admin/plugins/marketplace/install` | fresh + `users.write` | Install from marketplace |
| `POST` | `/admin/plugins` | fresh + `users.write` | Install plugin artifact |
| `PATCH` | `/admin/plugins/:pluginId` | fresh + `users.write` | Update plugin metadata |
| `POST` | `/admin/plugins/:pluginId/upgrade` | fresh + `users.write` | Upgrade marketplace plugin |
| `DELETE` | `/admin/plugins/:pluginId` | fresh + `users.write` | Uninstall plugin |
| `PATCH` | `/admin/plugin-extension-settings` | fresh + `users.write` | System-wide extension ordering/visibility |

## Common error codes

| `error_code` | HTTP | When |
|---|---|---|
| `AUTH_INVALID_CREDENTIALS` | 401 | Wrong username/password |
| `AUTH_MISSING_TOKEN` | 401 | No credentials on protected route |
| `AUTH_TOKEN_INVALID` | 401 | Expired or malformed JWT/API key |
| `AUTH_PASSWORD_CHANGE_REQUIRED` | 403 | Must call `PATCH /users/me/password` first |
| `FORBIDDEN` | 403 | Missing required permission |
| `API_KEY_REVOKED` / `API_KEY_EXPIRED` | 401 | API key no longer valid |
| `PROJECT_NOT_FOUND` | 404 | Unknown project UUID |
| `TASK_NOT_FOUND` | 404 | Unknown task UUID |
| `VIEW_IS_LAST_VIEW` | 409 | Cannot delete the only view in a context |
| `TASK_TYPE_IS_SYSTEM` | 403 | Mutation blocked on system type (Epic, Subtask) |
| `BAD_REQUEST` | 400 | Invalid body or query parameter |
| `INTERNAL_ERROR` | 500 | Unexpected server failure |

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Log in, create a project, generate an API key, and verify `/api/healthz`.
</Card>
<Card title="Task activity API" href="/task-activity-api">
Activity feed types, comment shapes, and mutation semantics.
</Card>
<Card title="Interaction views" href="/interaction-views">
Sprint, backlog, and timeline view model with `context` query parameters.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` for agent-mode API access with plugin tools.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
JWT TTLs, storage backends, and service environment variables.
</Card>
</CardGroup>

---

## 16. Task activity API

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

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/16-task-activity-api.md
- Generated: 2026-06-13T22:11:04.869Z

### 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>

---

## 17. Realtime events

> Socket.IO connection auth, Valkey pub/sub fan-out, project and conversation rooms, and domain event payloads including agent monitoring.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/17-realtime-events.md
- Generated: 2026-06-13T22:11:02.286Z

### Source Files

- `services/realtime/README.md`
- `services/realtime/src/server.ts`
- `services/realtime/src/session.ts`
- `services/realtime/src/subscriber.ts`
- `docs/ai-agent/realtime-events.md`
- `apps/web/src/lib/socket-client.ts`

---
title: "Realtime events"
description: "Socket.IO connection auth, Valkey pub/sub fan-out, project and conversation rooms, and domain event payloads including agent monitoring."
---

Paca pushes live updates through `services/realtime`: a Bun + Socket.IO server that subscribes to the Valkey Pub/Sub channel `paca.events`, routes messages into permission-scoped rooms, and emits them to authenticated web clients. Producers include `services/api` (tasks, docs, notifications, plugins) and `services/ai-agent` (conversation lifecycle and monitoring signals). The nginx gateway exposes the service at `/ws/socket.io`.

## Architecture

```mermaid
sequenceDiagram
    participant API as services/api
    participant Agent as services/ai-agent
    participant Valkey as Valkey (paca.events)
    participant RT as services/realtime
    participant Web as Web client

    API->>Valkey: PUBLISH {type, payload}
    Agent->>Valkey: PUBLISH {type, payload}
    RT->>Valkey: SUBSCRIBE paca.events
    Valkey-->>RT: message
    RT->>RT: Route to Socket.IO room
    RT-->>Web: emit("event" | "notification")
```

| Component | Role |
|-----------|------|
| `paca.events` | Single Pub/Sub channel for immediate fan-out (constant `events.ChannelRealtime` in the API) |
| `services/realtime` | Auth middleware, room membership, Pub/Sub subscriber, `/healthz` |
| Valkey streams (`paca.task_activities`, `paca:agent:events`, …) | Durable pipelines for DB consumers; **not** read by the realtime service |
| nginx gateway | Proxies `/ws/` → `realtime:3001`, stripping the `/ws` prefix so the service sees `/socket.io/` |

API and ai-agent both publish to `paca.events`. The ai-agent also appends full conversation events to the `paca:agent:events` stream for durability; WebSocket delivery uses the direct Pub/Sub path (`publish_realtime` in `services/ai-agent`).

## Connect and authenticate

Clients connect through the gateway, not directly to port 3001.

<ParamField body="path" type="string" default="/ws/socket.io">
Socket.IO path. The gateway strips `/ws/` before forwarding.
</ParamField>

<ParamField body="withCredentials" type="boolean" default="true">
Required for browser clients so the HttpOnly `access_token` cookie is sent.
</ParamField>

<ParamField body="handshake.auth.token" type="string">
Bearer access JWT for programmatic clients. Checked before the cookie.
</ParamField>

<RequestExample>

```ts title="Browser client"
import { io } from "socket.io-client";

const socket = io(window.location.origin, {
  path: "/ws/socket.io",
  withCredentials: true,
  transports: ["websocket", "polling"],
});
```

</RequestExample>

<RequestExample>

```ts title="Programmatic client"
const socket = io("https://your-paca-host", {
  path: "/ws/socket.io",
  auth: { token: "<access-jwt>" },
});
```

</RequestExample>

### Auth flow

The realtime service **does not verify JWTs locally**. On every connection:

1. Extract token from `handshake.auth.token`, or fall back to the `access_token` cookie.
2. Call `GET /api/v1/users/me/global-permissions` with `Authorization: Bearer <token>`.
3. On HTTP 200, decode identity claims (`sub`, `username`, `role`) from the JWT payload and accept the connection.
4. On any non-200 response, reject with `invalid or expired token`.

A Valkey session record (`realtime:session:<socketId>`, 20-minute TTL) stores user identity and cached project permissions for auditing. The raw token stays in memory only.

Socket.IO connection state recovery is enabled with a 2-minute max disconnection window.

## Room model

Rooms gate **which** events a socket receives. Permission checks happen once at join time; the Pub/Sub subscriber performs no per-message authorization.

### Auto-joined rooms

On connect, every authenticated socket joins:

```
user:<userId>:notifications
```

This room receives `notification.*` events without an explicit client `join`.

### Project rooms

When a client emits `join` with `{ projectId }`, the server:

1. Calls `GET /api/v1/projects/:projectId/members/me/permissions`.
2. Silently ignores the request if the user is not a project member (404).
3. Joins only the namespace rooms the user is permitted to see.

| Room | Required permission | Event prefixes |
|------|---------------------|----------------|
| `project:<projectId>:tasks` | `tasks.read` (or `tasks.*`, `*`) | `task.*`, `github.*`, `agent.*` |
| `project:<projectId>:docs` | `docs.read` (or `docs.*`, `*`) | `doc.*` |

Wildcard permissions are honored: `*` grants everything; `tasks.*` covers `tasks.read`.

<Steps>
<Step title="Join a project">

```ts
socket.emit("join", { projectId: "<uuid>" });
```

The server fetches permissions and places the socket into zero, one, or both namespace rooms.

</Step>
<Step title="Leave a project">

```ts
socket.emit("leave", { projectId: "<uuid>" });
```

Removes the socket from both `project:<id>:tasks` and `project:<id>:docs`.

</Step>
<Step title="Verify delivery">

Listen for the unified `event` channel (project-scoped) or `notification` (user-scoped). See [Listen for events](#listen-for-events).

</Step>
</Steps>

### Conversation granularity

There is **no** `conversation:<conversationId>` Socket.IO room. Agent monitoring events are delivered to `project:<projectId>:tasks` like other `agent.*` types. Clients filter on `payload.conversation_id` when they need conversation-level updates.

## Listen for events

Paca uses two Socket.IO event names on the wire:

| Socket.IO event | When emitted | Message shape |
|-----------------|--------------|---------------|
| `event` | Project-scoped domain events (`task.*`, `doc.*`, `agent.*`, `github.*`, plugin topics) | `{ type: string, payload: object }` |
| `notification` | User-scoped `notification.*` events | `{ type: string, payload: object }` |

<ResponseExample>

```json title="Project event envelope"
{
  "type": "task.comment.added",
  "payload": {
    "id": "uuid",
    "task_id": "uuid",
    "project_id": "uuid",
    "activity_type": "comment",
    "content": "...",
    "actor_id": "uuid",
    "created_at": "2026-06-13T10:00:00Z",
    "updated_at": "2026-06-13T10:00:00Z"
  }
}
```

</ResponseExample>

<ResponseExample>

```json title="Notification envelope"
{
  "type": "notification.created",
  "payload": {
    "id": "uuid",
    "recipient_user_id": "uuid",
    "actor_member_id": "uuid",
    "type": "assigned",
    "task_id": "uuid",
    "project_id": "uuid",
    "created_at": "2026-06-13T10:00:00Z"
  }
}
```

</ResponseExample>

The Pub/Sub subscriber drops messages that lack a routable payload, lack `project_id` (for project events), or use an unrecognized type prefix. Malformed JSON is logged and skipped.

### Web app integration pattern

The web app uses a singleton socket (`connectSocket` / `disconnectSocket` in `apps/web/src/lib/socket-client.ts`):

- **Authenticated layout** — connects on login, listens for `notification`, disconnects on logout.
- **Project pages** — `useProjectRealtime(projectId)` emits `join` on mount and maps incoming `event` types to React Query invalidations.

```ts
socket.on("event", ({ type, payload }) => {
  if (type.startsWith("task.")) {
    invalidate(["projects", projectId, "tasks"]);
  }
  if (type.startsWith("agent.")) {
    invalidate(["projects", projectId, "conversations"]);
    if (payload.conversation_id) {
      invalidate(["projects", projectId, "conversations", payload.conversation_id, "events"]);
    }
  }
});
```

## Event producers and type catalog

### Task events (`task.*`)

Published by `services/api` when task activities are created or comments mutate.

| Type | Origin | Notes |
|------|--------|-------|
| `task.created` | Activity stream + Pub/Sub | System activity; persisted via `paca.task_activities` consumer |
| `task.updated` | Activity stream + Pub/Sub | `content` holds field change JSON |
| `task.deleted` | Activity stream + Pub/Sub | |
| `task.attachment.added` | Activity stream + Pub/Sub | |
| `task.attachment.removed` | Activity stream + Pub/Sub | |
| `task.comment.added` | Pub/Sub only | Writes DB directly; not appended to activity stream |
| `task.comment.updated` | Pub/Sub only | |
| `task.comment.deleted` | Pub/Sub only | |
| `agent.session.started` | Activity stream + Pub/Sub | Recorded as a task activity when an agent session begins (e.g. description-write trigger) |

Standard activity payload fields:

<ResponseField name="id" type="uuid">Activity entry ID.</ResponseField>
<ResponseField name="task_id" type="uuid">Affected task.</ResponseField>
<ResponseField name="project_id" type="uuid">Required for routing.</ResponseField>
<ResponseField name="activity_type" type="string">Semantic activity type (may match `type`).</ResponseField>
<ResponseField name="content" type="string">JSON-encoded activity body.</ResponseField>
<ResponseField name="actor_id" type="uuid">Project member ID of the actor, when present.</ResponseField>
<ResponseField name="created_at" type="string">RFC 3339 timestamp.</ResponseField>
<ResponseField name="updated_at" type="string">RFC 3339 timestamp.</ResponseField>

### Doc events (`doc.*`)

Published by `services/api` for document and folder activities.

| Type | Notes |
|------|-------|
| `doc.created`, `doc.updated`, `doc.deleted`, `doc.moved` | Stream + Pub/Sub via `paca.doc_activities` |
| `doc.folder.created`, `doc.folder.updated`, `doc.folder.deleted` | Stream + Pub/Sub |
| `doc.comment.added`, `doc.comment.updated`, `doc.comment.deleted` | Pub/Sub only |

Payload shape mirrors task activities with `project_id` for routing.

### GitHub plugin events (`github.*`)

Plugin-emitted events (for example `github.branch.linked`, `github.pr.linked`) route to `project:<id>:tasks` because they are task-scoped. Payloads include `task_id` and `project_id`.

### Notification events (`notification.*`)

| Type | Routing |
|------|---------|
| `notification.created` | `user:<recipient_user_id>:notifications` via separate `notification` Socket.IO event |

Notification `type` values in the payload: `assigned`, `mentioned`.

### Plugin-emitted events

WASM plugins call `paca.event_emit(topic, payload)` which publishes directly to `paca.events`:

```json
{ "type": "<plugin-topic>", "source": "<plugin-name>", "payload": { ... } }
```

The subscriber reads `payload` (or legacy `data`) for routing. Plugin topics must include `project_id` in the payload and use a recognized prefix (`task.`, `doc.`, `github.`, `agent.`) to reach clients.

## Agent monitoring events

`services/ai-agent` publishes lightweight realtime signals to `paca.events` via `publish_realtime`, while persisting full event bodies to PostgreSQL and the `paca:agent:events` stream.

### Lifecycle events

| Type | When published |
|------|----------------|
| `agent.conversation.finished` | OpenHands run completes successfully |
| `agent.conversation.failed` | Run errors or unhandled exception |

Both carry at minimum:

<ResponseField name="project_id" type="uuid">Routes to `project:<id>:tasks`.</ResponseField>
<ResponseField name="conversation_id" type="uuid">Filter key for monitoring UIs.</ResponseField>

Constants for `agent.conversation.started`, `agent.conversation.paused`, `agent.conversation.resumed`, and `agent.conversation.stopped` exist in the API event catalog; the ai-agent executor currently publishes `finished` and `failed` for terminal states.

### Per-event monitoring signals

During a conversation, each persisted OpenHands SDK event triggers a realtime notification:

```
agent.<sdk_event_type_lowercase>
```

Examples: `agent.cmddrunaction`, `agent.filewriteaction`, `agent.messageevent`.

The Pub/Sub payload is intentionally minimal:

```json
{
  "type": "agent.cmddrunaction",
  "payload": {
    "project_id": "uuid",
    "conversation_id": "uuid"
  }
}
```

Full event bodies (command text, file paths, message content) live in the REST conversation-events API. The monitoring panel (`conversation-view.tsx`) loads events via HTTP and refreshes when realtime invalidates the `["projects", projectId, "conversations", conversationId, "events"]` query key.

SDK events skipped from both DB and realtime include `StreamingDeltaEvent`, `ConversationStateUpdateEvent`, `SystemPromptEvent`, and `ConversationErrorEvent`. Agent `MessageEvent` entries from the event callback are skipped because streaming token callbacks produce richer `agent.messageevent` rows.

### Agent trigger pipeline (not realtime)

Agent **triggers** (`agent.task_assigned`, `agent.comment_mention`, `agent.chat_message`, `agent.description_write`, `agent.stop`) flow through the `paca:agent:triggers` Valkey stream consumed by ai-agent workers. These are control-plane messages, not Socket.IO events.

<AccordionGroup>
<Accordion title="Monitoring a single conversation">

1. Ensure the client has joined the project (`socket.emit("join", { projectId })`).
2. Listen on `event` and filter `type.startsWith("agent.")`.
3. Match `payload.conversation_id` to the active conversation.
4. Re-fetch `GET /api/v1/projects/:projectId/conversations/:conversationId/events` (paginated) on each matching event.

There is no separate conversation room subscription.

</Accordion>
<Accordion title="Common OpenHands event_type values in stored events">

| Stored `event_type` | Typical source |
|---------------------|----------------|
| `MessageEvent` | User or agent conversational message |
| `CmdRunAction` | Shell command |
| `CmdOutputObservation` | Command output |
| `FileReadAction`, `FileWriteAction`, `FileEditAction` | File operations |
| `BrowseURLAction` | Web fetch |
| `AgentThinkAction` | Internal reasoning |
| `AgentFinishAction` | Run complete |
| `ErrorObservation` | Tool error |

Realtime `type` values lowercase the SDK class name (e.g. `CmdRunAction` → `agent.cmddrunaction`).

</Accordion>
</AccordionGroup>

## Configuration

Environment variables for `services/realtime`:

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `PORT` | No | `3001` | HTTP + Socket.IO listen port |
| `REDIS_URL` | **Yes** | — | Same Valkey instance as the API |
| `API_URL` | **Yes** | — | Internal API base URL for auth and permissions (not the public gateway) |
| `CORS_ORIGINS` | No | `http://localhost:3000` | Comma-separated browser origins |
| `LOG_LEVEL` | No | `info` | Pino log level |
| `NODE_ENV` | No | `development` | `production` enables JSON logging |

Gateway WebSocket proxy (`deploy/nginx/gateway.conf`): `/ws/` → `realtime:3001` with 3600s read timeout for long-lived connections.

## Troubleshooting

| Symptom | Likely cause | Check |
|---------|--------------|-------|
| Connection refused immediately | Missing or invalid token | Confirm `access_token` cookie or `auth.token`; verify API returns 200 on `/users/me/global-permissions` |
| Connected but no project events | Not joined or missing permission | Emit `join` with valid `projectId`; confirm `tasks.read` / `docs.read` |
| Agent events missing | Not in tasks room | Requires `tasks.read`; join project first |
| Notifications missing | Wrong Socket.IO listener | Listen on `notification`, not `event` |
| Events stop after ~15 min | Access token expired | Re-authenticate; reconnect socket with fresh token |
| No events at all | Valkey disconnect | Check realtime logs for subscriber errors; confirm `REDIS_URL` matches API |
| Malformed events in logs | Bad Pub/Sub JSON | Inspect publisher; subscriber skips unparseable messages |

Health check: `GET /healthz` on the realtime service returns `{"status":"ok"}`.

## Related pages

<Card href="/platform-architecture" title="Platform architecture" icon="layers">
Monorepo service boundaries, Valkey decoupling, and nginx routing between web, API, realtime, and storage.
</Card>

<Card href="/ai-agents" title="AI agents" icon="bot">
Agent triggers, OpenHands conversation lifecycle, Valkey streams, and how agents participate as project teammates.
</Card>

<Card href="/task-activity-api" title="Task activity API" icon="list">
Activity types, JSONB content shapes, and REST endpoints that complement realtime task events.
</Card>

<Card href="/configuration-reference" title="Configuration reference" icon="settings">
Environment variables across API, web gateway, realtime, and ai-agent services.
</Card>

<Card href="/troubleshooting" title="Troubleshooting" icon="life-buoy">
Stack-wide failures including Valkey connectivity, auth cookies, and health checks.
</Card>

---

## 18. MCP tools reference

> All @paca-ai/paca-mcp tool names by category, input schemas, agent-mode constraints, and dynamically loaded plugin tools.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/18-mcp-tools-reference.md
- Generated: 2026-06-13T22:10:55.304Z

### Source Files

- `apps/mcp/ALL_TOOLS.md`
- `apps/mcp/src/tools/index.ts`
- `apps/mcp/src/tools/task-tools.ts`
- `apps/mcp/src/tools/project-tools.ts`
- `apps/mcp/src/api/client.ts`
- `apps/mcp/src/permissions.ts`

---
title: "MCP tools reference"
description: "All @paca-ai/paca-mcp tool names by category, input schemas, agent-mode constraints, and dynamically loaded plugin tools."
---

The `@paca-ai/paca-mcp` package (`apps/mcp`) exposes **62 core MCP tools** over stdio, backed by the Paca REST API at `/api/v1`. At startup the server fetches agent or user permissions, filters core tools, loads enabled plugin modules from `GET /api/v1/plugins`, and merges plugin tool definitions into a single flat `tools/list` response. Tool calls route to the plugin registry first, then to core handlers in `apps/mcp/src/tools/index.ts`.

## Runtime configuration

| Variable | Required | Default | Effect |
|---|---|---|---|
| `PACA_API_KEY` | Yes | — | Sent as `X-API-Key` on every API request |
| `PACA_API_URL` | No | `http://localhost:8080` | API base URL |
| `PACA_GATEWAY_URL` | No | `PACA_API_URL` | Resolves relative plugin `remoteEntryUrl` paths (nginx gateway in Docker) |
| `PACA_AGENT_ID` | No | — | Agent UUID; sent as `X-Agent-ID`; requires `PACA_PROJECT_ID` |
| `PACA_PROJECT_ID` | When `PACA_AGENT_ID` set | — | Locks tool calls to one project; drives permission fetch |

<ParamField body="PACA_API_KEY" type="string" required>
API key for authentication. In agent mode, use the server-configured global `AGENT_API_KEY`.
</ParamField>

<ParamField body="PACA_AGENT_ID" type="string">
Agent UUID to impersonate. Startup fails if set without `PACA_PROJECT_ID`.
</ParamField>

<ParamField body="PACA_PROJECT_ID" type="string">
Project UUID for single-project mode. When set with agent mode, every tool argument `projectId` must match this value or the call returns an error.
</ParamField>

## Operating modes

| Mode | Env vars | Permission source | Core tool visibility |
|---|---|---|---|
| Personal (legacy) | `PACA_API_KEY` only | None — all core tools listed | Full catalog |
| User single-project | `PACA_API_KEY` + `PACA_PROJECT_ID` | `GET /api/v1/projects/{id}/members/me/permissions` + optional global perms | Filtered to project permissions |
| User global | `PACA_API_KEY` (no project) | `GET /api/v1/users/me/global-permissions` | Global tools only; project-scoped tools hidden |
| Agent single-project | `PACA_API_KEY` + `PACA_AGENT_ID` + `PACA_PROJECT_ID` | Agent permissions in the bound project | Filtered; `projectId` enforced |

<Warning>
In agent mode, if a tool call passes `projectId` that differs from `PACA_PROJECT_ID`, the server returns `Error: projectId must be {id} in single-project agent mode` with `isError: true` — before any API request is made.
</Warning>

<Info>
Plugin tools are **not** filtered at the MCP list-tools layer. Authorization for plugin routes is enforced by the API at `/api/v1/plugins/{pluginId}/…`.
</Info>

## Tool call flow

```mermaid
sequenceDiagram
    participant Client as MCP client
    participant Server as paca-mcp server
    participant Plugins as PluginRegistry
    participant Core as Core handlers
    participant API as Paca API

    Client->>Server: tools/call { name, arguments }
    alt projectId mismatch in agent mode
        Server-->>Client: isError text response
    else plugin owns tool name
        Server->>Plugins: handleToolCall
        Plugins->>API: /api/v1/plugins/{pluginId}/…
        API-->>Plugins: result
        Plugins-->>Client: text content
    else core tool
        Server->>Core: handleToolCall
        Core->>API: /api/v1/…
        API-->>Core: SuccessEnvelope data
        Core-->>Client: formatted text content
    end
```

## Shared input conventions

<ParamField body="projectId" type="string" required>
Technical project UUID. Use `list_projects` to resolve IDs. **Do not** pass the project display name.
</ParamField>

- **Markdown ↔ BlockNote**: `description` on tasks, `content` on docs and comments is accepted as Markdown on write and returned as Markdown on read.
- **Pagination**: `list_tasks` supports `cursor` (opaque, from `next_cursor`) and `pageSize` (1–200, default 20).
- **Dates**: ISO 8601 strings (`startDate`, `endDate`, `dueDate`).
- **Path-based docs**: Filesystem doc tools use `/`-separated paths (e.g. `Architecture/API Design`); folder segments are created automatically on write.

## Response format

All tools return MCP `content` arrays with `type: "text"`. Successful operations return human-readable formatted text (lists, detail blocks, success messages). Failures return:

```json
{
  "content": [{ "type": "text", "text": "Error: …" }],
  "isError": true
}
```

Zod validation errors and API `4xx`/`5xx` responses surface through the same envelope.

## Permission keys

Core tools map to permission keys in `apps/mcp/src/permissions.ts`. Wildcards (`*`, `tasks.*`) are supported at global and project scope.

| Permission key | Grants |
|---|---|
| `projects.read` | `list_projects`, `get_project` |
| `projects.create` | `create_project` |
| `projects.write` | `update_project` |
| `projects.delete` | `delete_project` |
| `tasks.read` | Task, type, status, view, custom field, attachment, and activity **read** tools |
| `tasks.write` | Task, type, status, view, custom field, attachment, and activity **write** tools |
| `sprints.read` | `list_sprints`, `get_sprint` |
| `sprints.write` | `create_sprint`, `update_sprint`, `delete_sprint`, `complete_sprint` |
| `docs.read` | `list_docs`, `read_doc` |
| `docs.write` | `write_doc`, `delete_doc`, `move_doc` |
| `project.members.read` | `list_project_members`, `get_my_project_permissions` |
| `project.members.write` | `add_project_member`, `update_project_member_role`, `remove_project_member` |
| `project.roles.read` | `list_project_roles` |
| `project.roles.write` | `create_project_role`, `update_project_role`, `delete_project_role` |

<Tip>
Call `get_my_project_permissions` to inspect the effective permission set for the authenticated user or agent in a project.
</Tip>

## Core tools by category

### Projects (5)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_projects` | List accessible projects | — | — |
| `get_project` | Get one project | `projectId` | — |
| `create_project` | Create project | `name` | `description` |
| `update_project` | Update project | `projectId` | `name`, `description` |
| `delete_project` | Delete project | `projectId` | — |

### Tasks (6)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_tasks` | List tasks (cursor pagination) | `projectId` | `cursor`, `pageSize` |
| `get_task` | Full task detail (subtasks, attachments, activities) | `projectId`, `taskId` | — |
| `get_task_by_number` | Task detail by number | `projectId`, `taskNumber` | — |
| `create_task` | Create task | `projectId`, `title` | `description`, `statusId`, `typeId`, `sprintId`, `assigneeId`, `parentTaskId`, `importance`, `storyPoints`, `tags`, `startDate`, `dueDate` |
| `update_task` | Update task | `projectId`, `taskId` | Same as create; `parentTaskId` nullable to clear |
| `delete_task` | Delete task | `projectId`, `taskId` | — |

### Sprints (6)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_sprints` | List sprints | `projectId` | — |
| `get_sprint` | Get sprint | `projectId`, `sprintId` | — |
| `create_sprint` | Create sprint | `projectId`, `name`, `startDate`, `endDate` | — |
| `update_sprint` | Update sprint | `projectId`, `sprintId` | `name`, `startDate`, `endDate` |
| `delete_sprint` | Delete sprint | `projectId`, `sprintId` | — |
| `complete_sprint` | Mark sprint completed | `projectId`, `sprintId` | — |

### Documentation — filesystem model (5)

Path-oriented tools over folders and documents. Prefer these over local files for project documentation.

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_docs` | Tree listing of folders and docs | `projectId` | `path` (folder root) |
| `read_doc` | Read doc as Markdown | `projectId`, `path` | — |
| `write_doc` | Create or update doc at path | `projectId`, `path`, `content` | — |
| `delete_doc` | Delete doc or folder (recursive) | `projectId`, `path` | — |
| `move_doc` | Move or rename doc/folder | `projectId`, `sourcePath`, `destPath` | — |

### Project members (5)

| Tool | Description | Required inputs |
|---|---|---|
| `list_project_members` | List members | `projectId` |
| `add_project_member` | Add member | `projectId`, `userId`, `roleId` |
| `get_my_project_permissions` | Current user/agent permissions | `projectId` |
| `update_project_member_role` | Change member role | `projectId`, `userId`, `roleId` |
| `remove_project_member` | Remove member | `projectId`, `userId` |

### Project roles (4)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_project_roles` | List roles | `projectId` | — |
| `create_project_role` | Create role | `projectId`, `name`, `permissions` | `description` |
| `update_project_role` | Update role | `projectId`, `roleId` | `name`, `description`, `permissions` |
| `delete_project_role` | Delete role | `projectId`, `roleId` | — |

### Task types (5)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_task_types` | List types | `projectId` | — |
| `create_task_type` | Create type | `projectId`, `name` | `icon`, `color`, `description` |
| `update_task_type` | Update type | `projectId`, `typeId` | `name`, `icon`, `color`, `description` |
| `delete_task_type` | Delete type | `projectId`, `typeId` | — |
| `set_default_task_type` | Set project default | `projectId`, `typeId` | — |

### Task statuses (5)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_task_statuses` | List statuses | `projectId` | — |
| `create_task_status` | Create status | `projectId`, `name`, `category` | `color` |
| `update_task_status` | Update status | `projectId`, `statusId` | `name`, `color`, `category`, `position` |
| `delete_task_status` | Delete status | `projectId`, `statusId` | — |
| `set_default_task_status` | Set project default | `projectId`, `statusId` | — |

`category` values: `backlog`, `refinement`, `ready`, `todo`, `inprogress`, `done`.

### Views and task positioning (9)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_views` | List views | `projectId` | `context` (`sprint` \| `backlog` \| `timeline`), `sprintId` |
| `create_view` | Create view | `projectId`, `name`, `context`, `viewType` | `sprintId` |
| `reorder_views` | Reorder views | `projectId`, `viewIds` | — |
| `get_view` | Get view | `projectId`, `viewId` | — |
| `update_view` | Update view | `projectId`, `viewId` | `name`, `context`, `viewType`, `sprintId` |
| `delete_view` | Delete view | `projectId`, `viewId` | — |
| `list_task_positions` | Positions in a view | `projectId`, `viewId` | — |
| `bulk_move_tasks` | Move task in view | `projectId`, `viewId`, `taskId`, `targetViewId` | `targetStatusId`, `targetPosition` |
| `move_task` | Move task in view | `projectId`, `viewId`, `taskId`, `targetViewId` | `targetStatusId`, `targetPosition` |

`viewType` values: `table`, `board`, `roadmap`.

### Custom fields (5)

| Tool | Description | Required inputs | Optional inputs |
|---|---|---|---|
| `list_custom_fields` | List field definitions | `projectId` | — |
| `create_custom_field` | Create field | `projectId`, `fieldKey`, `displayName`, `fieldType` | `options`, `isRequired` |
| `get_custom_field` | Get field | `projectId`, `fieldId` | — |
| `update_custom_field` | Update field | `projectId`, `fieldId` | `displayName`, `fieldType`, `options`, `isRequired` |
| `delete_custom_field` | Delete field | `projectId`, `fieldId` | — |

`fieldType` values: `text`, `number`, `date`, `select`, `multi_select`, `boolean`, `url`.

### Attachments (3)

| Tool | Description | Required inputs |
|---|---|---|
| `list_task_attachments` | List task attachments | `projectId`, `taskId` |
| `get_attachment_download_url` | Presigned download URL | `projectId`, `taskId`, `attachmentId` |
| `delete_task_attachment` | Delete attachment | `projectId`, `taskId`, `attachmentId` |

<Note>
There is no MCP upload tool. File uploads use the REST two-step initiate → complete flow documented on the REST API reference page.
</Note>

### Task activities and comments (4)

| Tool | Description | Required inputs |
|---|---|---|
| `list_task_activities` | Activity feed for a task | `projectId`, `taskId` |
| `add_task_comment` | Add comment | `projectId`, `taskId`, `content` |
| `update_task_comment` | Update comment | `projectId`, `taskId`, `commentId`, `content` |
| `delete_task_comment` | Delete comment | `projectId`, `taskId`, `commentId` |

## Example tool calls

<RequestExample>

```json
{
  "name": "create_task",
  "arguments": {
    "projectId": "550e8400-e29b-41d4-a716-446655440000",
    "title": "Implement MCP filtering",
    "description": "## Scope\n\nFilter tools by agent permissions at startup.",
    "typeId": "660e8400-e29b-41d4-a716-446655440001"
  }
}
```

</RequestExample>

<ResponseExample>

```text
Task created successfully:

Task #42: Implement MCP filtering
ID: 770e8400-e29b-41d4-a716-446655440002
Status: Backlog
...
```

</ResponseExample>

<RequestExample>

```json
{
  "name": "list_tasks",
  "arguments": {
    "projectId": "550e8400-e29b-41d4-a716-446655440000",
    "pageSize": 10,
    "cursor": "eyJjcmVhdGVkX2F0Ijoi..."
  }
}
```

</ResponseExample>

## Plugin-contributed tools

Enabled plugins with `manifest.mcp.remoteEntryUrl` in `plugin.json` load at server startup via dynamic ESM `import()`. Their tools appear in the same `tools/list` response as core tools.

### Loading behavior

1. `GET /api/v1/plugins` with `PACA_API_KEY`
2. Skip `enabled: false` and plugins without `mcp.remoteEntryUrl`
3. Import each entry module; validate default export shape (`tools: Tool[]`, `handleToolCall: function`)
4. Register tool name → plugin ID mapping; skip duplicate names with a warning
5. Failed plugins log to stderr; core tools still start

### Plugin entry contract

```ts
interface PluginMCPEntry {
  tools: Tool[];
  handleToolCall(
    name: string,
    args: Record<string, unknown>,
    context: { pluginId: string; baseURL: string; apiKey: string }
  ): Promise<{ content: Array<{ type: string }>; isError?: boolean }>;
}
```

Build plugin MCP modules with `@paca-ai/plugin-sdk-mcp` (`PluginAPIClient`, `textResult`, `errorResult`). Plugin handlers call scoped routes under `/api/v1/plugins/{pluginId}/…`.

### Naming rules

| Rule | Detail |
|---|---|
| Uniqueness | Tool names must be unique across all loaded plugins |
| Pattern | `[a-z][a-z0-9_]*` |
| Prefix | Use a plugin-specific prefix (e.g. `checklist_list_items`, `bdd_list_scenarios`) |

### URL resolution

| `remoteEntryUrl` form | Resolution |
|---|---|
| Absolute `https://…` | Imported directly |
| Relative `/plugins-mcp/{id}/mcp.js` | Resolved against `PACA_GATEWAY_URL` or `PACA_API_URL` |
| `http://localhost:…` | Allowed for local development only |

<Warning>
Plugin MCP modules run in the same Node.js process as the MCP server without sandboxing. Only install plugins from trusted sources.
</Warning>

### Discovering plugin tools

Plugin tool names are not fixed in core — they depend on installed marketplace plugins. After connecting the MCP server, call `tools/list` (or use your client's tool picker) to see the merged catalog. Installed plugin manifests and `remoteEntryUrl` values are visible via the admin UI or `GET /api/v1/plugins`.

## Tool inventory summary

| Category | Count |
|---|---|
| Projects | 5 |
| Tasks | 6 |
| Sprints | 6 |
| Documentation (filesystem) | 5 |
| Project members | 5 |
| Project roles | 4 |
| Task types | 5 |
| Task statuses | 5 |
| Views | 9 |
| Custom fields | 5 |
| Attachments | 3 |
| Task activities | 4 |
| **Core total** | **62** |
| Plugin tools | Variable (per installed plugins) |

## Related pages

<CardGroup>
<Card title="Connect MCP server" href="/connect-mcp">
Configure `npx @paca-ai/paca-mcp`, environment variables, and client-specific setup.
</Card>
<Card title="Plugin system" href="/plugin-system">
WASM backend, frontend extensions, MCP plugin tools, and capability permissions.
</Card>
<Card title="Plugin SDK reference" href="/plugin-sdk-reference">
`@paca-ai/plugin-sdk-mcp` types, `PluginAPIClient`, and extension points.
</Card>
<Card title="REST API reference" href="/rest-api">
Underlying `/api/v1` endpoints, auth, and response envelopes.
</Card>
<Card title="Configure AI agents" href="/configure-ai-agents">
Create agents, assign roles, and wire MCP config for project teammates.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
MCP connection errors, permission gaps, and gateway URL misconfiguration.
</Card>
</CardGroup>

---

## 19. Configuration reference

> Environment variables across API, web gateway, realtime, and ai-agent: JWT, storage, plugins, encryption, cache TTLs, and internal keys.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/19-configuration-reference.md
- Generated: 2026-06-13T22:12:44.535Z

### Source Files

- `deploy/.env.production.example`
- `deploy/.env.dev.example`
- `services/api/.env.example`
- `services/ai-agent/.env.example`
- `services/realtime/.env.example`
- `apps/mcp/.env.example`

---
title: "Configuration reference"
description: "Environment variables across API, web gateway, realtime, and ai-agent: JWT, storage, plugins, encryption, cache TTLs, and internal keys."
---

Paca reads configuration from environment variables at process startup. Docker Compose stacks pass a single deploy-level `.env` file into each service; host-side development uses per-service `.env` files copied from the `*.env.example` templates. The API validates required keys before listening; the realtime and ai-agent services fail fast on missing values.

## Configuration sources

| Layer | File | Used by |
| --- | --- | --- |
| Production / standalone deploy | `deploy/.env.production.example` → `.env` | `deploy/docker-compose.prod.yml` |
| Local dev stack | `deploy/.env.dev.example` → `deploy/.env.dev` | `deploy/docker-compose.dev.yml` |
| API (host run) | `services/api/.env.example` | `go run` / `air` on the API |
| Realtime (host run) | `services/realtime/.env.example` | `bun run` on realtime |
| AI agent (host run) | `services/ai-agent/.env.example` | Python worker on the host |
| MCP client | `apps/mcp/.env.example` | `@paca-ai/paca-mcp` via `npx` |

The install script (`scripts/install.sh`) generates a production `.env` with random secrets for `JWT_SECRET`, `AGENT_API_KEY`, and `INTERNAL_API_KEY`.

## Cross-service key relationships

Three pre-shared secrets tie services together. Mismatches cause auth failures that are easy to misdiagnose.

```mermaid
flowchart LR
  subgraph api["API"]
    AGENT_API_KEY
    ENCRYPTION_KEY
  end
  subgraph agent["AI agent"]
    PACA_API_KEY
    INTERNAL_API_KEY
    ENCRYPTION_KEY_A["ENCRYPTION_KEY"]
  end
  subgraph mcp["Built-in MCP (in agent container)"]
    PACA_API_KEY_MCP["PACA_API_KEY"]
    PACA_GATEWAY_URL
  end
  AGENT_API_KEY -->|"must equal"| PACA_API_KEY
  PACA_API_KEY -->|"injected as"| PACA_API_KEY_MCP
  ENCRYPTION_KEY -->|"must equal"| ENCRYPTION_KEY_A
```

| Pair | API / compose name | AI agent name | Purpose |
| --- | --- | --- | --- |
| Agent MCP auth | `AGENT_API_KEY` | `PACA_API_KEY` | Static `X-API-Key` accepted by the API as the built-in agent bot; injected into the hardcoded `paca` MCP server inside agent conversations |
| Encryption | `ENCRYPTION_KEY` | `ENCRYPTION_KEY` | AES-256-GCM for plugin secrets and encrypted LLM API keys in Postgres |
| Internal HTTP | — | `INTERNAL_API_KEY` | Protects ai-agent internal conversation endpoints via `X-Internal-Token` header (not exposed through the gateway) |

<AccordionGroup>
<Accordion title="Generate production secrets">

```bash
# JWT signing secret (API)
openssl rand -hex 32

# Agent ↔ API pre-shared key (set AGENT_API_KEY on API; same value as PACA_API_KEY on ai-agent)
openssl rand -hex 32

# AI agent internal endpoint protection
openssl rand -hex 32

# AES-256 encryption key — 64 lowercase hex chars (32 bytes)
openssl rand -hex 32
```

</Accordion>
</AccordionGroup>

## Deploy and compose variables

These variables live in the top-level `.env` consumed by Compose. They configure infrastructure, image pins, and ports rather than a single application binary.

### Image pins

| Variable | Default | Description |
| --- | --- | --- |
| `PACA_API_IMAGE` | `pacaai/paca-api:latest` | API container image |
| `PACA_WEB_IMAGE` | `pacaai/paca-web:latest` | Web SPA image (production) |
| `PACA_REALTIME_IMAGE` | `pacaai/paca-realtime:latest` | Realtime Socket.IO image |
| `PACA_AI_AGENT_IMAGE` | `pacaai/paca-ai-agent:latest` | AI agent worker image |

### Runtime mode and ports

| Variable | Default | Services | Description |
| --- | --- | --- | --- |
| `ENVIRONMENT` | `production` | API (`ENV`), realtime (`NODE_ENV`) | `production` enables secure cookies and structured JSON logging in realtime |
| `GATEWAY_PORT` | `80` | gateway | Host port mapped to nginx |
| `AI_AGENT_PORT` | `8082` | ai-agent | Host port for direct agent HTTP (internal debugging; not the public gateway) |
| `LOG_LEVEL` | `info` | realtime, ai-agent | Pino / Python log level |

### PostgreSQL (bundled container)

| Variable | Default | Description |
| --- | --- | --- |
| `POSTGRES_DB` | `paca` | Database name for the bundled Postgres service |
| `POSTGRES_USER` | `paca` | Database user |
| `POSTGRES_PASSWORD` | — | **Required** in production compose |
| `DATABASE_URL` | Derived from Postgres vars | Full DSN; override for external Postgres (`--scale postgres=0`) |

### Valkey / Redis

| Variable | Default | Services | Description |
| --- | --- | --- | --- |
| `REDIS_URL` | `redis://valkey:6379/0` | API, realtime | Cache, pub/sub fan-out, agent trigger streams |
| `VALKEY_URL` | Same as `REDIS_URL` in compose | ai-agent | Compose maps `REDIS_URL` → `VALKEY_URL` |

All three application services must point at the **same** Valkey instance.

### Public URL and CORS

| Variable | Dev equivalent | Used by | Description |
| --- | --- | --- | --- |
| `PUBLIC_URL` | `PUBLIC_HOST` | API, realtime CORS | Externally reachable base URL (no trailing slash). Used for plugin callbacks, GitHub webhook registration, and presigned storage URL rewriting |
| `CORS_ORIGINS` | Falls back to `PUBLIC_URL` | realtime | Comma-separated browser origins allowed for Socket.IO |
| `VITE_ALLOWED_HOST` | — | web (dev only) | Hostname (no scheme) for Vite `allowedHosts` when using tunnels or custom domains |

<ParamField body="PUBLIC_URL" type="string" required>
Full public URL where users reach Paca, e.g. `https://paca.example.com` or `http://localhost:8080`. When unset, GitHub webhook auto-registration returns `GITHUB_WEBHOOK_URL_REQUIRED`.
</ParamField>

## API service (`services/api`)

Loaded by `config.Load()` from environment (optional `.env` via `godotenv`). Missing required variables produce a single error listing all gaps.

### Server

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PORT` | No | `8080` | HTTP listen port |
| `ENV` | No | `development` | `development` or `production` |
| `PUBLIC_URL` | No | `""` | External base URL for webhooks and plugin callbacks |
| `COOKIE_SECURE` | No | `false` | Set `Secure` flag on auth cookies; use `true` behind TLS |

### Database and cache

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `DATABASE_URL` | **Yes** | — | PostgreSQL connection string |
| `REDIS_URL` | **Yes** | — | Valkey/Redis URL |
| `CACHE_PROJECT_TTL` | No | `5m` | Project detail and member lists; `0` disables caching |
| `CACHE_CONFIG_TTL` | No | `10m` | Task types, statuses, custom fields, roles; `0` disables |
| `CACHE_SPRINT_TTL` | No | `2m` | Sprints and views; `0` disables |

### JWT and admin seed

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `JWT_SECRET` | **Yes** | — | HMAC signing secret for access and refresh tokens |
| `JWT_ACCESS_TTL` | No | `15m` | Access token lifetime |
| `JWT_REFRESH_TTL` | No | `168h` | Refresh token lifetime (persistent / remember-me sessions) |
| `JWT_REFRESH_SESSION_TTL` | No | `24h` | Refresh session lifetime (ephemeral sessions) |
| `ADMIN_USERNAME` | **Yes** | — | Default admin username seeded on first startup |
| `ADMIN_PASSWORD` | **Yes** | — | Default admin password seeded on first startup |

### Object storage

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `STORAGE_PROVIDER` | No | `minio` | `minio` or `s3` |
| `STORAGE_ENDPOINT` | No | `minio:9000` | MinIO host:port; ignored for AWS S3 |
| `STORAGE_PUBLIC_URL` | No | `""` | Public gateway path for presigned URLs, e.g. `http://localhost/storage` |
| `STORAGE_REGION` | No | `us-east-1` | AWS region (also passed to MinIO) |
| `STORAGE_BUCKET` | No | `paca` | Bucket name; created on startup if missing |
| `STORAGE_ACCESS_KEY_ID` | **Yes** | — | S3/MinIO access key |
| `STORAGE_SECRET_ACCESS_KEY` | **Yes** | — | S3/MinIO secret key |
| `STORAGE_USE_SSL` | No | `false` | `true` when endpoint is HTTPS |

For AWS S3, set `STORAGE_PROVIDER=s3`, supply real credentials, leave `STORAGE_ENDPOINT` empty, and run with `--scale minio=0`. For S3, `STORAGE_PUBLIC_URL` can be empty because presigned URLs are already public.

### Security and internal keys

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `ENCRYPTION_KEY` | No | `""` | 64-char lowercase hex string (32 bytes, AES-256). Encrypts plugin tokens/secrets at rest. Legacy alias: `GITHUB_ENCRYPTION_KEY` |
| `AGENT_API_KEY` | No | `""` | Pre-shared `X-API-Key` for the built-in agent bot; must match `PACA_API_KEY` on ai-agent |
| `AI_AGENT_URL` | No | `http://ai-agent:8080` | Internal base URL for API → ai-agent HTTP calls (e.g. LLM model list) |

When `ENCRYPTION_KEY` is set but invalid, the API logs a warning and disables agent LLM key decryption.

### Plugin subsystem

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PLUGINS_STORE` | No | `local` | `local` (filesystem) or `s3` (object storage) |
| `PLUGINS_WASM_DIR` | No | `./plugins/local/backend` | WASM binaries root (`local` mode) |
| `PLUGINS_FRONTEND_DIR` | No | `./plugins/local/frontend` | Frontend bundles served at `/plugins/` |
| `PLUGINS_MCP_DIR` | No | `./plugins/local/mcp` | MCP bundles served at `/plugins-mcp/` |
| `PLUGINS_S3_PREFIX` | No | `plugins` | S3 key prefix when `PLUGINS_STORE=s3` |
| `PLUGINS_MARKETPLACE_CATALOG_URL` | No | `paca-plugins` raw JSON URL | Marketplace catalog endpoint |
| `PLUGINS_MARKETPLACE_TIMEOUT` | No | `20s` | HTTP timeout for marketplace fetches |

Production Compose hardcodes container paths (`/plugins`, `/plugins-frontend`, `/plugins-mcp`) and does not pass `PLUGINS_STORE` or cache TTL overrides — defaults from `config.Load()` apply.

## Realtime service (`services/realtime`)

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PORT` | No | `3001` | HTTP + Socket.IO listen port |
| `API_URL` | **Yes** | — | Internal API base URL for token verification (direct to API, not through nginx) |
| `REDIS_URL` | **Yes** | — | Valkey URL for pub/sub fan-out |
| `CORS_ORIGINS` | No | `http://localhost:3000` | Comma-separated allowed browser origins |
| `LOG_LEVEL` | No | `info` | `trace` \| `debug` \| `info` \| `warn` \| `error` \| `fatal` |
| `NODE_ENV` | No | `development` | `production` enables structured JSON logging (no pretty-print) |

## AI agent service (`services/ai-agent`)

Pydantic settings load from environment (optional `.env`). `INTERNAL_API_KEY` is required with minimum length 1.

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PORT` | No | `8080` | HTTP listen port inside the container |
| `LOG_LEVEL` | No | `INFO` | Python logging level |
| `VALKEY_URL` | No | `redis://valkey:6379/0` | Valkey for trigger streams and event publishing |
| `DATABASE_URL` | **Yes** | — | PostgreSQL (reads agent config and conversation state) |
| `INTERNAL_API_KEY` | **Yes** | — | Shared secret for `X-Internal-Token` on internal conversation routes |
| `API_BASE_URL` | No | `http://api:8080` | Internal API URL for service-to-service calls |
| `GATEWAY_BASE_URL` | No | `http://gateway` | Gateway URL for plugin MCP bundle resolution (`/plugins-mcp/`) |
| `PACA_API_KEY` | No | `""` | Must match `AGENT_API_KEY` on API; when set, injects built-in `paca` MCP server into agent conversations |
| `ENCRYPTION_KEY` | No | `""` | Must match API; decrypts `llm_api_key_secret` values from the database |
| `DOCKER_SOCKET` | No | `/var/run/docker.sock` | Host Docker socket for OpenHands agent-server containers |
| `AGENT_SERVER_IMAGE` | No | `ghcr.io/openhands/agent-server:latest-python` | Default agent-server image |
| `AGENT_SERVER_CONTAINER_PORT` | No | `8000` | Port the agent-server process listens on inside its container |
| `PORT_POOL_START` | No | `10000` | First host port in the pool (local dev outside Docker) |
| `PORT_POOL_SIZE` | No | `100` | Pool size (= max concurrent conversations per replica) |
| `WORKER_CONCURRENCY` | No | `10` | Concurrent Valkey trigger consumers |

When `PACA_API_KEY` is empty, the built-in `paca` MCP server is not injected. When `ENCRYPTION_KEY` is unset but the API encrypts secrets, the agent logs a warning and uses database values as-is.

## Web application and gateway

### Production web

The production `paca-web` image is a prebuilt SPA served by nginx inside the container. It has **no runtime environment variables** — API and realtime URLs are resolved relative to the gateway.

### Development web

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `DOCKER` | No | — | Set to `true` in dev Compose so Vite proxies through the gateway |
| `VITE_ALLOWED_HOST` | No | — | Hostname allowed by Vite when accessing through a tunnel or custom domain |
| `NODE_OPTIONS` | No | — | Dev Compose sets `--max-old-space-size=2048` |

### Gateway (nginx)

The gateway container mounts `deploy/nginx/gateway.conf` and has no application env vars beyond the Compose port mapping (`GATEWAY_PORT`). It routes:

- `/api/*` → API (strips `/api` prefix)
- `/ws/*` → realtime (Socket.IO)
- `/storage/*` → MinIO (when bundled)
- `/plugins/`, `/plugins-mcp/` → plugin volume mounts
- `/*` → web SPA

`STORAGE_PUBLIC_URL` must match the gateway storage path so presigned URLs rewrite correctly for browsers.

## MCP client (`@paca-ai/paca-mcp`)

External MCP clients configure these variables. They are not part of the Docker stack unless you run MCP locally.

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PACA_API_KEY` | **Yes** | — | User API key or global `AGENT_API_KEY` (agent mode) |
| `PACA_API_URL` | No | `http://localhost:8080` | Paca API base URL |
| `PACA_GATEWAY_URL` | No | — | Gateway URL for loading plugin MCP bundles; falls back to `PACA_API_URL` |
| `PACA_AGENT_ID` | No | — | Agent UUID for agent single-project mode |
| `PACA_PROJECT_ID` | No | — | Project UUID; required when `PACA_AGENT_ID` is set |

<RequestExample>

```json
{
  "mcpServers": {
    "paca": {
      "command": "npx",
      "args": ["-y", "@paca-ai/paca-mcp"],
      "env": {
        "PACA_API_KEY": "your-api-key",
        "PACA_API_URL": "http://localhost:8080",
        "PACA_GATEWAY_URL": "http://localhost"
      }
    }
  }
}
```

</RequestExample>

## Production vs development defaults

| Concern | Production | Development |
| --- | --- | --- |
| Public URL var | `PUBLIC_URL` | `PUBLIC_HOST` |
| `COOKIE_SECURE` | `true` | `false` |
| `JWT_SECRET` / admin password | Strong random values required | Dev placeholders (`dev-change-in-production`, `adminpassword`) |
| `ENCRYPTION_KEY` | Generated by install script | Fixed dev hex key in `docker-compose.dev.yml` |
| Internal keys | `openssl rand -hex 32` each | `dev-agent-api-key-change-in-production`, `dev-internal-key-change-in-production` |
| Storage | MinIO sidecar or AWS S3 | MinIO on ports 9000/9001 |
| Plugin paths | Docker volumes at `/plugins*` | Bind mounts to `plugins/local/*` |
| AI agent | Optional (`--scale ai-agent=0` to skip) | Included with Docker socket mount |

## Verify configuration

<Steps>
<Step title="Check API health">

```bash
curl -s http://localhost/api/healthz
```

Expect HTTP 200 from the gateway-proxied health endpoint.

</Step>
<Step title="Confirm Valkey connectivity">

If the API or realtime fails to start, check that `REDIS_URL` / `VALKEY_URL` resolve to the same Valkey instance and that the hostname matches your compose network (`valkey` in Docker, `localhost` on the host).

</Step>
<Step title="Validate storage URLs">

Upload an attachment in the web UI. If presigned URLs fail, verify `STORAGE_PUBLIC_URL` matches your gateway storage route (e.g. `http://localhost/storage`).

</Step>
<Step title="Test agent key pairing">

With ai-agent enabled, confirm `AGENT_API_KEY` on the API equals `PACA_API_KEY` on ai-agent. A mismatch prevents the built-in MCP server from authenticating.

</Step>
<Step title="Confirm encryption key sync">

Set the same `ENCRYPTION_KEY` on API and ai-agent. Store an agent LLM key in the admin UI and start a conversation; decryption errors in ai-agent logs indicate a mismatch.

</Step>
</Steps>

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
Prerequisites, install script, and required secrets for first deploy.
</Card>
<Card title="Deploy production" href="/deploy-production">
Production Compose topology, nginx gateway, and scaling optional services.
</Card>
<Card title="Local development" href="/local-development">
Dev Compose stack, hot-reload, and host-side run commands.
</Card>
<Card title="Configure AI agents" href="/configure-ai-agents">
Agent types, LLM config, and conversation lifecycle after keys are set.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` with `PACA_API_KEY` and agent mode env vars.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Auth cookies, Valkey connectivity, storage misconfiguration, and MCP errors.
</Card>
</CardGroup>

---

## 20. Plugin SDK reference

> Typed APIs for @paca-ai/plugin-sdk-react, plugin-sdk-go host bridge, and @paca-ai/plugin-sdk-mcp; extension points and scoped HTTP clients.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/20-plugin-sdk-reference.md
- Generated: 2026-06-13T22:12:34.850Z

### Source Files

- `docs/plugins/sdk-reference.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/mcp-plugin-system.md`
- `docs/plugins/developer-guide.md`
- `services/api/internal/transport/http/dto/plugin_dto.go`

---
title: "Plugin SDK reference"
description: "Typed APIs for @paca-ai/plugin-sdk-react, plugin-sdk-go host bridge, and @paca-ai/plugin-sdk-mcp; extension points and scoped HTTP clients."
---

Paca plugin authors integrate through three published SDK packages — `@paca-ai/plugin-sdk-react` for Module Federation UI, `github.com/Paca-AI/plugin-sdk-go` for WASM backend handlers, and `@paca-ai/plugin-sdk-mcp` for MCP tool modules. Each SDK wraps scoped HTTP access to `/api/v1/plugins/{pluginId}/…` (and, where applicable, core `/api/v1/` routes) so plugins never construct unscoped clients against the host.

## Package map

| Package | Runtime | Repository | Install |
|---|---|---|---|
| `@paca-ai/plugin-sdk-react` | Browser (Module Federation remote) | [github.com/Paca-AI/plugin-sdk-react](https://github.com/Paca-AI/plugin-sdk-react) | `bun add @paca-ai/plugin-sdk-react` |
| `github.com/Paca-AI/plugin-sdk-go` | WASM (`GOOS=wasip1 GOARCH=wasm`) in `services/api` | [github.com/Paca-AI/plugin-sdk-go](https://github.com/Paca-AI/plugin-sdk-go) | `go get github.com/Paca-AI/plugin-sdk-go` |
| `@paca-ai/plugin-sdk-mcp` | Node.js ESM in `apps/mcp` | [github.com/Paca-AI/plugin-sdk-mcp](https://github.com/Paca-AI/plugin-sdk-mcp) | `bun add @paca-ai/plugin-sdk-mcp` |

<Note>
All three SDKs live in separate repositories. The Paca monorepo hosts the runtime that loads plugin artifacts and enforces permissions; SDKs are the typed contract plugin code compiles against.
</Note>

A working end-to-end reference is [github.com/Paca-AI/paca-plugin-example](https://github.com/Paca-AI/paca-plugin-example).

```mermaid
flowchart LR
  subgraph browser["Browser — apps/web host"]
    EP["ExtensionPoint / PluginSlot"]
    RE["@paca-ai/plugin-sdk-react"]
    EP --> RE
  end

  subgraph api["services/api"]
    RT["plugin.Runtime — wazero"]
    GO["plugin-sdk-go"]
    RT --> GO
    RTR["/api/v1/plugins/:pluginId/*path"]
  end

  subgraph mcp["apps/mcp"]
    PL["plugin-loader.ts"]
    MCP["@paca-ai/plugin-sdk-mcp"]
    PL --> MCP
  end

  RE -->|"pluginGet / listTasks"| RTR
  MCP -->|"PluginAPIClient"| RTR
  MCP -->|"coreGet"| CORE["/api/v1/…"]
```

## Scoped HTTP paths

Backend routes declared in `plugin.json` under `backend.routes` are proxied at:

```
/api/v1/plugins/{pluginId}/{path}
```

Project-scoped handlers should include `/projects/:projectId/` in the manifest path (for example `/projects/:projectId/tasks/:taskId/items`). The Gin router captures the full sub-path via a wildcard (`/plugins/:pluginId/*path`) and dispatches to the plugin WASM handler after per-route middleware from the manifest.

| Client | Prefix | Auth |
|---|---|---|
| `PluginApiClient` (React) | `/api/v1` + `plugins/{pluginId}/` | Session cookie via host-injected `fetch` (`credentials: "include"`) |
| `PluginAPIClient` (MCP) | `/api/v1/plugins/{pluginId}/` for plugin routes; `/api/v1/` for `coreGet` | `X-API-Key` from `PluginMCPContext.apiKey` |
| Go route handlers | N/A — in-process WASM | Caller identity from `req.Caller()` (JWT claims validated by host) |

<Warning>
For project-scoped plugin routes, include `projects/{projectId}/` in relative paths passed to `pluginGet`, `pluginPost`, and MCP `PluginAPIClient` helpers. Omitting the project segment produces 404s against the mounted namespace.
</Warning>

---

## `@paca-ai/plugin-sdk-react`

TypeScript/React SDK for frontend extension point components loaded via Vite Module Federation. Mark `@paca-ai/plugin-sdk-react` as a **shared singleton** in the plugin's `vite.config.ts` so the host and remote use the same SDK instance.

### `PluginSDK`

The host injects a `PluginSDK` object on every extension point component:

```ts
interface PluginSDK {
  api: PluginApiClient;
  ui: PluginUI;
  meta: PluginMeta;
}
```

Extension point prop interfaces extend `BaseExtensionProps`, which flattens `api`, `ui`, and `meta` to top-level props alongside point-specific fields.

### `PluginApiClient`

<ParamField body="baseUrl" type="string" required>
API root, for example `https://app.example.com/api/v1`. Set by the host.
</ParamField>

<ParamField body="projectId" type="string" required>
Current project UUID. Injected by the host for project-scoped calls.
</ParamField>

<ParamField body="fetch" type="function" required>
Host-provided `fetch` wrapper that attaches session credentials. Plugins must not construct their own client.
</ParamField>

| Method | Returns | Scope |
|---|---|---|
| `listTasks(filters?)` | `TaskSummary[]` | Current project |
| `getTask(taskId)` | `Task` | Current project |
| `getProject()` | `ProjectSummary` | Current project |
| `listMembers()` | `ProjectMember[]` | Current project |
| `pluginGet<T>(pluginId, path)` | `T` | `/plugins/{pluginId}/{path}` |
| `pluginPost<T>(pluginId, path, body)` | `T` | same |
| `pluginPatch<T>(pluginId, path, body)` | `T` | same |
| `pluginDelete(pluginId, path)` | `void` | same |

<RequestExample>

```ts
// Backend route registered at /tasks/:taskId/items
const items = await api.pluginGet<MyItem[]>(
  meta.pluginId,
  `projects/${projectId}/tasks/${taskId}/items`,
);

const members = await api.listMembers();
const tasks = await api.listTasks({ status_ids: ["done"] });
```

</RequestExample>

### `PluginUI`

| Method | Behavior |
|---|---|
| `toast(opts)` | Host toast: `title`, optional `description`, `variant` (`default` \| `success` \| `destructive`), `duration` |
| `confirm(opts)` | Modal; resolves `true` if confirmed. `title` required. |
| `navigate(path)` | In-app navigation via host router |

### `PluginMeta`

<ResponseField name="pluginId" type="string">
Reverse-DNS identifier, for example `com.paca.checklist`.
</ResponseField>

<ResponseField name="displayName" type="string">
Human-readable plugin name from manifest.
</ResponseField>

<ResponseField name="version" type="string">
Semver from manifest.
</ResponseField>

### Extension point contracts

The host recognizes five extension point IDs. Each exported remote component must match the typed props for its point:

| Extension point ID | Host surface | SDK props interface | Additional props |
|---|---|---|---|
| `task.detail.section` | Task detail drawer/page | `TaskDetailSectionProps` | `taskId`, `projectId` |
| `sidebar.general.section` | Global sidebar | `SidebarGeneralSectionProps` | `isCollapsed` |
| `sidebar.project.section` | Project sidebar | `SidebarProjectSectionProps` | `projectId`, `isCollapsed` |
| `project.settings.tab` | Project settings | `ProjectSettingsTabProps` | `projectId` |
| `view` | Board/view area | `ViewExtensionProps` | `projectId`, `viewConfig?` |

```ts
interface BaseExtensionProps {
  api: PluginApiClient;
  ui: PluginUI;
  meta: PluginMeta;
}

interface TaskDetailSectionProps extends BaseExtensionProps {
  taskId: string;
  projectId: string;
}
```

The host's `<ExtensionPoint>` forwards point-specific `componentProps` (for example `{ projectId, taskId }` on task detail) to every registered remote at that point, sorted by manifest `order` and admin `extension_settings`.

### Shared types

Task and project shapes use **snake_case** field names matching REST JSON:

```ts
interface TaskSummary {
  id: string;
  title: string;
  task_number: number;
  status_id: string | null;
  assignee_id: string | null;
}

interface TaskFilters {
  status_ids?: string[];
  assignee_ids?: string[];
  sprint_id?: string;
  parent_task_id?: string;
  page?: number;
  page_size?: number;
}
```

### React Query integration

`PluginQueryClientProvider` namespaces TanStack Query cache keys under `["plugin", pluginId, …]` so plugin queries cannot collide with the host or sibling plugins.

```ts
import {
  PluginQueryClientProvider,
  usePluginQuery,
  usePluginQueryClient,
} from "@paca-ai/plugin-sdk-react";

function MyComponent({ api, meta, taskId }: TaskDetailSectionProps) {
  const { data, isLoading } = usePluginQuery(
    meta.pluginId,
    ["my-items", taskId],
    () => api.pluginGet(meta.pluginId, `projects/${projectId}/tasks/${taskId}/items`),
  );
}
```

`PluginQueryClientProvider` accepts an optional `queryClient` to reuse the host's `QueryClient` when running inside the federation shell.

---

## `github.com/Paca-AI/plugin-sdk-go`

Go SDK for WASM backend plugins executed by `services/api/internal/platform/plugin.Runtime` (wazero). The SDK hides the linear-memory protobuf host bridge behind idiomatic Go types.

### Build target

<Tabs>
<Tab title="TinyGo (smaller binary)">

```sh
GOOS=wasip1 GOARCH=wasm tinygo build -o plugin.wasm -target wasip1 .
```

</Tab>
<Tab title="Standard Go 1.21+">

```sh
GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm .
```

</Tab>
</Tabs>

Every plugin entry file uses the `wasip1` build tag and calls `plugin.Run` from `init()`:

```go
//go:build wasip1

package main

import plugin "github.com/Paca-AI/plugin-sdk-go"

type myPlugin struct {
    db  *plugin.DB
    kv  *plugin.KV
    log *plugin.Logger
    cfg *plugin.Config
}

func (p *myPlugin) Init(ctx *plugin.Context) error {
    p.db  = ctx.DB()
    p.kv  = ctx.KV()
    p.log = ctx.Log()
    p.cfg = ctx.Config()

    ctx.Route("GET",  "/tasks/:taskId/items", p.listItems)
    ctx.Route("POST", "/tasks/:taskId/items", p.createItem)
    ctx.On("task.deleted", p.onTaskDeleted)
    return nil
}

func (p *myPlugin) Shutdown() {}

func init() { plugin.Run(&myPlugin{}) }
func main() {}
```

### `Plugin` interface

| Lifecycle | SDK surface | Host behavior |
|---|---|---|
| Startup | `Init(ctx *plugin.Context) error` | Host calls after loading WASM; register routes and event handlers here |
| HTTP | `ctx.Route(method, path, handler)` | Routes matched under `/api/v1/plugins/{pluginId}/…` |
| Events | `ctx.On(topic, handler)` | Subscriptions declared in `backend.eventSubscriptions` |
| Shutdown | `Shutdown()` | Called before module unload |

### Request and response helpers

| Symbol | Purpose |
|---|---|
| `req.PathParam(name)` | Route parameter from manifest path |
| `req.Caller()` | `CallerIdentity` with `ProjectID`, user claims |
| `plugin.JSONBody[T](req)` | Deserialize request body |
| `resp.JSON(status, data)` | JSON response |
| `resp.Error(status, message)` | Error response |
| `resp.NoContent()` | 204 response |
| `plugin.JSONPayload[T](evt)` | Parse domain event payload |
| `plugin.DispatchEvent(ctx, topic, payload)` | Fire event in tests |

### Host bridge (Go SDK wrappers)

The SDK maps to host functions registered in `plugin.Runtime`:

| SDK type | Host functions | Constraints |
|---|---|---|
| `*plugin.DB` | `paca.db_query`, `paca.db_exec`, transactions | `Query`/`Exec` run in plugin schema `plugin_data_{pluginId}`; SELECT-only for `db_query` |
| `*plugin.KV` | `paca.storage_get/set/delete` | JSONB key-value per plugin |
| `*plugin.Logger` | `paca.log` | Structured logs tagged with plugin ID |
| `*plugin.Config` | `paca.config_get` | Keys allowlisted in `backend.allowedConfigKeys` |
| Core reads | `paca.tasks_list`, `paca.task_get`, `paca.project_get`, `paca.members_list` | Project scope enforced |
| Outbound HTTP | `paca.fetch` | Domains allowlisted in `backend.allowedOutboundDomains` |
| Events | `paca.event_emit` | Plugin-namespaced topics to Valkey |

Default resource limits per WASM instance: **64 MiB** memory (`MaxMemoryPages: 1024`), **5 s** max call duration.

### Unit testing with `plugintest`

```go
tc := plugintest.NewContext(t)
tc.DB.SeedRows("my_items", []string{"id", "task_id", "title"}, [][]any{{"abc", "task-1", "Test"}})
tc.Config.Set("greeting.prefix", "Hi")

var p myPlugin
p.Init(tc.PluginContext())

res := tc.Call("GET", "/tasks/:taskId/items", plugintest.Request{
    PathParams: map[string]string{"taskId": "task-1"},
    Caller:     plugin.CallerIdentity{ProjectID: "proj-1"},
})
```

| `plugintest` API | Description |
|---|---|
| `NewContext(t)` | Fresh harness; auto cleanup |
| `DB.SeedRows` / `DB.AllRows` | In-memory table seeding and inspection |
| `KV.Set`, `Config.Set` | Pre-seed state |
| `PluginContext()` | `*plugin.Context` for `Init` |
| `Call(method, path, req)` | Dispatch route; returns `*plugin.Response` |

---

## `@paca-ai/plugin-sdk-mcp`

TypeScript SDK for MCP tool modules loaded at `apps/mcp` startup. Declare `mcp.remoteEntryUrl` in `plugin.json`; the MCP server imports the ESM bundle, validates `PluginMCPEntry`, and merges tools into the flat tool list.

### `PluginMCPEntry`

```ts
interface PluginMCPEntry {
  tools: Tool[];
  handleToolCall(
    name: string,
    args: Record<string, unknown>,
    context: PluginMCPContext,
  ): Promise<PluginToolResult>;
}
```

Default-export the entry object from `src/mcp.ts` (or equivalent build output).

### `PluginMCPContext`

<ParamField body="pluginId" type="string" required>
Reverse-DNS plugin name (for example `com.paca.checklist`).
</ParamField>

<ParamField body="baseURL" type="string" required>
Paca API base URL (for example `http://localhost:8080`).
</ParamField>

<ParamField body="apiKey" type="string" required>
API key from MCP server config (`PACA_API_KEY`). Sent as `X-API-Key`.
</ParamField>

Construct one `PluginAPIClient` per tool invocation:

| Method | Prefix |
|---|---|
| `pluginGet/Post/Patch/Delete(path)` | `/api/v1/plugins/{pluginId}/` |
| `coreGet(path)` | `/api/v1/` |

### `PluginToolResult`

```ts
interface PluginToolResult {
  content: ToolResultContent[];
  isError?: boolean;
}

type ToolResultContent =
  | { type: "text"; text: string }
  | { type: "image"; data: string; mimeType: string }
  | { type: "resource"; resource: { uri: string; text?: string; mimeType?: string } };
```

Helpers: `textResult(text)` for success, `errorResult(message)` sets `isError: true`.

### Tool naming

Tool names must be globally unique across all enabled plugins. Use a short prefix derived from the plugin ID:

| Plugin ID | Prefix | Example |
|---|---|---|
| `com.paca.checklist` | `checklist_` | `checklist_list_items` |
| `com.example.my-plugin` | `my_plugin_` | `my_plugin_create_item` |

Pattern: `[a-z][a-z0-9_]*`.

### Loading and errors

<AccordionGroup>
<Accordion title="Startup sequence">

1. `GET /api/v1/plugins` with `X-API-Key`
2. Skip `enabled: false` or missing `mcp.remoteEntryUrl`
3. `import(remoteEntryUrl)` — `https://`, `file://`, or `http://` (localhost/gateway only)
4. Validate default export; register tools in `PluginRegistry`

</Accordion>
<Accordion title="Failure modes">

| Scenario | Behavior |
|---|---|
| API unreachable at startup | Warning logged; MCP starts with core tools only |
| Module fetch/validation fails | Warning logged; that plugin's tools omitted |
| `handleToolCall` throws | `isError: true` text result returned to client |
| Duplicate tool name | Later plugin's tool skipped with warning |

</Accordion>
</AccordionGroup>

<RequestExample>

```ts
import type { PluginMCPEntry } from "@paca-ai/plugin-sdk-mcp";
import { PluginAPIClient, textResult, errorResult } from "@paca-ai/plugin-sdk-mcp";

const entry: PluginMCPEntry = {
  tools: [{
    name: "checklist_list_items",
    description: "List checklist items attached to a task.",
    inputSchema: {
      type: "object",
      properties: {
        project_id: { type: "string" },
        task_id:    { type: "string" },
      },
      required: ["project_id", "task_id"],
    },
  }],
  async handleToolCall(name, args, context) {
    const api = new PluginAPIClient(context);
    const { project_id, task_id } = args as { project_id: string; task_id: string };
    try {
      const items = await api.pluginGet(
        `projects/${project_id}/tasks/${task_id}/items`,
      );
      return textResult(JSON.stringify(items, null, 2));
    } catch (err) {
      return errorResult(err instanceof Error ? err.message : String(err));
    }
  },
};

export default entry;
```

</RequestExample>

---

## Manifest fields that bind SDKs

| Manifest section | SDK | Key fields |
|---|---|---|
| `frontend` | React | `remoteEntryUrl`, `extensionPoints[].point`, `extensionPoints[].component` |
| `backend` | Go | `routes`, `eventSubscriptions`, `migrations`, `allowedConfigKeys`, `allowedOutboundDomains` |
| `mcp` | MCP | `remoteEntryUrl` |
| `permissions` | Go host bridge | Gates host function groups (`db:write:plugin_data`, `http:register_routes`, `events:emit`, …) |

Route middleware names: `authn`, `optionalAuthn`, `requireFreshPassword`, `requireJWTAuth`, `requirePermissions` (with `scope`, `projectParam`, `permissions`). Omitted `middlewares` applies the default chain: `optionalAuthn` → `requireFreshPassword` → project-scoped `projects.read`.

---

## Quick integration checklist

<Steps>
<Step title="Install the SDK for your surface">

Add `@paca-ai/plugin-sdk-react`, `plugin-sdk-go`, or `@paca-ai/plugin-sdk-mcp` to the plugin project matching the surfaces you ship.

</Step>
<Step title="Match extension point or route contracts">

React components implement the typed props for their `extensionPoints[].point`. Go plugins register paths that align with `backend.routes` and include `/projects/:projectId/` when scoped to a project.

</Step>
<Step title="Use scoped clients only">

Call `api.pluginGet(meta.pluginId, path)` in React, `PluginAPIClient` in MCP, and `ctx.Route` handlers in Go — never call `/api/v1` directly from plugin bundles with hard-coded credentials.

</Step>
<Step title="Verify against a running stack">

```sh
# Backend route
curl -s -b "session=…" \
  "http://localhost:8080/api/v1/plugins/com.example.my-plugin/projects/{pid}/tasks/{tid}/items"

# Plugin list (MCP loader input)
curl -s -H "X-API-Key: …" http://localhost:8080/api/v1/plugins
```

Open a task detail panel to confirm frontend remotes load, and restart the MCP server to pick up new `mcp.remoteEntryUrl` modules.

</Step>
</Steps>

## Related pages

<CardGroup>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, extension points, MCP tools, capability permissions, and marketplace install lifecycle.
</Card>
<Card title="Build a plugin" href="/build-plugin">
End-to-end authoring: manifest, WASM backend, frontend remotes, migrations, and local install.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Core `@paca-ai/paca-mcp` tools plus dynamically loaded plugin tools.
</Card>
<Card title="REST API reference" href="/rest-api">
Versioned `/api/v1` paths, auth envelopes, and resource endpoints plugins may call via scoped clients.
</Card>
</CardGroup>

---

## 21. Troubleshooting

> Common install and runtime failures: health checks, auth cookies, Valkey connectivity, storage misconfiguration, and MCP connection errors.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/21-troubleshooting.md
- Generated: 2026-06-13T22:12:53.169Z

### Source Files

- `deploy/README.md`
- `SECURITY.md`
- `services/api/docker-entrypoint.sh`
- `services/realtime/README.md`
- `docs/guides/getting-started.md`
- `apps/mcp/README.md`

---
title: "Troubleshooting"
description: "Common install and runtime failures: health checks, auth cookies, Valkey connectivity, storage misconfiguration, and MCP connection errors."
---

Paca surfaces install and runtime failures through container health probes (`/api/healthz`, `/healthz`), API bootstrap logs (`bootstrap: ...`), and service-specific startup checks (Valkey `PING`, storage `EnsureBucket`, required env validation). Use the sections below to isolate which dependency or configuration surface is failing before changing secrets or restarting the full stack.

## Quick diagnostic checklist

<Steps>
<Step title="Confirm the gateway is reachable">

From the host where you run clients or browsers:

```bash
curl -s http://localhost/api/healthz
```

<ResponseExample>

```json
{"status":"ok"}
```

</ResponseExample>

A `200` with `{"status":"ok"}` means the nginx gateway and API are up. Connection refused or `502`/`504` usually means the gateway container started before upstreams were healthy, or `nginx/gateway.conf` is missing from the install directory.

</Step>

<Step title="Inspect container health and logs">

```bash
docker compose ps
docker compose logs api --tail 50
docker compose logs realtime --tail 50
docker compose logs valkey --tail 20
```

Look for `bootstrap:` errors on API startup, `cache: ping:` for Valkey, `bootstrap: ensure storage bucket:` for MinIO/S3, and `Missing required environment variable` in the realtime service.

</Step>

<Step title="Verify required secrets in .env">

Production compose fails fast when required variables are absent. At minimum, confirm these are set and non-empty:

| Variable | Used by |
|---|---|
| `JWT_SECRET` | API — token signing |
| `ADMIN_PASSWORD` | API — seeded admin account |
| `POSTGRES_PASSWORD` or `DATABASE_URL` | API, ai-agent |
| `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY` | API, MinIO |
| `ENCRYPTION_KEY` | API, ai-agent — plugin/agent secret encryption |

Generate secrets with `openssl rand -hex 32`. `ENCRYPTION_KEY` must be a 64-character lowercase hex string (32 bytes).

</Step>
</Steps>

## Health check failures

### API container unhealthy

The API health probe calls `GET /api/healthz` inside the container. The handler returns `200` with `{"status":"ok"}` and performs no dependency checks — if the probe fails, the process is not listening or bootstrap never completed.

| Symptom | Likely cause | Fix |
|---|---|---|
| API stuck in `starting` for 40s+ then `unhealthy` | Bootstrap failed before HTTP server started | Read `docker compose logs api` for `bootstrap:` prefix |
| `JWT_SECRET is required` at compose parse time | Missing `.env` entry | Set `JWT_SECRET` in `.env` and restart |
| `bootstrap: cache: ping:` | Valkey unreachable at `REDIS_URL` | Ensure `valkey` is healthy; confirm `REDIS_URL=redis://valkey:6379/0` in Docker networks |
| `bootstrap: auto-migrate:` | PostgreSQL connection or migration error | Verify `DATABASE_URL`; check postgres health and credentials |
| `bootstrap: ensure storage bucket:` | MinIO down or credential mismatch | Confirm `minio` is healthy; align `STORAGE_*` with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` |

<Note>

The API applies database migrations automatically on every startup. Migration failures block the health endpoint from ever becoming available.

</Note>

### Gateway returns 502 or connection refused

The gateway mounts `./nginx/gateway.conf` read-only. If the file is missing, nginx fails to start or serves no upstream routes.

```bash
ls -la nginx/gateway.conf
curl -s http://localhost/api/healthz
```

Download both release artifacts together when setting up manually:

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf
```

### Realtime container unhealthy

Realtime exposes `GET /healthz` on port `3001` (independent of Socket.IO). Compose waits for API health before starting realtime.

| Symptom | Likely cause | Fix |
|---|---|---|
| `Missing required environment variable: REDIS_URL` | Env not passed to realtime service | Set `REDIS_URL` in `.env` (default in compose: `redis://valkey:6379/0`) |
| `Missing required environment variable: API_URL` | Internal API URL unset | Production compose sets `API_URL=http://api:8080` automatically |
| Realtime logs `valkey subscriber error` | Valkey connection dropped | Restart `valkey`; verify network and `REDIS_URL` match API |

## Auth cookie failures

Paca session auth uses HttpOnly cookies set by `POST /api/v1/auth/login`:

| Cookie | Path | Notes |
|---|---|---|
| `access_token` | `/` | Sent on API and realtime requests |
| `refresh_token` | `/api/v1/auth/refresh` | Only sent to the refresh endpoint |

<ParamField body="COOKIE_SECURE" type="boolean" default="true (production)">
When `true`, browsers only send cookies over HTTPS. Set `COOKIE_SECURE=false` only when serving over plain HTTP (local dev). Production `.env.production.example` defaults to `true`.
</ParamField>

### Login succeeds but subsequent requests return 401

| Symptom | Likely cause | Fix |
|---|---|---|
| Login works once, then all API calls 401 | `JWT_SECRET` changed after tokens were issued | Log out and log in again; avoid rotating `JWT_SECRET` without clearing sessions |
| Browser never stores cookies | `COOKIE_SECURE=true` over `http://` | Set `COOKIE_SECURE=false` for HTTP deployments |
| Realtime connects then immediately disconnects | Missing or expired `access_token` cookie | Confirm `withCredentials: true` on Socket.IO; re-login if access token expired (default `JWT_ACCESS_TTL=15m`) |
| `POST /api/v1/auth/refresh` returns missing token | Refresh cookie not sent | Refresh cookie path is `/api/v1/auth/refresh` only — call refresh against the gateway API base, not a different host/port |

### Realtime auth rejected

Socket.IO auth resolves the JWT from `handshake.auth.token` first, then the `access_token` cookie. The realtime service does not validate JWTs locally — it calls `GET /api/v1/users/me/global-permissions` on the API. Any non-200 response produces `invalid or expired token`.

```ts
const socket = io("http://localhost", {
  path: "/ws/socket.io",
  withCredentials: true,
});
```

<Warning>

If `CORS_ORIGINS` on the realtime service does not include your browser origin, the Socket.IO handshake fails before auth runs. Production compose defaults `CORS_ORIGINS` to `PUBLIC_URL`.

</Warning>

## Valkey connectivity

Valkey (Redis-compatible) is required by the API, realtime, and ai-agent services. The API pings Valkey at bootstrap; failure prevents startup.

| Service | Env var | Default (bundled compose) |
|---|---|---|
| API | `REDIS_URL` | `redis://valkey:6379/0` |
| Realtime | `REDIS_URL` | `redis://valkey:6379/0` |
| AI agent | `VALKEY_URL` | Same as `REDIS_URL` in compose |

### API fails with `cache: ping`

1. Confirm Valkey is running: `docker compose ps valkey`
2. Test from the API container network: Valkey healthcheck uses `valkey-cli ping`
3. For host-side dev (API on host, infra in Docker): set `REDIS_URL=redis://localhost:6379/0`

### Realtime events not arriving

Realtime subscribes to the `paca.events` Pub/Sub channel published by the API. Symptoms and checks:

| Symptom | Check |
|---|---|
| Web UI stale until manual refresh | `docker compose logs realtime` — look for `subscribed to valkey channel` |
| `valkey subscriber reconnecting` loops | Valkey restart or network partition; check Valkey logs and volume |
| Socket connected but no task/doc events | Client must `emit("join", { projectId })` after connect; events route only to joined project rooms |

```bash
docker compose logs realtime | rg "valkey|subscribe|auth failed"
docker compose logs api | rg "redis connected"
```

## Storage misconfiguration

The API uses S3-compatible storage for attachments and presigned uploads. With bundled MinIO, the API auto-creates the bucket on startup (`EnsureBucket`). With AWS S3 (`STORAGE_PROVIDER=s3`), the bucket must already exist and the API skips auto-creation.

### Startup failure: `bootstrap: ensure storage bucket`

| Cause | Fix |
|---|---|
| MinIO not healthy | Start or scale up `minio`; wait for `mc ready local` healthcheck |
| Credential mismatch | `STORAGE_ACCESS_KEY_ID` / `STORAGE_SECRET_ACCESS_KEY` must match MinIO `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` |
| Wrong endpoint | Docker network: `STORAGE_ENDPOINT=minio:9000`; host-side dev: `localhost:9000` |
| Using S3 but MinIO still running | Set `STORAGE_PROVIDER=s3`, real AWS credentials, and start with `--scale minio=0` |

### Upload/download URL failures in the browser

Presigned URLs are rewritten from the internal MinIO host (`minio:9000`) to `STORAGE_PUBLIC_URL` so browsers reach storage through the gateway `/storage/` proxy. The gateway rewrites the `Host` header to `minio:9000` for signature validation.

| Misconfiguration | Symptom |
|---|---|
| `STORAGE_PUBLIC_URL` does not match your public hostname | Presigned URLs point at unreachable host |
| `STORAGE_PUBLIC_URL` missing `/storage` suffix | Browser requests bypass gateway MinIO proxy |
| `minio` scaled to 0 but `STORAGE_PROVIDER=minio` | 502 on `/storage/` routes |

<Tabs>
<Tab title="Bundled MinIO">

```bash
STORAGE_PROVIDER=minio
STORAGE_ENDPOINT=minio:9000
STORAGE_PUBLIC_URL=http://localhost/storage
STORAGE_BUCKET=paca
STORAGE_ACCESS_KEY_ID=<matches MINIO_ROOT_USER>
STORAGE_SECRET_ACCESS_KEY=<matches MINIO_ROOT_PASSWORD>
STORAGE_USE_SSL=false
```

</Tab>
<Tab title="AWS S3">

```bash
STORAGE_PROVIDER=s3
STORAGE_ENDPOINT=
STORAGE_PUBLIC_URL=
STORAGE_REGION=us-east-1
STORAGE_BUCKET=your-existing-bucket
STORAGE_ACCESS_KEY_ID=<aws-key>
STORAGE_SECRET_ACCESS_KEY=<aws-secret>
STORAGE_USE_SSL=true
```

Start without MinIO: `docker compose --env-file .env up -d --scale minio=0`

</Tab>
</Tabs>

### Plugin directory permissions

The API entrypoint creates `/plugins`, `/plugins-frontend`, and `/plugins-mcp` with ownership `app:app` before starting the binary. Permission errors on plugin volumes usually appear at container start, not at first plugin install.

## MCP connection errors

The `@paca-ai/paca-mcp` server runs via `npx` and authenticates with `X-API-Key`. It exits immediately if required env vars are missing.

<ParamField body="PACA_API_KEY" type="string" required>
API key from **Settings → API Keys**. Required at startup; missing key prints an error and exits with code 1.
</ParamField>

<ParamField body="PACA_API_URL" type="string" default="http://localhost:8080">
Base URL prepended to paths like `/api/v1/projects`. MCP requests use `${PACA_API_URL}/api/v1/...`.
</ParamField>

<ParamField body="PACA_AGENT_ID" type="string">
When set, `PACA_PROJECT_ID` is also required. Agent mode uses the server `AGENT_API_KEY` value, not a per-agent key.
</ParamField>

### Common MCP failures

| Error | Cause | Fix |
|---|---|---|
| `PACA_API_KEY environment variable is required` | Key not in MCP client config | Add `PACA_API_KEY` to the `env` block; restart the MCP client |
| `Connection refused` / `ECONNREFUSED` | API not reachable at `PACA_API_URL` | For production Docker (API not host-published), use the gateway URL: `PACA_API_URL=http://localhost` not `:8080` |
| `API request failed: 401` | Invalid, revoked, or expired API key | Generate a new key in Paca settings; check for trailing whitespace |
| `API request failed: 403` | Key valid but missing permissions | Assign global/project roles; set `PACA_PROJECT_ID` for project-scoped tools |
| `PACA_PROJECT_ID is required when using PACA_AGENT_ID` | Agent mode incomplete | Set both `PACA_AGENT_ID` and `PACA_PROJECT_ID` |
| `npx: command not found` | Node.js not installed | Install Node.js 18+ |
| `Cannot find package '@paca-ai/paca-mcp'` | npm registry unreachable | Check network; verify with `npx @paca-ai/paca-mcp --version` |
| No project tools listed | Global user mode without project scope | Set `PACA_PROJECT_ID` to enable `list_tasks`, `create_task`, etc. |

<CodeGroup>

```bash title="Verify API reachability (production via gateway)"
curl -s http://localhost/api/healthz
```

```bash title="Verify API reachability (dev API on host port)"
curl -s http://localhost:8080/api/healthz
```

```bash title="Test MCP package"
npx @paca-ai/paca-mcp --version
```

</CodeGroup>

<AccordionGroup>
<Accordion title="Agent mode key confusion">

Agent mode expects `PACA_API_KEY` to be the server-wide `AGENT_API_KEY` from the Paca deployment environment, combined with `PACA_AGENT_ID` and `PACA_PROJECT_ID`. The MCP server sends `X-Agent-ID` to impersonate the agent. A user's personal API key works for user mode only.

</Accordion>

<Accordion title="Plugin MCP tools missing">

At startup the MCP server calls `GET /api/v1/plugins` and loads enabled plugins with `mcp.remoteEntryUrl`. Failures to fetch plugins log warnings but core tools still load. If plugin tools are absent, confirm the plugin is enabled in Paca and the API key can read plugins.

</Accordion>

<Accordion title="Debug logging">

```bash
export DEBUG="*"
npx @paca-ai/paca-mcp
```

</Accordion>
</AccordionGroup>

## Install and upgrade failures

### Compose project rename (volume migration)

The compose project was renamed from `paca-prod` to `paca`. Existing volumes (`paca-prod_postgres_data`, etc.) are not automatically attached. After stopping the old stack, copy data into new volume names before starting the new project, or accept a fresh install with empty data.

### Required env validation at compose time

Docker Compose interpolates `${JWT_SECRET:?...}` and `${ADMIN_PASSWORD:?...}` — missing values fail before containers start. Generate a complete `.env` via the install script or `.env.production.example`.

### AI agent optional but failing

Skip the ai-agent when Docker socket access or resources are unavailable:

```bash
docker compose --env-file .env up -d --scale ai-agent=0
```

When enabled, `AGENT_API_KEY` and `INTERNAL_API_KEY` must match across `api` and `ai-agent` services, and `ENCRYPTION_KEY` must be identical so agent LLM keys stored encrypted in PostgreSQL can be decrypted.

### Rate limiting on API

The gateway applies `100r/s` per IP with burst 50 on `/api/`. Sustained automated probing returns `429`. This is expected under aggressive health polling or load tests — back off request rates.

## Log signals reference

| Log prefix / message | Service | Meaning |
|---|---|---|
| `bootstrap: cache: ping:` | API | Valkey unreachable at `REDIS_URL` |
| `bootstrap: ensure storage bucket:` | API | MinIO/S3 bucket creation or head failed |
| `bootstrap: auto-migrate:` | API | PostgreSQL migration error |
| `redis connected` | API | Valkey connection OK |
| `schema migrations applied` | API | DB ready |
| `valkey session client connected` | Realtime | Session Redis client OK |
| `subscribed to valkey channel` | Realtime | Pub/Sub on `paca.events` active |
| `socket auth failed` | Realtime | Token rejected by API permissions check |
| `PACA_API_KEY environment variable is required` | MCP | Client env misconfigured |

## Related pages

<CardGroup>
<Card title="Installation" href="/installation">
Prerequisites, install script, manual compose setup, and required secrets.
</Card>
<Card title="Quickstart" href="/quickstart">
First successful run and health endpoint verification.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog across all services.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Client-specific MCP setup for Claude Desktop, VS Code, and other hosts.
</Card>
<Card title="Deploy production" href="/deploy-production">
Production topology, storage backends, and scaling optional services.
</Card>
<Card title="Upgrade and migration" href="/upgrade-migration">
Image upgrades, automatic migrations, and volume migration steps.
</Card>
</CardGroup>

---

## 22. Upgrade and migration

> Pull new images, automatic DB migrations on API startup, compose project rename volume migration, and version upgrade verification signals.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/22-upgrade-and-migration.md
- Generated: 2026-06-13T22:12:54.421Z

### Source Files

- `deploy/README.md`
- `docs/guides/getting-started.md`
- `services/api/docker-entrypoint.sh`
- `deploy/docker-compose.prod.yml`
- `README.md`

---
title: "Upgrade and migration"
description: "Pull new images, automatic DB migrations on API startup, compose project rename volume migration, and version upgrade verification signals."
---

Production upgrades are image pulls plus a Compose restart: the API container applies embedded core schema SQL on every startup before serving traffic, and plugin SQL runs per installed plugin. Persistent state lives in Compose-named volumes keyed by project name (`paca`); installations that used the older `paca-prod` project require explicit volume copies.

## Upgrade paths

| Deployment | Typical upgrade command | Schema migration owner |
|---|---|---|
| Production Compose (self-hosted) | `docker compose pull` then `docker compose --env-file .env up -d` | API binary on startup |
| Install script layout | Same, from the install directory | API binary on startup |
| Contributor dev stack | `git pull` then restart API (container or host) | API binary on startup |
| Fresh Postgres in dev Compose | Postgres `initdb` scripts on first volume init only | Postgres init + API on subsequent restarts |

<Warning>
Keep your existing `.env` across upgrades. Changing `JWT_SECRET` invalidates sessions; changing `ENCRYPTION_KEY` breaks decryption of stored plugin and agent secrets. `ADMIN_PASSWORD` only affects the seeded admin account on first bootstrap, not ongoing logins.
</Warning>

## Standard production upgrade

Run from the directory that contains `docker-compose.yml` and `.env` (for example `./paca` after `install.sh`).

<Steps>
<Step title="Pull new images">

```bash
docker compose pull
```

This fetches updated tags for `api`, `web`, `realtime`, `ai-agent`, and infrastructure images defined in the compose file.

</Step>
<Step title="Recreate containers">

```bash
docker compose --env-file .env up -d
```

Compose recreates changed services. The API container restarts and runs core migrations before binding port 8080.

If you scaled optional services, pass the same `--scale` flags used at install time (for example `--scale postgres=0` for external PostgreSQL).

</Step>
<Step title="Verify health">

Confirm the stack is healthy before handing traffic back to users. See [Verification signals](#verification-signals) below.

</Step>
</Steps>

<Tip>
The install script starts with `--pull always`. For manual upgrades, `docker compose pull` followed by `up -d` is equivalent.
</Tip>

## Pin or lock a release

Image tags are controlled through `.env` variables. The install script writes pinned tags from the chosen release; manual deployments default to `:latest`.

| Variable | Default (prod compose) | Example pinned value |
|---|---|---|
| `PACA_API_IMAGE` | `pacaai/paca-api:latest` | `pacaai/paca-api:0.4.0` |
| `PACA_WEB_IMAGE` | `pacaai/paca-web:latest` | `pacaai/paca-web:0.4.0` |
| `PACA_REALTIME_IMAGE` | `pacaai/paca-realtime:latest` | `pacaai/paca-realtime:0.4.0` |
| `PACA_AI_AGENT_IMAGE` | `pacaai/paca-ai-agent:latest` | `pacaai/paca-ai-agent:0.4.0` |

To install a specific release via the install script:

```bash
PACA_VERSION=v0.4.0 bash install.sh
```

After editing image variables, run `docker compose pull` and `docker compose --env-file .env up -d` again.

## Automatic core database migrations

Core schema changes ship inside the API Docker image. On every startup, bootstrap runs embedded SQL from `services/api/migrations/` before seeding admin data or loading plugins.

```text
API container start
       │
       ▼
bootstrap.New()
       │
       ├── connect PostgreSQL + Valkey
       ├── RunMigrationsFS(db, migrations.FS)   ← all *.sql, lexicographic order
       ├── seed roles / admin / agent bot
       ├── run plugin migrations (per enabled plugin)
       └── load WASM plugins + start HTTP server
```

Migration properties:

| Property | Behavior |
|---|---|
| Execution timing | Every API startup |
| File order | Lexicographic filename sort (`000001_init.sql` … `000012_…`) |
| Idempotency | SQL uses `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `INSERT … ON CONFLICT`, and similar patterns |
| Failure mode | Bootstrap returns an error; the API process exits with `bootstrap: auto-migrate: …` and the container restarts |
| Runtime dependency | No source tree or migration directory mount required in production |

<Note>
Core migrations are not tracked in a `schema_migrations` table. Idempotent re-execution on each startup is the versioning mechanism. New release images embed new `*.sql` files that apply additive changes.
</Note>

### Manual migration (development)

Contributors can apply the same SQL files against a running database:

```bash
cd services/api
export DATABASE_URL=postgres://paca:paca@localhost:5432/paca?sslmode=disable
make migrate-up
```

`make migrate-up` runs `psql -f` on each `migrations/*.sql` file in sorted order. Use this when debugging migration SQL or when the API is not the migration runner.

In `deploy/docker-compose.dev.yml`, Postgres mounts `services/api/migrations` to `/docker-entrypoint-initdb.d` for **first-time volume initialization only**. Ongoing schema changes still rely on API startup migrations (or `make migrate-up`).

## Plugin migrations

Plugin-owned SQL is separate from core migrations. Each plugin gets a dedicated PostgreSQL schema and a `plugin_schema_migrations` tracking table. Only files not yet recorded are applied, in lexicographic order, inside per-file transactions.

Plugin migrations run in three contexts:

| Context | When | On failure |
|---|---|---|
| API startup | For each **enabled** installed plugin, before WASM load | Logged as `plugin: migration failed`; API still starts |
| Marketplace install | After artifact download, before runtime load | Install rolled back; API returns error |
| Marketplace upgrade | After new artifacts, before version persist | Artifacts cleaned up; API returns error |

Plugin WASM, frontend, and MCP artifacts persist in Compose volumes (`backend_plugins`, `frontend_plugins`, `mcp_plugins`). Upgrading the Paca platform image does not reinstall or upgrade marketplace plugins — upgrade plugins separately through the admin UI or plugin API.

## Compose project rename volume migration

`deploy/docker-compose.prod.yml` sets `name: paca`. Docker Compose prefixes volume names with the project name, so data volumes are `paca_postgres_data`, `paca_valkey_data`, `paca_minio_data`, `paca_backend_plugins`, `paca_frontend_plugins`, and `paca_mcp_plugins`.

Earlier installations used project name `paca-prod`, producing volumes like `paca-prod_postgres_data`. A normal `docker compose up` under the new project name creates **empty** volumes and looks like a fresh install.

Migrate data only when you need to preserve an existing installation:

<Steps>
<Step title="Stop the old stack">

```bash
docker compose -p paca-prod --env-file .env down
```

Volumes remain on disk.

</Step>
<Step title="Copy each volume">

Example for PostgreSQL:

```bash
docker volume create paca_postgres_data
docker run --rm \
  -v paca-prod_postgres_data:/from \
  -v paca_postgres_data:/to \
  alpine sh -c "cp -av /from/. /to/"
docker volume rm paca-prod_postgres_data
```

Repeat for `minio_data`, `valkey_data`, `backend_plugins`, `frontend_plugins`, and `mcp_plugins` as needed. Map `paca-prod_<suffix>` → `paca_<suffix>`.

</Step>
<Step title="Start under the new project name">

```bash
docker compose --env-file .env up -d
```

The API applies any new core migrations against the copied database.

</Step>
</Steps>

<Check>
Fresh installs skip volume migration entirely. Only rename when upgrading an existing `paca-prod` deployment that must keep data.
</Check>

## Verification signals

Paca exposes no dedicated `/version` endpoint. Confirm a successful upgrade through container health, HTTP probes, logs, and UI smoke checks.

### HTTP health endpoints

| Probe | Path | Auth | Expected response |
|---|---|---|---|
| API (direct or via gateway) | `GET /api/healthz` | None | `200` with `{"status":"ok"}` |
| Realtime (internal) | `GET /healthz` on port 3001 | None | `200` with `{"status":"ok"}` |
| Gateway (E2E compose pattern) | `GET /api/healthz` through port 80 | None | `200` with `{"status":"ok"}` |

<RequestExample>

```bash
curl -fsS http://localhost/api/healthz
```

</RequestExample>

<ResponseExample>

```json
{"status":"ok"}
```

</ResponseExample>

### Docker Compose health checks

Production `docker-compose.prod.yml` defines health checks that gate dependent services:

| Service | Health check target | Notes |
|---|---|---|
| `postgres` | `pg_isready` | API `depends_on` with `service_healthy` |
| `valkey` | `valkey-cli ping` | API and realtime wait for healthy |
| `minio` | `mc ready local` | API waits when MinIO is enabled |
| `api` | `wget` → `http://localhost:8080/api/healthz` | `start_period: 40s` allows migration + bootstrap time |

When `api` becomes healthy, `realtime` and `gateway` start. A stuck `api` container in `starting` state usually indicates a migration or bootstrap failure.

### Log signals

After a successful core migration pass, API logs include:

```text
schema migrations applied
```

Plugin migrations log per file:

```text
plugin: applying migration  plugin=<name> file=<filename>
plugin: migration applied   plugin=<name> file=<filename>
```

Inspect API logs when health checks fail:

```bash
docker compose logs api --tail=100
```

### Post-upgrade smoke checks

| Check | Pass criteria |
|---|---|
| Web UI | Login page loads at `PUBLIC_URL` |
| Authenticated API | Existing sessions work (if `JWT_SECRET` unchanged) or fresh login succeeds |
| Realtime | Board or task updates propagate without refresh |
| Plugins | Installed plugins appear in Settings; no migration errors in API logs |
| Object storage | File attachments upload and download |
| AI agent (if enabled) | Agent conversations start without auth errors between `AGENT_API_KEY` and `INTERNAL_API_KEY` |

## Contributor stack upgrades

For local development with `deploy/docker-compose.dev.yml` (project name `paca-dev`):

<Steps>
<Step title="Update source">

```bash
git pull
```

</Step>
<Step title="Restart application services">

If running in containers:

```bash
docker compose -f deploy/docker-compose.dev.yml restart api web realtime
```

If running API on the host (`make run` or `air`), stop and restart the process. Migrations run on the next bootstrap.

</Step>
<Step title="Rebuild when base images changed">

```bash
docker compose -f deploy/docker-compose.dev.yml up -d --build api realtime
```

</Step>
</Steps>

Dev Postgres data persists in the `paca-dev_postgres_data` volume across restarts. To reset completely:

```bash
docker compose -f deploy/docker-compose.dev.yml down -v
```

## Failure modes

| Symptom | Likely cause | Direction |
|---|---|---|
| `api` stuck unhealthy / restart loop | Core migration SQL error | Read `docker compose logs api`; fix DB state or roll back image tag |
| Empty database after upgrade | New Compose project name without volume copy | Run [volume migration](#compose-project-renamed-volume-migration) or restore backup |
| Sessions invalidated | `JWT_SECRET` changed in `.env` | Expected; users re-login |
| Plugin secrets / agent LLM keys broken | `ENCRYPTION_KEY` changed | Restore previous key from backup; data cannot be re-decrypted otherwise |
| Plugin features missing | Plugin migration failed at startup (logged, non-fatal) | Check logs; reinstall or upgrade plugin from marketplace |
| `401` on agent tasks | `AGENT_API_KEY` / `INTERNAL_API_KEY` mismatch after `.env` edit | Align keys across `api` and `ai-agent` services, restart both |

## Related pages

<CardGroup>
<Card title="Deploy production" href="/deploy-production">
Production Compose topology, gateway routing, storage backends, and secret generation.
</Card>
<Card title="Installation" href="/installation">
First-time install script and manual Compose setup prerequisites.
</Card>
<Card title="Quickstart" href="/quickstart">
Health endpoint verification and first successful login flow.
</Card>
<Card title="Install marketplace plugins" href="/install-marketplace-plugins">
Plugin install and upgrade lifecycle including per-plugin SQL migrations.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Image variables, database URLs, and encryption keys that must stay stable across upgrades.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, Valkey connectivity, and auth cookie issues after restarts.
</Card>
</CardGroup>

---

## 23. Contributing

> Repository layout, dev prerequisites, PR checklist, test surfaces (Go integration, Playwright e2e), and documentation update expectations.

- Page Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/pages/23-contributing.md
- Generated: 2026-06-13T22:14:29.520Z

### Source Files

- `CONTRIBUTING.md`
- `CODE_OF_CONDUCT.md`
- `docs/guides/local-development.md`
- `apps/e2e/README.md`
- `services/api/.golangci.yml`
- `.github/PULL_REQUEST_TEMPLATE.md`

---
title: "Contributing"
description: "Repository layout, dev prerequisites, PR checklist, test surfaces (Go integration, Playwright e2e), and documentation update expectations."
---

Paca is a monorepo with path-filtered PR CI per runtime area (`services/api`, `apps/web`, `apps/mcp`, `services/realtime`, `services/ai-agent`, `apps/e2e`). Each workflow runs lint, build, and tests appropriate to that stack; API changes additionally split unit/integration tests from Docker-backed Go E2E tests under `services/api/test/e2e/`.

## Repository layout

:::files
paca/
├── README.md
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── SECURITY.md
├── ROADMAP.md
├── .github/                    # CI workflows and PR templates
├── docs/                       # Architecture, guides, API, deployment, plugins
├── apps/
│   ├── web/                    # React + TanStack Start + shadcn/ui frontend
│   ├── mcp/                    # @paca-ai/paca-mcp MCP server (npm package)
│   └── e2e/                    # Playwright browser test suite (not deployed)
├── services/
│   ├── api/                    # Go + Gin application backend
│   ├── realtime/               # Node.js + Socket.IO real-time fan-out
│   └── ai-agent/               # Python + FastAPI + OpenHands SDK agent runtime
├── skills/                     # Claude Code slash-command skill definitions
├── scripts/                    # Install and plugin management scripts
└── deploy/
    ├── docker-compose.dev.yml  # Hot-reload dev stack
    ├── docker-compose.prod.yml
    ├── docker-compose.e2e.yml  # Fixed-credential stack for Playwright
    └── nginx/                  # Gateway config mounted into nginx container
:::

| Area | Role |
|---|---|
| `apps/web` | User-facing UI; Vite HMR in dev |
| `apps/mcp` | MCP server published as `@paca-ai/paca-mcp` |
| `apps/e2e` | Playwright tests that exercise `apps/web` against a running stack |
| `services/api` | System-of-record HTTP API, migrations, domain events |
| `services/realtime` | Socket.IO fan-out from Valkey events |
| `services/ai-agent` | OpenHands conversation runtime and Docker workspaces |
| `docs/` | Durable technical writing kept out of the root README |
| `deploy/` | Compose files and nginx gateway configuration |

<Note>
`apps/e2e` lives under `apps` because it versions alongside `apps/web` and directly exercises the web surface. It is not a deployable runtime.
</Note>

## Prerequisites

<Tabs>
<Tab title="Full containerized stack">

Docker with the Compose plugin is the only hard requirement. One command starts every service with hot-reload:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d
```

Open `http://localhost`. Services watch local source files and reload automatically.

</Tab>
<Tab title="Host-side development">

For IDE debugging or faster iteration on a single service, start infra only and run the target service on the host:

| Toolchain | Version | Used by |
|---|---|---|
| Go | 1.26+ (`go.mod` pins `1.26.0`) | `services/api` |
| Bun | 1.x | `apps/web`, `services/realtime`, `apps/mcp`, `apps/e2e` |
| Python + uv | 3.12+ | `services/ai-agent` |
| Docker | Compose plugin | Postgres, Valkey, MinIO containers |

```bash
# Infra only
docker compose -f deploy/docker-compose.dev.yml up -d postgres valkey

# API (first time: cp .env.example .env)
cd services/api && make run

# Web (first time: bun install)
cd apps/web && bun run dev

# Realtime (first time: bun install)
cd services/realtime && bun run dev

# AI agent (first time: uv sync)
cd services/ai-agent && uv run uvicorn src.main:app --reload --port 8000
```

Default dev credentials (`admin` / `adminpassword`, `paca:paca@localhost:5432/paca`) are intentionally weak — never use them outside local development.

</Tab>
</Tabs>

## Contribution workflow

<Steps>
<Step title="Fork and branch">

Clone the repository and create a focused branch for one concern. Keep pull requests scoped — the PR template and `CONTRIBUTING.md` both require single-concern changes.

</Step>
<Step title="Set up the dev environment">

Follow the containerized or host-side setup above. For a complete walkthrough of ports, nginx routing, and migration behavior, see the local development guide.

</Step>
<Step title="Make and verify changes">

Run the lint and test commands for every area you touch before opening a PR. Path-filtered CI mirrors these commands but does not run Playwright browser tests automatically.

</Step>
<Step title="Update documentation">

When behavior, interfaces, or architecture change, update the matching `docs/` section. Explain non-obvious tradeoffs in the PR description.

</Step>
<Step title="Open a pull request">

Fill in `.github/PULL_REQUEST_TEMPLATE.md`: summary, change type, and checklist. CI runs automatically for paths under the changed area.

</Step>
</Steps>

## Code quality by area

| Area | Linter | Local command | CI workflow |
|---|---|---|---|
| `services/api` | golangci-lint v2 | `make lint` | `api-pr-ci` |
| `apps/web` | Biome | `bun run lint` | `web-pr-ci` |
| `apps/mcp` | Biome | `bun run lint` | `mcp-pr-ci` |
| `services/realtime` | Biome | `bun run lint` | `realtime-pr-ci` |
| `services/ai-agent` | ruff (lint + format) | `uv run ruff check src/` | `ai-agent-pr-ci` |
| `apps/e2e` | Biome | `bun run lint` | `e2e-ci` |

### API lint configuration

`services/api/.golangci.yml` enables `errcheck`, `govet`, `staticcheck`, `revive`, `gocritic`, `bodyclose`, `noctx`, `exhaustive`, and others. Formatters include `gofmt` and `goimports` with local prefix `github.com/Paca-AI/api`. Test files relax `errcheck` and `gocritic` rules.

## Test surfaces

Paca has four distinct test layers. Know which layer applies to your change before choosing commands.

### Go unit tests

Scattered through `services/api/internal/` (for example handler-level tests). Run with the race detector:

```bash
cd services/api
go test -race -timeout 60s ./...
# or
make test
```

CI excludes `test/e2e` and uploads a coverage artifact:

```bash
go test -race -timeout 60s -coverprofile=coverage.out $(go list ./... | grep -v '/test/e2e')
```

### Go HTTP integration tests

Package `integration_test` under `services/api/test/integration/` exercises HTTP handlers and services through in-memory fakes and `miniredis` — no external Postgres or Valkey required. These run as part of the standard `go test ./...` invocation and in the `api-pr-ci` unit job.

Coverage areas include auth, users, projects, tasks, views, attachments, documents, plugins, API keys, agent API keys, admin authorization, and cache behavior.

```bash
cd services/api
go test -race -timeout 60s ./test/integration/...
```

### Go API E2E tests (testcontainers)

`services/api/test/e2e/` spins up real Postgres, Valkey, and MinIO containers via `testcontainers-go`, applies migrations, wires the full service stack, and hits an in-process `httptest.Server`. Requires Docker and the `PACA_E2E=1` gate:

```bash
cd services/api
PACA_E2E=1 go test -v -timeout 300s ./test/e2e/...
```

<Warning>
Without `PACA_E2E=1`, e2e tests skip with `set PACA_E2E=1 to run e2e tests (requires Docker)`. CI sets this env var in the dedicated `test-e2e` job, which depends on the `build` job completing first.
</Warning>

Test files cover auth flows, user/project/task/view management, attachments, API keys, plugins, pagination, and global-role authorization.

### Web unit tests (Vitest)

`apps/web` uses Vitest for component and API-client tests:

```bash
cd apps/web
bun run test        # single run
bun run test:watch  # watch mode
```

CI runs `bun run lint`, `bun run test`, and `bun run build`.

### Playwright browser E2E tests

`apps/e2e/` is a Playwright suite that exercises the web UI against a live application stack. It is separate from Go API e2e tests.

**Prerequisites:** Bun ≥ 1.0 and a running stack. Use either the dev compose file or the dedicated E2E compose file with test-safe credentials:

<CodeGroup>
```bash title="Dev stack"
docker compose -f deploy/docker-compose.dev.yml up -d
```

```bash title="E2E stack (recommended for Playwright)"
docker compose -f deploy/docker-compose.e2e.yml up -d --build --wait
```
</CodeGroup>

Setup and run:

```bash
cd apps/e2e
bun install
bunx playwright install --with-deps   # first time only
cp .env.example .env
bun test
```

| Variable | Default | Purpose |
|---|---|---|
| `E2E_BASE_URL` | `http://localhost` | Running app URL |
| `E2E_USERNAME` | `admin` | Test account username |
| `E2E_PASSWORD` | `e2e-admin-password` | Test account password |

<Info>
The E2E compose file sets `ADMIN_PASSWORD: e2e-admin-password`. The dev compose file uses `adminpassword`. Match `.env` credentials to whichever stack you started.
</Info>

Additional Playwright commands:

| Command | Purpose |
|---|---|
| `bun run test:ui` | Interactive Playwright UI |
| `bun run test:headed` | Visible browser window |
| `bun run test:debug` | Step-through debugging |
| `bun run test:report` | Open last HTML report |

Test layout:

```
apps/e2e/
├── global-setup.ts         # logs in once, saves auth state
├── playwright.config.ts
├── fixtures/               # extended test fixture with page objects
├── pages/                  # Page Object Model classes
├── features/               # Gherkin specs mirroring coverage (not wired to a runner)
└── tests/
    ├── auth/
    ├── validation/
    ├── security/
    ├── session/
    └── ux/
```

`global-setup.ts` writes browser auth state to `playwright/.auth/user.json` (git-ignored). Session tests load it via `test.use({ storageState: AUTH_FILE })`; other suites start unauthenticated for isolated login flows.

On CI (`CI=true`), Playwright config uses 1 worker, 2 retries, and projects for Chromium, Firefox, WebKit, Pixel 5, and iPhone 12. The `e2e-ci` workflow currently runs Biome lint only — contributors must run the Playwright suite locally before merging UI changes.

### AI agent unit tests

`services/ai-agent/tests/` runs with pytest; no external services required:

```bash
cd services/ai-agent
uv sync
uv run pytest --tb=short
```

CI runs ruff lint/format check, pytest, and a Docker image build.

## Pull request checklist

Use `.github/PULL_REQUEST_TEMPLATE.md` and `CONTRIBUTING.md` as your gate before requesting review:

<Check>
The change is scoped to one concern.
</Check>
<Check>
Tests are added or updated for changed behaviour in the affected test layer (Go integration, Go e2e, Vitest, Playwright, or pytest).
</Check>
<Check>
Related `docs/` files are updated when behaviour, interfaces, or architecture change.
</Check>
<Check>
Non-obvious decisions and tradeoffs are explained in the PR description.
</Check>
<Check>
Premature abstraction is avoided — add reuse only when proven.
</Check>

PR change types from the template: Documentation, Repository structure, Architecture clarification, Scaffolding, or Other.

## Documentation update expectations

The `docs/` directory is the main technical documentation home. Principles from `docs/README.md`:

- Keep documents short and navigable.
- Document decisions before implementation details.
- Prefer stable concepts over framework-level churn.
- Keep the root README product-focused; put technical detail here.

When your change touches a surface, update the matching section:

| Change type | Update |
|---|---|
| HTTP API paths, envelopes, auth | `docs/api/` |
| Architecture, service boundaries, DB schema | `docs/architecture/` |
| Dev setup, MCP setup, design system | `docs/guides/` |
| Plugin system, SDK, marketplace | `docs/plugins/` |
| AI agent behaviour, streams, skills | `docs/ai-agent/` |
| Production compose, secrets, scaling | `docs/deployment/` |
| Product concepts and workflows | `docs/product/` |

<Tip>
If you add a new env var, endpoint, or extension point, update both the relevant `docs/` page and any `.env.example` in the affected service.
</Tip>

## Community standards

### Code of conduct

`CODE_OF_CONDUCT.md` applies to repository discussions, issues, pull requests, and other community spaces. Participants communicate respectfully, criticize ideas rather than people, and must not engage in harassment or discrimination. Report violations through private maintainer contact — not public GitHub issues.

### Security

Do not open public issues for security vulnerabilities. Use [GitHub Security Advisories](https://github.com/Paca-AI/paca/security/advisories/new) per `SECURITY.md`. Scope includes auth/authz risks, data exposure, unsafe agent actions, WASM sandbox escapes, supply chain issues, deployment misconfiguration, and injection risks.

## Discussion areas

Contributors are welcome to discuss:

- Product workflow and user experience
- Service responsibilities and system boundaries
- Plugin system design and extension points
- AI agent behaviour and collaboration model
- Open-source governance and contributor experience

## Related pages

<CardGroup>
<Card title="Local development" href="/local-development">
Dev Compose stack, hot-reload per service, host-side run commands, and nginx port map.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Monorepo runtime areas, service boundaries, and Valkey event decoupling.
</Card>
<Card title="Build a plugin" href="/build-plugin">
Plugin authoring workflow when contributing to the extension system.
</Card>
<Card title="REST API reference" href="/rest-api">
Endpoint catalog to cross-check when adding or changing API behaviour.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Common install and runtime failures during local contribution setup.
</Card>
</CardGroup>

---
