# Pile — full documentation # ===== docs/AUDIT-crikket.md ===== # Crikket (local) Audit Source: `/Users/shlomokabareti/Projects/crikket` (README, SDK README, server routes, bug-reports package, docs). ## 1. What Crikket Is Open-source, self-hostable bug reporting. Modern alternative to Jam.dev and Marker.io. Built with Bun + Turborepo. - **Capture:** screenshot or screen recording from browser. - **Context:** console logs, network requests, device info, steps, reproduction metadata. - **Share:** public or private links. - **Embed:** `@crikket-io/capture` SDK with public key + allowed origins. ## 2. Monorepo Layout | Path | Purpose | | ---------------------- | ------------------------------------- | | `apps/web` | Next.js dashboard | | `apps/server` | Hono API (auth, capture, bug reports) | | `apps/docs` | Marketing + docs | | `apps/extension` | Browser extension | | `sdks/capture` | Embeddable browser capture SDK | | `packages/bug-reports` | Domain logic for bug reports | ## 3. Capture Flow 1. **Client:** `init({ key, host })` mounts a floating launcher. 2. **Token:** `POST /api/embed/capture-token` returns a short-lived token. 3. **Upload session:** `POST /api/embed/bug-report-upload-session` creates a pending upload with metadata. 4. **Upload:** client uploads video/screenshot and debugger payload to object storage. 5. **Finalize:** `POST /api/embed/bug-report-finalize` completes the report, returns `shareUrl`, `id`. ## 4. Bug Report Object `packages/bug-reports/src/lib/upload-session.ts`: - `title` (optional, 200 chars) - `description` (optional, 3000 chars) - `priority` (enum, default `none`) - `tags` (up to 20 strings) - `url` - `attachmentType`: `video` | `screenshot` - `visibility`: `public` | `private` - `metadata`: duration, page title, SDK version, submitted via, thumbnail URL - `deviceInfo`: browser, OS, viewport - `captureContentType` - `hasDebuggerPayload` - `debuggerSummary`: counts of actions, logs, network requests Key insight: the actual debugger payload is uploaded as a separate artifact; the bug report record only keeps a summary. The media and debugger data are object-storage artifacts. ## 5. Domain Tables (inferred from Drizzle schema) - `bugReport` — the report record. - `bugReportUploadSession` — pending upload state, TTL 24h. - Artifacts stored in S3/R2-compatible object storage with presigned URLs. ## 6. Auth / Security - `better-auth` for workspace auth. - `public keys` for SDK embeds. Allowed origins restrict where the widget can run. - `x-crikket-public-key`, `x-crikket-capture-token`, `x-crikket-capture-finalize-token` headers. - Rate limiting on capture and RPC endpoints. - CORS explicitly configured for `api/embed/*` routes. ## 7. Notable Primitives - **Public key per surface:** one key per website/app. - **Upload session + finalize pattern:** separates reservation from actual upload, avoiding large payloads in the main request. - **Debugger artifact key:** `buildDebuggerArtifactKey`, `buildCaptureArtifactKey`. - **Ingestion jobs:** async processing after finalize. - **Orphan cleanup:** periodic cleanup of stale pending uploads and artifacts. - **Entitlements:** checks usage limits before creating a report. ## 8. What to Borrow for Pile - **Capture channel as a first-class support channel.** A bug report is just a support ticket with `source_channel = capture`. - **Public key + origin allowlist for embeds.** Reuse for the in-app chat / capture widget. - **Upload session pattern for large attachments (screenshot/video/debugger).** R2 for artifacts, D1 for ticket metadata. - **Debugger payload as an attachment.** The `support_ticket_events` table can point to `attachments` for console logs / network JSON. - **Bug report → issue tracker promotion.** Crikket has share links; Pile can promote a capture to a `support_ticket` and then to a Pile `issue` via `issue_id`. - **Visibility (public/private) on captures.** Useful for customer share links vs internal reports. ## 9. Gaps to Decide - Crikket is a dedicated bug-reporting product, not a full support inbox. Its "team" model is simpler. Pile should not copy the entire Crikket dashboard; just the capture + report primitives. - Crikket uses Bun. Pile uses pnpm/Cloudflare Workers. Do not copy the monorepo shape; only the data model and API flow. # ===== docs/AUDIT-jam.md ===== # Jam.dev Audit Source: `https://jam.dev/docs/llms.txt` and `https://jam.dev/docs/whats-in-a-jam.md`. ## 1. What Jam Is Hosted bug reporting tool. Captures bugs with screenshot, screen recording, or Instant Replay. Attaches console logs, network requests, user events, and device info. Routes reports to issue trackers, support desks, Slack, or AI agents. ## 2. Capture Methods | Method | How | | -------------------- | --------------------------------------------------------------------- | | Chrome extension | One-click screenshot/tab recording/desktop recording. | | iOS app | Mobile bug reports. | | Recording Links | No-install, no-account; share a URL, customer records on the site. | | Instant Replay | Rewind last 2 minutes of activity. | | SDK (`@jam.dev/sdk`) | `jam.metadata()` attaches custom logs (user ID, feature flags, etc.). | ## 3. What's in a Jam - Visual context (screenshot or video). - Custom logs / metadata. - DevTools: console logs, network requests, user events. - Device and browser details. - Figma embed support. - AI-generated titles and repro steps (`Jam AI`). ## 4. Integrations - Issue trackers: Jira, Linear, GitHub, GitLab, Asana, ClickUp, Azure DevOps, Notion. - Support desks: Zendesk, Freshdesk, HubSpot, ServiceNow, Jira Service Management, Intercom (Fin). - Chat/AI: Slack, Sentry, FullStory, LogRocket, MCP for Claude/Cursor/VS Code. - Webhooks: `jam.dev/docs/webhooks` for custom event fan-out. - Recording Links for customer support: request a recording inside Zendesk/Freshdesk/HubSpot/ServiceNow/Jira/Intercom. ## 5. Workspaces & Access - Members and roles: Viewer, Creator, Admin. - Default access per Jam + per-Jam visibility overrides. - Audit logs for access/membership changes. - SSO. - SOC 2 Type II, AES-256, automatic blurring. ## 6. Developer API Surface - **SDK:** `jam.metadata()` for custom logs; `npm i @jam.dev/sdk`. - **MCP server:** exposes tools to read Jam details, console logs, network requests, frames, transcripts. - **CLI:** `jam` CLI to authenticate, read/write Jams, record, manage recording links. - **Personal access tokens:** for headless / CI. - **Webhooks:** new Jam created events. ## 7. Key Primitives - **Capture token / public key?** Jam uses browser extension and recording links; Crikket is closer to the embeddable public-key model. - **Metadata is live:** `jam.metadata()` callback is invoked at capture time, not page load. - **DevTools as first-class context:** console, network, user events are core, not addons. - **Recording Links for support:** no install on customer side; the link opens a recorder on the page. - **AI is post-capture:** titles and repro steps, plus MCP for coding agents. ## 8. What to Borrow for Pile - **Capture as a support channel.** A support ticket can be created from a Jam-style capture, with attachments for screenshot/video and debugger JSON. - **Recording links for support.** Send a customer a link to record their issue without an extension. - **`jam.metadata()` pattern.** A `pile.capture.metadata()` SDK call on the customer's site attaches custom data to the ticket. - **DevTools attachment per ticket.** `support_ticket_messages` can link to attachments for console log and network JSON. - **MCP / AI agent integration.** Pile's MCP layer can expose the same "read Jam context" tools. - **Issue tracker promotion.** Jam turns captures into Jira/Linear/GitHub issues. Pile can turn a capture `support_ticket` into a Pile `issue` via `issue_id`. ## 9. Gaps to Decide - Jam is SaaS-first and does not expose a self-hosted embed SDK like Crikket. Pile can build a Crikket-style SDK (`@pile/capture`) for its own capture channel. - Jam's AI features are hosted. Pile provides primitives; customers bring their own model. # ===== docs/AUDIT-plain.md ===== # Plain.com Backend Audit Source: Plain GraphQL API docs, agent docs, webhook docs, and operation index (`https://www.plain.com/docs/llms-full.txt`). ## 1. Architecture Overview - **GraphQL-first API** at `https://core-api.uk.plain.com/graphql/v1`. - **Single endpoint**, single `POST` transport, everything is a query or mutation. - **API keys with fine-grained permissions**; supports machine users for agents. - **Webhooks** are signed with `plain-request-signature` header; SDK available for verification. - **Agent-native**: built so external agents can receive events and act on threads with the same API as human users. ## 2. Core Data Model ### 2.1 Threads (conversations/tickets) The central object. Threads are created on inbound messages or via API. They carry state, priority, assignment, labels, custom fields, a timeline of events/messages, and discussions. - **State**: todo / done (and likely more via labels/fields). - **Priority**: modeled as a first-class field. - **Customer**: linked to one customer. - **Additional assignees**: supports multiple assignees. - **Timeline**: chronological entries (emails, chat messages, notes, events). - **Discussions**: side-conversations within a thread. - **Fields**: custom thread fields with schema. - **Links**: links to other threads. ### 2.2 Customers Individual contacts. Can belong to tenants/groups, have cards, events, surveys, and custom fields. - **Email / identity**. - **Customer groups** for segmentation. - **Tenants** (companies/organizations) a customer belongs to. - **Customer cards**: UI cards rendered from JSON for rich customer context. - **Customer events**: timeline events on the customer (e.g., `createCustomerEvent`). - **Surveys**: customer satisfaction / feedback. ### 2.3 Tenants Companies or organizations. Customers can be in multiple tenants. Tenants have fields and schemas. ### 2.4 Users & Machine Users - **Users**: human team members, with roles, status (active/away), tiers, billing rotas. - **Machine users**: agent identity, has public name, API keys, permissions. - **Roles & custom roles**: permission system. - **Tiers**: user grouping (e.g., support tiers). ### 2.5 Workspace - **Settings**: email domains, support email addresses, Slack/Discord/Teams channel integrations. - **API keys / webhooks**. - **Billing plan & credits**. ## 3. Messaging & Channels Plain supports multiple native channels and a custom-channel protocol: - **Email**: inbound/outbound, email verification, signatures, previews. - **Slack**: workspace + channel + sidekick integrations; customer resolution from Slack. - **MS Teams**: workspace + channel integrations. - **Discord**: workspace + channel integrations. - **Chat**: chat apps, embed tokens, demo channels. - **API / custom channels**: `createThread`, `sendNewEmail`, `replyToThread`, custom channel protocol for Discourse/Hubspot/etc. - **Broadcasts**: bulk outbound messages (`createBroadcast`, `scheduleBroadcast`, `sendBulkEmail`). ## 4. Agent / AI Support Plain is explicitly built for agents: - **Machine users**: agent identity. - **Webhooks**: events like `thread.thread_created`, `thread.email_received`, `thread.assignment_changed`. - **MCP server**: exposes tools for threads, customers, tenants, help center, user/workspace. - **Agent skill / Cursor integration**: agent-assisted workflows. - **Sidekick**: custom skills and MCP server for agents. - **Suggested replies**: `addGeneratedReply` for agent-drafted but human-approved replies. - **Notes**: internal-only timeline entries. - **Routing**: workflows assign threads to machine users or users. - **AI tone rules**: generated tone guidance. - **AI feedback / approvals**: `createAiFeedback`, `resolveAgentApproval`. ## 5. Help Center & Knowledge - **Help center**: groups, articles. - **Knowledge sources**: indexed documents, help center, customer card data. - **Indexed documents**: `createIndexedDocument` for RAG. - **Search**: knowledge source search for agent prompts. - **Customer cards**: JSON-driven UI components shown in thread sidebar. ## 6. Productivity & Workflow - **Labels / label types**: classification, tags. - **Snippets**: canned responses (`createSnippet`). - **Tasks**: subtasks inside threads (`createTask`, `deleteTask`). - **Autoresponders**: auto-reply rules (`createAutoresponder`). - **Escalation paths**: routing/escalation rules. - **Workflows**: `createWorkflow`, `createWorkflowRule`, `createWorkflowStep`. - **Service level agreements (SLAs)**: `createServiceLevelAgreement`. - **Saved thread views**: `createSavedThreadsView`. - **Favorites pages / My favorite page**. ## 7. Integrations Plain has first-class integrations with many tools, each requiring auth and channel mappings: - **Linear** (`createLinearAppIntegration`, `createMyLinearIntegration`). - **GitHub** (`createGithubUserAuthIntegration`). - **Slack** (`createWorkspaceSlackIntegration`, `bulkJoinSlackChannels`). - **MS Teams** (`createMyMSTeamsIntegration`). - **Discord** (`createUserAuthDiscordChannelIntegration`). - **Hubspot** (custom channel example). - **Discourse** (custom channel example). - **Cursor** (`createWorkspaceCursorIntegration`). - **Hyperline** (`createHyperlineBillingPortalSession`, `createHyperlineComponentsAuthToken`). ## 8. Import & Migration - `importCustomers` - `importTenants` - `importThread` - `importThreadDiscussion` - `importThreadMessages` - `createImportSync` Imports preserve original timestamps and conversation history. ## 9. Webhooks & Events - Signed POST to a configured URL. - Events: thread created/updated, email received/sent, assignment changed, customer updated, etc. - SDK for verifying `plain-request-signature`. - Webhook targets and workflow-driven routing. ## 10. Billing & Credits - `changeBillingPlan` - `purchaseCredits` - `previewBillingPlanChange` - `calculateRoleChangeCost` - Usage/credit model. ## 11. Reporting & Feedback - Customer surveys. - AI feedback. - Saved views for queue triage. - Customer events for activity tracking. ## 12. Security & Admin - API keys with permissions (`createApiKey`, `deleteApiKey`). - User roles and custom roles (`createCustomRole`, `assignRolesToUser`). - Workspace invites and membership. - File uploads/downloads with signed URLs. - HMAC webhook signatures. ## 13. Key Takeaways for Pile 1. **Thread == Issue**: Plain's `Thread` is the closest analog to a Pile `Issue`. 2. **Customer + Tenant == Contact + Company/Workspace**: reuse the `support-contacts` model. 3. **Timeline == comments + history**: Pile `comments` and `issue_history` can model the thread timeline. 4. **Machine user == Agent**: already planned in Pile; agent sessions and activities exist. 5. **Labels, snippets, tasks, SLAs** map directly to Pile `labels`, `templates`/`comments`, `tasks` (new), `cycles`/deadlines. 6. **Channels** are ingestion: each is a webhook/POST that creates/updates a thread. 7. **Custom channel protocol** is the pattern for plugging in any external source (Discourse, Hubspot, etc.). 8. **Help center + knowledge sources** are documents; Pile documents can model them. 9. **GraphQL is the API shape**; Pile should expose REST/OpenAPI first, but the object model can mirror Plain's. 10. **MCP server is the agent interface**: Pile's MCP generation should expose the same verbs. ## 14. Gaps to Decide - Plain uses a **single workspace-wide email address**; Pile needs per-workspace support inboxes. - Plain has **native chat widget**; Pile would need a chat SDK or iframe. - Plain has **billing/credits** for support usage; Pile may need `ISS-5` billing first. - Plain's **customer cards** are UI components; Pile can defer to JSON-rendered components. # ===== docs/CAPABILITY-MAP-customer-support.md ===== # Capability Map: Customer Support Layer in Pile ## Objective Replace Intercom / Zendesk / Plain.com with an internal, developer-friendly and agent-friendly customer support surface nested inside Pile. The customer support tool should feel native to the issue tracker and to the agents that work it. Build order follows the data dependencies, but migration adapters from Intercom and Zendesk are used as the reference schema to validate the internal model as we build it. ## Assumptions 1. The support model is centered on **tickets** (a.k.a. conversations) and **contacts** (customers / companies / leads). 2. The first channel is **email** + a webhook/POST endpoint; Slack, SMS and in-app messenger come later. 3. In-app chat and agent-side messaging use the **Vercel AI SDK** (`ai` package) rather than a custom chat layer. 4. Agent identity, assignment and team membership reuse existing Pile `users`, `teams` and `organization_role` where possible. 5. Macros, canned replies and tags reuse the existing `labels` and `templates` concepts where possible. 6. Webhook HMAC verification follows the same pattern as the GitHub and GitLab handlers. ## Capabilities | Module id | Responsibility | Depends on | | ------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------- | | `support-contacts` | Customers, companies, leads and contact methods | `organization`, `user` | | `support-tickets` | Conversations / tickets: state, priority, source channel, assignment | `support-contacts` | | `support-team` | Agent assignment, away status, teams | `support-tickets`, existing `teams` | | `support-content` | Macros, canned replies, tags, auto-replies | `support-tickets` | | `support-capture` | Bug capture: screenshot, screen recording, console logs, network requests, device info | `support-tickets`, `support-contacts` | | `support-channels` | Ingestion endpoints: email, webhook, Slack, SMS, in-app, capture; in-app/agent chat uses Vercel AI SDK | `support-tickets`, `support-capture` | | `support-inbox` | Inbox views, queues, filters, real-time updates | `support-tickets`, `support-team` | | `support-migration` | Import and webhook sync from Intercom and Zendesk | `support-contacts`, `support-tickets` | ## Build Order 1. `support-contacts` 2. `support-tickets` 3. `support-capture` (parallel with `support-team` and `support-content`) 4. `support-team` (parallel with `support-content`) 5. `support-migration` (Intercom + Zendesk adapters validate the `support-contacts` and `support-tickets` schema) 6. `support-channels` (email, webhook, capture ingestion) 7. `support-inbox` ## Interfaces at the Boundaries - `support-contacts` exposes contacts by `external_id` + `organization_id`. - `support-tickets` exposes tickets by `number` (per-workspace) and `conversation_id`. - `support-migration` writes into `support-contacts` and `support-tickets` using the same D1 mapping pattern as `intercom_conversations`. - `support-channels` creates / appends to `support-tickets` and triggers `support-inbox` updates. ## Open Questions 1. Do we store support data in the per-workspace Durable Object SQLite or in D1? 2. Should tickets use the existing `workspaceIssues` table (with a `kind` or `source`) or a separate `support_tickets` table? 3. Should the in-app chat widget be a separate Worker or part of the issue tracker Worker? (Vercel AI SDK handles the chat layer in either case.) 4. Which channels are required for the first ship: email, Slack, SMS, in-app, or a subset? 5. Does the Vercel AI SDK chat use the same `support-tickets` data or a separate real-time conversation store? # ===== docs/SPEC-agent-dispatch-lifecycle.md ===== # Spec: Agent Dispatch Lifecycle Improvements ## Objective Make agent runs in Pile behave like Linear agent sessions: a single active session per issue, visible lifecycle state, automatic result/PR writeback to the issue, and lifecycle events that webhooks and realtime consumers can subscribe to. ## Tech Stack - Hono + `@hono/zod-openapi` for API routes - Drizzle ORM on D1 (metadata) and Durable Object SQLite (workspace state) - Zod for validation - Existing `WorkspaceDO` RPC methods and event/notification paths - No new dependencies ## Data Model Changes - `workspaceAgentSessions` schema unchanged. - `RealtimeEvent` gains `agent_session.created`, `agent_session.updated`, `agent_session.completed`, `agent_session.failed`, `agent_session.canceled`. - `AgentProviderSession` gains optional `prUrl`, `prState`, `branch` so providers can return PR artifacts separate from the session dashboard `url` and result text. ## API Surface No new routes. Existing routes updated: - `POST /workspaces/{organizationId}/issues/{id}/dispatch` - Rejects with `409` if an active session already exists for the issue. - Adds a `thought` activity immediately. - On provider failure, marks session `failed` and adds an `error` activity. - On success, applies the provider result through `applyAgentSessionResult`. - `POST /workspaces/{organizationId}/agent/sessions/{id}/poll` - Calls `applyAgentSessionResult` with the provider `AgentProviderSession`. - `PATCH /workspaces/{organizationId}/agent/sessions/{id}` - Calls `applyAgentSessionResult` with the patched fields. - `POST /workspaces/{organizationId}/agent/sessions/{id}/cancel` - Calls `applyAgentSessionResult` with `status: "canceled"`. ## Lifecycle Rules 1. **One active session per issue.** `getActiveAgentSessionForIssue` is checked before `dispatchAgent` creates a new session. 2. **Session status drives issue status only at start and on terminal PR states.** - `created`, `running`, `waiting` move the issue to `in_progress` if it is currently `triage`, `backlog`, or `todo`. - `completed` does not auto-close the issue. - `prState: "merged"` maps to `done` and `prState: "closed"` maps to `canceled` only when the issue is not already terminal. 3. **Provider results write back to the issue.** `prUrl`, `prState`, and `branch` are copied to the issue when present. 4. **Terminal completion creates a comment.** When a session transitions to `completed`, a comment is added with the agent result and PR link, attributed to the agent (`externalAuthor: agentId`, `externalSource: "agent"`). 5. **Failures are recorded, not rolled back.** A failed session becomes `failed` with an `error` activity. The issue stays in its current status for a human to recover. 6. **Lifecycle events are emitted.** `agent_session.created/updated/completed/failed/canceled` are emitted through the existing `emit` path, which also delivers webhooks. ## Plan Mode (PILE-283) Lanes can draft an approved approach before any code is written. - **Dispatch `mode`** on `POST /issues/{id}/dispatch`: `build` (default), `plan`, `implement_plan`. - `plan` dispatches a lane with `purpose: "plan"` and `PILE_LANE_MODE=plan`. The runner never commits, pushes, or opens a PR for it. If the issue already has a plan, the new lane revises it, and `instructions` is used as the feedback (PlanEdit). - `implement_plan` dispatches a normal build lane, with the latest plan included as approved instructions. Returns `400` if the issue has no plan. - **Plan comment.** When a plan lane completes, it posts its plan as an issue comment (`externalSource: "plan"`, `externalId: `). The newest such comment is the plan that `implement_plan` builds. - **Comment triggers** (only when the issue has no live lane): `/plan [feedback]` drafts or revises the plan; `/implement_plan` approves the latest plan and dispatches a build lane from it. If a lane is live, the comment is treated as a normal follow-up. - **Label trigger.** Adding a label named `plan` to an issue with no live lane dispatches a plan lane. - Triggers reuse the agent from the issue's most recent lane, then the repo default agent, then `devin`. All of these are still gated by the `.pile/config.json` allowlist. A trigger that fails posts a `plan-mode` notice comment. - Build lanes dispatched from a plan record a `lane.plan` session event that points to the plan's comment and session. ## Provider Parity - `devin.ts`: `poll` extracts `prUrl`/`prState` from `pull_requests[0]`; `url` remains the Devin session dashboard link. - `cursor.ts`: `poll` extracts `prUrl` and `branch` from `run.git.branches` when a `prUrl` is present. - `cf-agent.ts`: no PR fields; `url` is the conversation URL and `result` is the final assistant text. ## Testing Strategy - Unit/route tests in `src/api/agent-sessions.test.ts` and `src/agents/index.test.ts`. - Mock provider now returns `prUrl`/`prState`/`branch` to exercise writeback. - New tests: - Duplicate dispatch returns `409`. - Successful dispatch moves issue to `in_progress`. - Completed session writes `prUrl`/`prState` and creates a comment. - Failed dispatch leaves issue status unchanged and marks session `failed`. - Poll route writes PR fields from provider. ## Success Criteria - `pnpm run check` passes. - All agent session tests pass. - `dispatchAgent` and `applyAgentSessionResult` are the only two places that mutate session+issue lifecycle state. ## Boundaries - **Always:** run `pnpm run check` before commit; no `any`; no `eslint-disable`/`@ts-ignore`. - **Ask first:** adding new migrations or changing the auth stack. - **Never:** auto-merge; expose provider secrets; add frontend code. # ===== docs/SPEC-agent-sessions.md ===== # Spec: Agent Sessions and Activities API ## Objective Make agents first-class actors in Pile by tracking every agent run as a session with a stream of activities. A session is created when an agent is dispatched to an issue; activities capture thoughts, responses, errors, elicitations, and actions. The API lets users and other agents inspect progress, resume context, and audit agent work. This is not a new auth system. It is the session/activity surface on top of Pile's existing `WorkspaceIdentity` auth. Human and agent actors are both Better Auth `user` rows; agent users are marked with `metadata.type: "agent"`. Workspace access is verified through Better Auth `member` records, and API keys are Better Auth credentials linked to those users. The `actorId`/`actorType` fields map to `WorkspaceIdentity.id` (the underlying `user.id`) and `WorkspaceIdentity.type` (from `user.metadata` or API key metadata). ## Data Model D1 tables (organization-scoped metadata): - `agent_sessions` - `id` text PK - `organizationId` text FK organization.id - `issueId` text FK issue.id (logical; issues live in the Workspace DO) - `agentId` text — provider id, e.g. `"devin"`, `"mock"` - `provider` text — same as `agentId` for now; future may split provider from agent persona - `actorId` text — identity that started the session (from `WorkspaceIdentity.id`) - `actorType` text — `"user" | "agent"` - `status` text — `"created" | "running" | "waiting" | "completed" | "failed" | "canceled"` - `result` text nullable - `url` text nullable — external session URL (e.g. Devin session link) - `createdAt` / `updatedAt` text - `agent_activities` - `id` text PK - `sessionId` text FK agent_sessions.id - `actorId` text nullable — who emitted the activity; null for provider events - `type` text — `"thought" | "response" | "error" | "elicitation" | "action" | "status"` - `message` text - `payload` text nullable — JSON blob for structured data - `createdAt` text Indexes: - `agent_sessions_organization_idx` on `agent_sessions(organizationId, createdAt DESC, id)` - `agent_sessions_issue_idx` on `agent_sessions(issueId)` - `agent_activities_session_idx` on `agent_activities(sessionId, createdAt)` ## API Surface All routes live under `/workspaces/{organizationId}` and reuse `workspaceAuthMiddleware` (`rls("read")` / `rls("write")`). - `GET /workspaces/{organizationId}/agent/sessions` - List sessions for a workspace, optionally `?issueId=` filtered. - Sorted `createdAt DESC, id`. - `?summary=1` returns lane-row scalars only (`id`, `issueId`, `agentId`, `provider`, `status`, `prUrl`, `prState`, timestamps, `derivedStatus`) — no `result`, `activities`, or internal bookkeeping. Polling consumers (fleet TUI, dashboards) should use it; the same `?summary=1` flag works on `GET /agent/sessions/{sessionId}`. - `GET /workspaces/{organizationId}/agent/sessions/{sessionId}` - Get session with `activities` included (`?summary=1` for scalars only). - `POST /workspaces/{organizationId}/agent/sessions/{sessionId}/activities` - Append an activity. Body: `{ type, message, payload? }`. - Used by agents or provider webhooks to stream progress. - `POST /workspaces/{organizationId}/agent/sessions/{sessionId}/poll` - Poll the provider for the latest session state and update the record. - `PATCH /workspaces/{organizationId}/agent/sessions/{sessionId}` - Update `status`, `result`, `url` (e.g. by provider webhooks or admin). - `POST /workspaces/{organizationId}/issues/{issueId}/dispatch` - Existing dispatch route; updated to create an `agent_sessions` row and a `created` activity before returning. Authorization: - `read` permission: list/get sessions. - `write` permission: append activities, poll, patch, dispatch. ## Provider Integration - `dispatchAgent` in `src/agents/index.ts` creates a session via `createAgentSession` before calling the provider, then returns the persisted session (id, agentId, issueId, status, url). - Provider `poll` is invoked through `POST .../poll` or by an alarm; results update `status`, `result`, `url`. - Provider webhooks can patch status and append activities; no polling required for all providers. ## Tests - Unit tests in `src/global/agent-sessions.test.ts` for helper functions (create, list, get, add activity). - Route tests in `src/api/agent-sessions.test.ts` for list, get, append, patch, and dispatch creating a session. - Keep `src/agents/index.test.ts` passing; mock provider should create a session when called through dispatch. ## Boundaries - **Always:** use Zod for request/response schemas; validate `payload` as JSON; run `pnpm run check` before commit. - **Ask first:** changing the auth stack (e.g. replacing `workspaceTokens` with `@better-auth/api-key` or `@better-auth/agent-auth`); adding new migrations without generated SQL. - **Never:** store raw provider secrets; use `any` or unchecked `unknown`. ## Success Criteria - [ ] `POST /workspaces/{organizationId}/issues/{issueId}/dispatch` creates an `agent_sessions` row and returns the session id. - [ ] `GET /workspaces/{organizationId}/agent/sessions` returns sessions for the workspace. - [ ] `GET /workspaces/{organizationId}/agent/sessions/{sessionId}` returns the session with `activities`. - [ ] `POST .../activities` appends a validated activity. - [ ] `PATCH` and `poll` update session state. - [ ] `pnpm run check` passes. ## Better Auth alignment Current auth resolves to `WorkspaceIdentity { id, organizationId, type, permissions }`. The `actorId`/`actorType` columns in `agent_sessions` map directly to that identity. When Pile migrates agent auth to Better Auth (`@better-auth/api-key` for workspace-scoped keys or `@better-auth/agent-auth` for the Agent Auth Protocol), the session/activity surface does not change: the middleware resolves a `WorkspaceIdentity` and the session records `actorId`/`actorType`. # ===== docs/SPEC-gitlab-integration.md ===== # GitLab Integration — First Slice ## Goal Mirror GitLab issues and issue notes into Pile, matching the existing GitHub integration pattern. This is the smallest useful slice before merge requests, labels, milestones, and assignees. ## Scope ### In scope - GitLab project webhook ingestion: - `Issue Hook` events: `open`, `update`, `close`, `reopen` - `Note Hook` events on issues: create / update / delete issue notes - HMAC/token verification using `X-Gitlab-Token` and a configurable `GITLAB_WEBHOOK_SECRET`. - Issue mapping via `repoIssues` (add `source` enum defaulting to `github`; allow `gitlab`). - Comment mapping with `externalSource: "gitlab"` and `externalId` = GitLab note id. - Outbound writeback: when a Pile comment is added to a GitLab-mapped issue, post it as a GitLab issue note. - API routes under `/workspaces/{organizationId}/gitlab/install` and `/gitlab/users` to store per-workspace access token and username mappings. - New global tables `gitlab_installations` and `gitlab_users`, following `github_installations` / `github_users`. ### Out of scope (next slice) - Merge request sync (open/update/merge/close/draft, `fixes KEY-123` linking, `prState` updates). - MR notes / diff notes. - Label / milestone / assignee sync. ## Data model ### `gitlab_installations` | column | type | | -------------- | -------------------------------------------- | | id | text primary key | | organizationId | text not null | | projectId | text (GitLab project id) | | projectPath | text (e.g. `vortexnyc/pile`) | | token | text (encrypted at rest — not persisted raw) | | webhookSecret | text | | createdAt | text | | updatedAt | text | For the first slice the token is a GitLab personal/project access token provided by the workspace admin. We do not implement OAuth app flow. ### `gitlab_users` | column | type | | -------------- | ---------------- | | id | text primary key | | organizationId | text not null | | userId | text (Pile user) | | gitlabUsername | text | ### `repoIssues` extension Add `source: text` default `'github'`. Existing GitHub rows stay `github`; GitLab rows use `gitlab`. ## Webhook contract Inbound route: `POST /gitlab` (no authz middleware; verification is token-based). Headers: - `X-Gitlab-Event` — e.g. `Issue Hook`, `Note Hook` - `X-Gitlab-Token` — matches `GITLAB_WEBHOOK_SECRET` or installation `webhookSecret` Payloads are parsed with Zod. Duplicates are ignored by `object_attributes.id` / `object_attributes.note_id` + event type. ## Issue mapping GitLab project path (`project.path_with_namespace`) + issue IID (`object_attributes.iid`) maps to one Pile issue. - `open` → create issue if missing, otherwise update status to `backlog`/`triage`. - `update` → update title/description. - `close` → status `canceled` unless MR merged (out of scope). - `reopen` → status `backlog`. Issue identifiers follow existing `identifier` scheme (team key + number). If the workspace has no team mapping for the project, use default team. ## Comment mapping Issue note `object_attributes.note_id` is `externalId`; `externalSource` is `"gitlab"`. Create/update/delete are handled via `Note Hook` `object_attributes.action`. ## Outbound writeback When `src/api/comments.ts` creates a comment on an issue whose `repoIssues.source = 'gitlab'`, post the comment body to the GitLab issue notes endpoint using the stored token and record the returned note id as `externalId`. ## Env / config - `GITLAB_WEBHOOK_SECRET` — default fallback for webhook verification. - `GITLAB_API_URL` — optional, defaults to `https://gitlab.com/api/v4`. ## Files to add / modify - `src/types/env.ts` — add `GITLAB_WEBHOOK_SECRET`, `GITLAB_API_URL`. - `src/global/schema.ts` — add `gitlab_installations`, `gitlab_users`; add `source` to `repoIssues`. - `src/global/db.ts` — no change unless new helpers. - `src/global/gitlab-auth.ts` — token fetch/header builder. - `src/global/gitlab-installations.ts` — CRUD. - `src/global/gitlab-users.ts` — CRUD. - `src/agents/gitlab.ts` — webhook verifier + event dispatcher. - `src/api/gitlab.ts` — install / user routes. - `src/api/index.ts` — register `POST /gitlab`. - `src/api/comments.ts` — outbound GitLab note writeback. - `src/workspace/durable-object.ts` — `findCommentByExternalId` already exists; ensure `externalSource` handling covers `gitlab`. ## Verification - `pnpm run typecheck` - `pnpm exec vp check` - `pnpm test` - `pnpm run knip` - Manual webhook test with `ngrok`/`cloudflared` or a GitLab test project. ## Future slices 1. ✅ Merge request sync and `fixes KEY-123` / `closes KEY-123` parsing. 2. ✅ MR notes / diff notes. 3. ✅ Label, milestone, and assignee sync. ## Out of scope for this integration - Pipeline / CI status sync. - Branch/commit push hooks. - GitLab OAuth app flow. # ===== docs/SPEC-gitlab-mr-sync.md ===== # GitLab Integration — Merge Request Sync ## Objective Extend the GitLab webhook integration to mirror GitLab merge requests into Pile the same way GitHub pull requests are handled: update a linked Pile issue's `prState`, `prUrl`, `repo`, and `branch`, and parse `fixes KEY-123` / `closes KEY-123` references in MR title, description, or source branch. This is the second GitLab slice, building directly on the issue/note webhook work in `docs/SPEC-gitlab-integration.md`. ## In scope - `Merge Request Hook` events: `open`, `update`, `merge`, `close`, `reopen` (and draft transitions). - Map MR state to Pile `prState`: `draft`, `opened`, `merged`, `closed`. - Call `WorkspaceDurableObject.updatePrState(repo, branch, prUrl, prState, "gitlab")`. - Parse `fixes|closes|resolves KEY-123` from MR title, description, and `source_branch`. - Call `WorkspaceDurableObject.updatePrByIdentifier(identifier, prUrl, prState, repo, branch, "gitlab")` for each found identifier. - `Note Hook` events on merge requests (`object_attributes.noteable_type === "MergeRequest"`). - Resolve the linked Pile issue by `repo` + `source_branch`. - Create / update / delete Pile comments with `externalSource: "gitlab"` and the GitLab note ID. - Zod schemas for the new payloads (no `any`). - Idempotent delivery handling via `webhookDeliveries`. - Generated OpenAPI / MCP / client / CLI parity. - Integration tests for: - MR open updates an existing issue by branch. - MR `fixes KEY-123` links an MR to an issue by identifier and backfills `repo`/`branch`. - MR close/merge sets `prState` and status. - MR note create/update/delete flows into the linked issue's comments. ## Out of scope (next slice) - Pipeline / CI status sync. - MR diff notes / code review comments on specific files. - GitLab branch/commit push hooks. - Full label/milestone/assignee reconciliation for MRs. ## Data model No new D1 tables. Reuses: - `gitlab_installations` / `gitlab_users` from the first slice. - `repoIssues` with `source: "gitlab"` for issue-level mappings (not used for MRs; MRs are linked by `repo` + `branch` or by identifier). - `workspaceIssues.repo`, `workspaceIssues.branch`, `workspaceIssues.prUrl`, `workspaceIssues.prState`. ## Webhook contract Inbound route: `POST /gitlab` (already public in `security.ts`). New `object_kind` values handled: - `merge_request` - `note` where `object_attributes.noteable_type === "MergeRequest"` (issue notes are already handled) ### `merge_request` payload shape ```ts { object_kind: "merge_request", event_type: "merge_request", project: { id, path_with_namespace }, object_attributes: { id, // note id iid, // MR number title, description: string | null, state: "opened" | "closed" | "merged" | "locked", action: "open" | "update" | "close" | "reopen" | "merge" | ..., draft: boolean, work_in_progress: boolean, source_branch, target_branch, url, source: { path_with_namespace, id }, target: { path_with_namespace, id }, created_at, updated_at, }, author: { username, name }, changes?: { ... }, } ``` ### `note` on MR payload shape ```ts { object_kind: "note", event_type: "note", project: { id, path_with_namespace }, object_attributes: { id, note: string, noteable_type: "MergeRequest", noteable_id: number, // MR iid action: "created" | "updated" | "deleted", created_at, updated_at, }, merge_request: { iid, source_branch, source: { path_with_namespace }, target: { path_with_namespace }, }, author: { username, name }, } ``` ## Implementation notes - Use the same `parseIssueIdentifiers` regex already used for GitHub PRs. - `repo` for the MR should prefer `object_attributes.source.path_with_namespace` when available, falling back to `project.path_with_namespace`. - `branch` is `object_attributes.source_branch`. - `prState` precedence: `draft || work_in_progress` → `"draft"`; `state === "merged"` → `"merged"`; `state === "closed"` → `"closed"`; otherwise `"opened"`. - Record webhook deliveries idempotently using `gitlabDeliveryId("merge_request", projectId, mrIid, updatedAt)` and `gitlabDeliveryId("mr_note", projectId, noteId, updatedAt)`. ## Verification - `pnpm run typecheck` - `pnpm run check` - `pnpm run knip` ## Files expected to change - `src/agents/gitlab.ts` — add `merge_request` and MR `note` handlers. - `src/api/index.test.ts` — add MR webhook integration tests. - `src/mcp/openapi.json`, `src/mcp/mcp-tools.ts`, `packages/client/src/types.ts`, `packages/cli/src/commands.ts` — regenerated by `pnpm run contract:check`. # ===== docs/SPEC-import-adapters.md ===== # Import Adapters ## Objective Make Pile the easiest place to land engineering data from Linear, Jira, Notion, Confluence, GitHub, or any other source. A single `POST /workspaces/{id}/import` endpoint accepts a `source` name and source-specific credentials, runs the import inside the workspace Durable Object, and returns a summary of what was imported along with a persistent `jobId`. This spec covers the shared framework plus all adapters folded into it: Jira, Confluence, Linear, Notion, and GitHub Issues. ## Import idempotency (`externalRef`) Issues may include an optional `externalRef`, which is unique within a workspace. A `POST /workspaces/{id}/issues` request with an existing `externalRef` returns the existing issue with status `200` instead of creating a duplicate. The same key can be queried with `GET /workspaces/{id}/issues?externalRef=...`; clients may also supply `id` for idempotent creates. The Linear adapter uses the convention `linear:` (for example, `linear:VOR-188`). ## Capability map | Module | Responsibility | Depends on | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `import-core` | Shared `ImportSource` contract, runner, `POST /workspaces/{id}/import` route, and `import_jobs` persistence | — | | `import-jira` | Jira Cloud issue import: projects, statuses, users, issues, comments, attachments | `import-core` | | `import-confluence` | Confluence Cloud page import: spaces, pages, ADF-to-markdown conversion | `import-core`, `import-jira` (Atlassian credentials are identical) | | `import-notion` | Notion page and database import into documents and issues | `import-core` | | `import-linear` | Linear issue migration | `import-core` | | `import-github-issues` | GitHub repository issue import into Pile issues | `import-core` | Build order: `import-core` → adapters (Jira, Confluence, Linear, Notion, GitHub Issues). ## Tech stack - Hono + `@hono/zod-openapi` for the `POST /workspaces/{id}/import` route. - Native `fetch` for external API calls. - Zod for response validation. - D1 for user / state / label / cycle / project lookups and source-specific mapping tables. - Durable Object SQLite for workspace-local issues, documents, comments, and attachments. - No `any`; use `unknown` and narrow with Zod. - No `eslint-disable` or `@ts-ignore`. ## Commands ```bash pnpm run typecheck pnpm run check # format, lint, contract generation, tests pnpm run knip pnpm run scan:secrets ``` ## Project structure ``` src/ import/ types.ts # ImportSource contract, ImportContext, summary types runner.ts # runImport(source, ctx, credentials, options) → { counts, job } jira.ts # JiraCloud adapter confluence.ts # ConfluenceCloud adapter linear.ts # Linear adapter notion.ts # Notion page/database adapter github-issues.ts # GitHub Issues adapter global/ import-jobs.ts # D1 helpers for import_jobs table adf-to-markdown.ts # Atlassian Document Format → markdown converter api/ import.ts # POST /workspaces/{id}/import + GET /workspaces/{id}/import/{jobId} ``` ## Code style - Each adapter is a single object implementing `ImportSource`. - Credentials and options are plain objects parsed by Zod in the route. - External API responses are validated with Zod; failures throw `ImportError`. - Sequential API calls where rate limits exist; no `Promise.all` over unbounded lists. - User resolution: try email first, create a placeholder Pile user if no email is available. ## Testing strategy - Integration tests in `src/api/index.test.ts` mock `globalThis.fetch` for the external API. - Each adapter gets one happy-path test and one error-handling test. - `pnpm run check` must pass before commit. ## Boundaries - **Always:** validate external API responses, run `pnpm run check`, keep adapters stateless, reuse existing `getWorkspaceStub` and DO methods. - **Ask first:** adding new top-level API routes beyond `/import`, adding third-party SDKs, changing D1 schema. - **Never:** commit credentials, use `any`, suppress lint rules, make imports irreversible. ## In scope ### Core - `POST /workspaces/{id}/import` with `rls("admin")`. - Body: `{ source: "jira" | "confluence" | "linear" | "notion" | "github-issues"; credentials: ...; options?: ... }` (discriminated union). - `ImportContext` carries `env`, `organizationId`, `importerId`, `db`, `stub`. - `ImportSource` interface: `validate(credentials)` and `run(ctx, credentials, options)`. - `runImport` creates an `import_jobs` row, runs validation, updates the job to `running`, executes the adapter, then marks it `completed` or `failed` with `counts` and `error`. It returns `{ counts, job }`. - `GET /workspaces/{id}/import/{jobId}` returns the current status and counts for an import job. ### Jira adapter Credentials: - `host`: `https://{domain}.atlassian.net` - `email`: Atlassian account email - `token`: Atlassian API token - `projectKey?`: import one project only - `jql?`: custom JQL override Behavior: - Validate with `GET /rest/api/3/myself`. - Fetch statuses and create Pile `states` (todo, in_progress, done mapping). - Fetch projects and create Pile `projects`. - Fetch users by email and create Pile users as needed. - Search issues with `POST /rest/api/3/search/jql`. - Map each Jira issue to a Pile issue: - `identifier` uses project key + issue number (`KEY-123`). - `title` from `fields.summary`. - `description` from `fields.description` converted from ADF to markdown. - `status` mapped from Jira status name. - `assigneeId` resolved from `fields.assignee.emailAddress`. - `labelIds` from `fields.labels` plus issue type as a label. - `parent` and `subtasks` for hierarchy. - Import comments (ADF → markdown) and attachments. ### Confluence adapter Credentials: - `host`: same as Jira - `email`, `token`: same as Jira - `spaceKey?`: limit to one space - `rootPageId?`: import a single page subtree Behavior: - Validate with `GET /wiki/rest/api/space`. - List pages with `GET /wiki/api/v2/pages?body-format=atlas_doc_format`. - For each page: - Convert `body.atlas_doc_format` from ADF JSON to markdown. - Create/update Pile document. - Resolve `parentDocumentId` on a second pass. - Map `authorId` / `ownerId` to Pile users via account lookup. ### ADF-to-markdown - Handle: `doc`, `paragraph`, `text` (with marks), `heading`, `bulletList`, `orderedList`, `listItem`, `codeBlock`, `hardBreak`, `rule`, `blockquote`, `panel`, `taskList`, `taskItem`, `table` (basic), `emoji`, `mention`, `media` (placeholder), `inlineCard`/`blockCard` (placeholder link). - Unsupported nodes fall back to processing children or are skipped with a placeholder comment. ## Out of scope - Real-time sync / webhooks for Jira or Confluence. - Bidirectional writeback. - Folding existing Linear and Notion routes into `/import` (Phase 2). - OAuth-based Atlassian auth; first slice uses API tokens only. - Full ADF fidelity (tables without alignment, complex panels as blockquotes). ## Success criteria - `POST /workspaces/{id}/import` with `source: "jira"` imports issues, comments, and attachments from a Jira Cloud project. - `POST /workspaces/{id}/import` with `source: "confluence"` imports pages as Pile documents with markdown content. - `pnpm run check` passes with no errors and `knip` reports no unused exports. - Adapters are isolated; adding a new source requires only a new adapter file and a route schema branch. ## Notes - Existing `/migrate/linear` and `/notion/import` have been folded into `/import`; the old routes are removed. - Import jobs are persisted in D1 with `pending | pending_approval | running | completed | failed | paused` status and a `GET` endpoint for status polling. - `POST /import/{jobId}/resume` accepts fresh credentials and an optional `limit` and continues from the stored `cursor`. - `POST /import` with `options.approvalRequired: true` creates a `pending_approval` job and an `import_approvals` row; `/approve` and `/reject` endpoints gate execution. - `ImportSource.run` now returns `{ counts, nextCursor? }` so the runner can accumulate counts and pause/resume per adapter. GitHub Issues is the first adapter with full cursor support; the rest return `nextCursor: null` for now. ## Consolidating a Linear import `scripts/consolidate-linear-import.ts` plans a safe, sequential consolidation of duplicate Linear imports. It is a dry run by default and requires `PILE_API_KEY`; set `PILE_BASE_URL` when using a non-default deployment. ```bash PILE_API_KEY=... pnpm exec tsx scripts/consolidate-linear-import.ts \ --workspace org_vortex_main PILE_API_KEY=... pnpm exec tsx scripts/consolidate-linear-import.ts \ --workspace org_vortex_main --apply PILE_API_KEY=... pnpm exec tsx scripts/consolidate-linear-import.ts \ --workspace org_vortex_main --apply --delete-duplicates ``` # ===== docs/SPEC-notion-integration.md ===== # Notion Integration ## Status - Phase 1 (page import and user mapping): implemented. - Phase 2 (ongoing webhook sync and database migration into issues): implemented. ## Objective Let Pile replace Notion as the source of truth for documents and issue backlogs by importing Notion pages into the existing Pile `documents` model and Notion databases into Pile `issues`. Ongoing webhook sync keeps documents in sync after the initial import. ## In scope — Phase 1: page import (done) - `POST /workspaces/{organizationId}/import` - Body: `{ source: "notion", credentials: { token: string }, options?: { rootPageId?: string; spaceId?: string } }` - `token` is a Notion internal integration token (`ntn_...`). - If `rootPageId` is provided, import that single page. - If omitted, use `POST /v1/search` to discover and import all pages reachable by the integration. - Parent pages are imported before children where possible; `parentDocumentId` is set on a second pass using `notion_page_mappings`. - Creates or updates Pile documents with `contentFormat: "markdown"`. - Preserves parent/child page hierarchy as `parentDocumentId`. - Records a `notion_page_mappings` row per page so re-imports update instead of duplicate. - Returns a summary: `{ created: number; updated: number; errors: number }`. - `POST /workspaces/{organizationId}/notion/users` - Body: `{ userId: string; notionUserId: string }` - Map a Notion user ID to a Pile user ID for `createdById` / `updatedById`. - D1 tables: - `notion_installations` — `organizationId`, `workspaceId`, `token` (encrypted at rest by D1), `createdAt`. - `notion_users` — `organizationId`, `notionUserId`, `userId`. - `notion_page_mappings` — `organizationId`, `notionPageId`, `documentId`, `createdAt`, `updatedAt`. - Notion API calls: - `GET /v1/pages/{page_id}` for title, icon, parent, created/last-edited users. - `GET /v1/pages/{page_id}/markdown` for content. - `POST /v1/search` to list pages when no `rootPageId` is given. - Content handling: - Store Notion markdown directly in `document.content` with `contentFormat: "markdown"`. - Title from `properties.title` or first `# h1` in markdown. - Icon emoji copied to `document.icon` if present. - OpenAPI / MCP / client / CLI parity regenerated. - Integration tests using mocked Notion API responses. ## In scope — Phase 2a: database migration into issues (done) - `POST /workspaces/{organizationId}/import` - Body: `{ source: "notion", credentials: { token: string }, options: { databaseId: string; teamId?: string } }` - `databaseId` is a Notion database ID. - Fetches database metadata with `GET /v1/databases/{database_id}` to discover the title property. - Paginates through rows with `POST /v1/databases/{database_id}/query`. - For each row, fetches `GET /v1/pages/{page_id}/markdown` for the description. - Creates or updates Pile `issues` through the native Workspace DO `createIssue` / `updateIssue` paths. - Records `notion_issue_mappings` (organizationId, notionPageId, issueId) for idempotent re-imports. - `teamId` is passed through to `createIssue`; the workspace default team is used otherwise. - D1 tables: - `notion_issue_mappings` — `organizationId`, `notionPageId`, `issueId`, `createdAt`, `updatedAt`. ## Out of scope - Writing Pile document edits back to Notion. - Importing comments, permissions, file attachments, or embedded databases. - Converting Notion blocks to BlockNote JSON (markdown is sufficient for page content). ## Data flow ``` POST /workspaces/{organizationId}/import { source: "notion" } ├─ validate token with GET /v1/users/me ├─ upsert notion_installations ├─ fetch Notion page(s) ├─ for each page: │ ├─ GET /v1/pages/{id} → title, icon, parent, authors │ ├─ GET /v1/pages/{id}/markdown → content │ ├─ resolve parentDocumentId from notion_page_mappings │ ├─ resolve createdById/updatedById from notion_users (fallback to importer) │ ├─ create or update Pile document via WorkspaceDO │ └─ upsert notion_page_mappings └─ return summary ``` ## Webhooks — Phase 2 (implemented) Notion sends signed `POST` events to `/notion/{organizationId}/{workspaceId}`: - Handshake request has no `X-Notion-Signature`; body is `{ verification_token }`. The token is stored on the matching `notion_installations` row. - Event requests include `X-Notion-Signature: sha256=` signed with the workspace's stored `verification_token`. The handler uses a constant-time comparison. - Supported events: `page.created`, `page.content_updated`, `page.properties_updated`, `page.deleted`, `page.moved`. - Event payload contains `entity.id` (page id) and `workspace_id`; handler fetches full page + markdown on create/update/move events and reuses the shared `syncNotionPage` logic. - Update or create the mapped Pile document; soft-delete on `page.deleted` by setting `trashedAt`. ## Security - The import token travels only from the request body to a Notion API call; it is not logged. - For ongoing webhooks, the `verification_token` is stored per workspace and used only for HMAC verification. - All D1 writes use the existing RLS middleware. ## Verification - `pnpm run typecheck` - `pnpm run check` - `pnpm run knip` - `pnpm run scan:secrets` ## Files expected to change - `src/import/notion.ts` — shared import adapter for pages and database migration. - `src/api/import.ts` — registers `source: "notion"` on the shared `/import` route. - `src/api/notion.ts` — Notion user mapping routes. - `src/api/notion-webhook.ts` — Notion webhook handler. - `src/platform/security.ts` — `/notion/*` as a public webhook path. - `src/global/notion-client.ts` — typed Notion API helpers including database query. - `src/global/notion-installations.ts`, `src/global/notion-users.ts`, `src/global/notion-page-mappings.ts`, `src/global/notion-issue-mappings.ts` — D1 helpers. - `src/global/schema.ts` — `notion_installations`, `notion_users`, `notion_page_mappings`, `notion_issue_mappings` tables. - `src/api/index.ts` — register Notion routes and webhook route. - `src/api/index.test.ts` — import, webhook, and database tests. - Generated OpenAPI / MCP / client / CLI artifacts. - `migrations/` — D1 schema migrations for Notion tables. # ===== docs/SPEC-slack-threads-unfurls.md ===== # Spec: Slack thread/reply sync, unfurls, and interactive components ## Objective Make the Pile Slack integration a first-class support channel. Every action a customer or agent can take in Slack must be available through the API, CLI, SDK, and MCP, so Pile can be used headless by other agents. What we are building: 1. **DM → issue** — any Slack DM to the bot creates a Pile issue. 2. **Channel message → issue** — messages in a connected Slack channel can be turned into Pile issues through a message action or an emoji reaction. 3. **Reply sync** — messages in a Slack thread linked to a Pile issue become issue comments; issue comment updates are posted back to the thread. 4. **Emoji status sync** — emoji reactions on the top-level Slack message map to Pile status changes (e.g. `✅` → done, `👀` → in progress). 5. **Link unfurls** — Pile issue links shared in Slack show a compact preview with title, status, assignee, and priority. 6. **In-Slack actions** — quick actions on unfurls and notifications to assign, change status, snooze, set priority, and open in Pile. 7. **Interactive components** — a message action / button to create an issue from any Slack message, and a "View in Pile" button on issue notifications. 8. **Ingestion modes (future)** — one-to-one, time-based, AI-based, and manual ingestion so support teams can control how Slack messages become Pile threads. Out of scope for this spec: - Slash commands (already in `bot.onSlashCommand`/`/pile`). - @mention issue creation (already in `bot.onNewMention`). - Real-time agent chat (covered by the `chat` SDK, not Slack-specific). - AI-based grouping of related Slack messages into a single issue (post-MVP). - Native help center / knowledge base (post-MVP). ## Competitive baseline Pile's Slack integration must be at least as programmable as the best backend-first support tools. The table below maps what Linear, Notion, Jam, Pylon, and Plain do in Slack to Pile capabilities. | Product | Slack feature | What Pile should expose | | ---------- | ---------------------------------------------------- | ------------------------------------------------------------------------ | | **Linear** | Create issue from a Slack message | `POST /slack/actions` `create_issue` + message action | | **Linear** | Sync Slack thread with issue comments | `slack-threads` bidirectional sync via `bot.onSubscribedMessage` | | **Linear** | Rich issue link unfurls with quick actions | `slack-unfurl` `link_shared` handler + action blocks | | **Linear** | @Linear bot commands | Already `bot.onNewMention`; expand to status queries | | **Linear** | Channel and personal notifications | `notifySlack` for `issue.created/updated/commented` | | **Notion** | Send Slack message to a database / create a page | `POST /slack/actions` `create_issue` from any message | | **Notion** | Slack notifications for mentions and changes | Extend `notifySlack` for @mentions and assignment changes | | **Notion** | Rich link previews | `slack-unfurl` for Pile issue / support ticket links | | **Jam** | Record bug + context and send to Slack | Capture Slack attachments, file URLs, and message body on issue creation | | **Jam** | Rich recording link unfurls with previews | `slack-unfurl` for Pile issues with attachments / metadata | | **Pylon** | Auto-create tickets from Slack messages | `slack-dm` and channel ingestion + `slack-actions` manual create | | **Pylon** | Bi-directional Slack thread sync | `slack-threads` reply sync | | **Pylon** | Internal triage threads from support request | Future `support-escalation` Slack thread bridge | | **Pylon** | SLA tracking on Slack messages | Use `support-tickets` `createdAt` and `snoozedUntil` fields | | **Plain** | In-Slack actions (assign, status, snooze, priority) | `slack-actions` block / message actions | | **Plain** | Emoji reaction status sync | `slack-emoji` reaction handler mapped to status transitions | | **Plain** | Ingestion modes (one-to-one, time-based, AI, manual) | Future per-channel `ingestionMode` setting | | **Plain** | Sidekick AI in Slack | Out of scope; Pile provides the API/CLI for agents to call | Design principle: **Plain is open infrastructure and Pylon is a full-stack app.** Pile should be open infrastructure — every Slack action is also an API call, CLI command, or MCP tool. ## Assumptions - The `chat` SDK (`chat@4.40.0`) is the canonical abstraction for Slack events and interactive components. - The Slack adapter is already installed and `slackInstallations` maps `teamId` → Pile `organizationId`. - Pile issue links are `https://pile.nyc//issues/` (custom domain mapping is a separate concern). - Auth to Pile issues uses the existing workspace-scoped API token; the Slack bot acts as a system agent with a workspace token stored on the `slackInstallations` row (or generated for the bot). ## Slack Connect B2B support happens in shared Slack Connect channels, so the design must not assume a single workspace. - A Pile workspace can be linked to multiple Slack `team_id`s: one for the company workspace and one per customer workspace that has invited the bot into a shared channel. - `slack_installations` must support `(organization_id, slack_team_id)` uniqueness, not just `team_id` globally. - **Internal vs external classification** — the Slack `team_id` of the company workspace is **internal**; every other `team_id` in a shared channel or DM is **external**. A message is classified by the sender's `team_id`. - Ingestion treats messages from **external** users as customer support requests and messages from **internal** users as agent replies. - Replies in a Slack Connect thread are mirrored to the Pile support ticket; outbound Pile replies are posted to the customer-visible Slack thread only when the sender is an agent. - **Internal-only notes** — add an `internal` boolean to `comments` and `support_messages`. Internal comments sync only to internal Slack threads or the Pile web surface; they never post to a customer-visible Slack thread. - **Identity resolution** — a Slack `user_id` in a customer workspace is not the same as a Pile `user_id`. Link them lazily through `support_contacts.external_id` using a composite key `:`. Email from `users:read.email` is a best-effort enrichment, not a primary key, because external emails are often hidden in Slack Connect. - Required OAuth scopes: - `commands` — for the existing `/pile` slash command. - `app_mentions:read` — for existing @mention issue creation. - `chat:write`, `chat:write.public` — to post replies in public and private channels. - `channels:history`, `groups:history`, `im:history`, `mpim:history` — to read messages in public, private, DM, and group DM contexts. - `reactions:read`, `reactions:write` — for emoji status sync. - `files:read` — to capture Slack file/attachment metadata. - `links:read`, `links:write` — for Pile issue link unfurls. - `users:read`, `users:read.email` — to resolve Slack `user_id` to email for contact matching. - `team:read` — to distinguish the company Slack team from customer teams in Slack Connect. ## Data model A single Slack conversation can map to a Pile issue and a Pile support ticket. Store the mapping once and reference it from both. ``` support_conversations - id (uuid) - organization_id - slack_team_id # the workspace that owns the channel - slack_channel_id - slack_thread_ts # top-level message timestamp; null for a DM or non-threaded channel message - issue_id # the public-facing issue - support_ticket_id # the internal support ticket - is_external # true if the conversation started from a customer - created_at - updated_at ``` `slack_installations` must support `(organization_id, slack_team_id)` uniqueness so one Pile workspace can be installed into many customer Slack teams. ## Project structure ``` src/slack/bot.ts # Chat bot handlers (existing) src/slack/threads.ts # Thread ↔ issue comment sync src/slack/unfurl.ts # link_shared preview generation src/slack/actions.ts # message_shortcut and block_actions handlers src/slack/emoji.ts # reaction_added / reaction_removed status sync src/slack/attachments.ts # Slack file / attachment capture src/slack/messages.ts # helpers for posting cards with buttons src/slack/ingestion.ts # v2: ingestion-mode decision logic src/api/slack.ts # HTTP routes (existing) ``` ## Module map | Module | Responsibility | Depends on | | ------------------- | -------------------------------------------------------------- | -------------------------------------------------- | | `slack-dm` | DM to issue creation | `chat` SDK, `WorkspaceDO` | | `slack-actions` | Create issue from any Slack message and in-Slack quick actions | `chat` SDK, `WorkspaceDO` | | `slack-threads` | Subscribe to issue threads and sync comments bidirectionally | `chat` SDK, `WorkspaceDO`, `support-tickets` | | `slack-emoji` | Map emoji reactions to Pile status transitions | `chat` SDK, `WorkspaceDO` | | `slack-attachments` | Capture Slack file metadata and shareable URLs | `chat` SDK, `files:read` | | `slack-unfurl` | Generate link previews for Pile issue URLs | `chat` SDK, `global/support-tickets` or issues API | | `slack-ingestion` | v2: per-channel ingestion-mode selection for channel messages | `chat` SDK, `global/support-channels` | MVP build order: `slack-dm` → `slack-actions` → `slack-attachments` → `slack-threads` → `slack-emoji` → `slack-unfurl` v2 build order: `slack-ingestion` after the MVP is stable. ## Commands - `pnpm run check` — full contract, lint, type, and test run. - `pnpm vitest run src/slack` — Slack module tests. - `pnpm -C packages/cli run build` — ensure CLI still builds. ## Code style - Keep handlers in `src/slack/bot.ts` via `bot.onDirectMessage`, `bot.onSubscribedMessage`, `bot.onReaction`, `bot.onAction`, and `bot.onLinkShared` (if supported). - Extract platform-specific transform logic into `src/slack/messages.ts`. - Use `createIssueFromText` pattern already used for `onNewMention` and `onSlashCommand`. - No `any`; use `unknown` and narrow. ## Testing strategy - Unit tests for `src/slack/messages.ts` transform helpers. - Integration tests for `src/api/slack.ts` event endpoint with mocked `Chat`/`SlackAdapter`. - One end-to-end test that triggers a DM and asserts an issue is created. - Negative tests: unknown Slack installation, invalid signature, duplicate event, unauthorized action. ## Boundaries - Always: run `pnpm run check` before commit; update generated artifacts if API changes. - Ask first: adding new `WorkerEnv` bindings or D1/Durable Object schema columns. - Never: commit Slack tokens or signing secrets; store Slack raw event payloads in logs. ## Success criteria 1. A Slack DM to the bot creates a Pile issue and replies with the issue identifier. 2. A message action creates a Pile issue from an arbitrary Slack message. 3. Replies in a linked Slack thread become comments on the Pile issue and vice versa. 4. Emoji reactions on the top-level Slack message update Pile status. 5. Pile issue links unfurl in Slack with title, status, assignee, and quick actions. 6. Every Slack action is reachable through the API, CLI, SDK, or MCP. 7. `pnpm run check` and `pnpm run scan:secrets` are green. ## Event handling Slack retries failed events quickly. All Slack HTTP handlers in `src/api/slack.ts` must respond with `200 OK` within 3 seconds and perform actual work asynchronously. Every non-trivial handler records an event idempotency key in `slack_events` to prevent duplicate processing. - `message` and `message_changed` / `message_deleted` in linked threads update the mapped Pile issue / support ticket. - `reaction_added` and `reaction_removed` on the top-level message drive `slack-emoji` status transitions. - `link_shared` falls back to a raw Slack handler in `src/api/slack.ts` if the `chat` SDK does not expose `onLinkShared`. - `block_actions` and `message_shortcut` route through `slack-actions`. ## Default emoji status map Map emoji reactions on the top-level Slack message to Pile status transitions. Workspaces can override this map through `settings.slack_emoji_status_map`. | Emoji | Pile status | Meaning | | ----- | ------------- | ----------------------------------- | | `✅` | `done` | Resolve the issue / support ticket. | | `👀` | `in_progress` | Mark as being worked on. | | `🛑` | `canceled` | Close as canceled / not doing. | | `🔥` | `urgent` | Set priority to urgent. | | `😴` | `snoozed` | Snooze until tomorrow. | ## Link unfurl auth model Pile link unfurls are **channel-scoped**, not public on the open internet. - Slack sends `link_shared` for the channel where the link was posted. The bot then calls `chat.unfurl` with a per-channel block payload. - If the `channel_id` is a shared / Slack Connect channel, the unfurl shows a **customer-safe preview** (identifier, title, status, assignee) and hides internal comments or notes. - If the `channel_id` is an internal company channel, the unfurl can show richer blocks (priority, cycle, labels, quick actions). - No workspace user token is required for the unfurl itself; the bot uses the installation token tied to the channel. The preview is only visible to people already in that Slack channel. # ===== docs/SPEC-support-capture.md ===== # Spec: support-capture ## Objective Define the bug-capture module for the customer support layer. `support-capture` lets customers and agents record bugs from a website or app, attach screenshots, screen recordings, console logs, network requests, and device info, and turn the result into a `support_ticket` with attachments. It is a Jam / Crikket-style capture channel that feeds the same `support_tickets` and `support_ticket_events` tables as email, Slack, and chat. This module depends on `support-contacts` and `support-tickets`. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for input validation. - Cloudflare R2 for artifact storage (`ATTACHMENTS_BUCKET`). - Native Web Crypto for token signing. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-capture.ts # D1 helpers src/global/schema.ts # support_capture_public_keys, support_capture_sessions, support_ticket_attachments tables src/capture/ # token, upload, finalize handlers src/api/support-capture.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_capture_public_keys` Public keys scoped to a website or app surface. Reuses the Crikket pattern. | Column | Type | Notes | | ----------------- | ---------------------- | -------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `name` | text | not null; e.g., "Marketing Site" | | `key` | text | not null; `crk_...` or `vtx_...` | | `allowed_origins` | text | JSON array of exact origins | | `is_active` | boolean | default true | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, key)`. ### `support_capture_sessions` Pending upload session. Crikket calls this `bugReportUploadSession`. | Column | Type | Notes | | ----------------- | ------------------------------------- | ---------------------------------------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `public_key_id` | text FK → support_capture_public_keys | which key started it | | `customer_id` | text FK → support_customers | nullable until identified | | `ticket_id` | text FK → support_tickets | nullable until finalized | | `status` | text | `pending`, `uploading`, `finalized`, `expired` | | `metadata` | text | JSON; title, description, url, tags, device info, priority, visibility | | `expires_at` | text | ISO timestamp | | `created_at` | text | ISO timestamp | Index: `(organization_id, status, expires_at)`. ### `support_ticket_attachments` Artifacts linked to a ticket. Screenshot, video, or debugger payload. | Column | Type | Notes | | ----------------- | ------------------------------- | -------------------------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `ticket_id` | text FK → support_tickets | not null | | `event_id` | text FK → support_ticket_events | the message/note this attachment belongs to | | `type` | text | `screenshot`, `video`, `debugger_json`, `log`, `network` | | `content_type` | text | e.g., `image/png`, `video/webm` | | `r2_key` | text | not null | | `r2_size_bytes` | integer | nullable | | `url` | text | presigned / public R2 URL | | `created_at` | text | ISO timestamp | Index: `(ticket_id, type)`. ## Code Style ```ts export const supportCaptureSessionSchema = z.object({ id: z.string(), organizationId: z.string(), publicKeyId: z.string(), customerId: z.string().optional(), ticketId: z.string().optional(), status: z.enum(["pending", "uploading", "finalized", "expired"]), metadata: z.record(z.unknown()).default({}), expiresAt: z.string().datetime(), createdAt: z.string().datetime(), }); export type SupportCaptureSession = z.infer; ``` - snake_case in SQL, camelCase in TypeScript. - `metadata` is `record(unknown)`; validated by capture-specific Zod on read/write. - No `any`. ## API Surface ### `POST /workspaces/{organizationId}/support/capture/public-keys` Create a public key for an embed surface. Body: `name`, `allowedOrigins`. ### `GET /workspaces/{organizationId}/support/capture/public-keys` List public keys. ### `DELETE /workspaces/{organizationId}/support/capture/public-keys/{keyId}` Revoke a key. ### `POST /support/capture/token` Client-side endpoint. Returns a short-lived capture token. Headers: `x-pile-capture-public-key`, `origin`. ### `POST /support/capture/upload-session` Client-side. Creates a pending capture session and returns an `uploadUrl` for the artifact. Body: `title`, `description`, `priority`, `tags`, `url`, `attachmentType`, `visibility`, `metadata`, `deviceInfo`. ### `POST /support/capture/upload/{sessionId}` Direct R2 upload or presigned URL. For v1, the client uploads the screenshot/video directly to R2 using a presigned URL returned by `upload-session`. ### `POST /support/capture/finalize` Client-side. Completes the upload, creates/updates the `support_ticket`, creates the `support_ticket_events` row, and returns the `ticketId` and `shareUrl`. Headers: `x-pile-capture-token`, `x-pile-capture-finalize-token`. Body: `sessionId`, `captureSizeBytes`, `debuggerSizeBytes`. ### `POST /support/capture/metadata` Client-side SDK helper. Accepts `metadata` to attach to the next capture. This is the `jam.metadata()` pattern. ## Storage Choice - **D1** for `support_capture_public_keys`, `support_capture_sessions`, `support_ticket_attachments`. - **R2** for the actual screenshot, video, and debugger JSON blobs. - Presigned R2 URLs for client upload and viewing. ## Testing Strategy - Tests in `src/api/support-capture.test.ts`. - Generate a public key, start a session, simulate an upload, finalize, and assert the `support_tickets` and `support_ticket_attachments` rows exist. - Test origin rejection with an unauthorized origin. - Test expired session cleanup. ## Migration Path `support-capture` does not import from Jam or Crikket. It is a new native channel. The Jam/Crikket audits are used as the design reference. ## Boundaries ### Always - Validate public key and origin before issuing a capture token. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Adding new `attachmentType` values. - Changing the token/signature scheme. - Moving artifact storage from R2 to another provider. ### Never - Commit capture signing secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 tables `support_capture_public_keys`, `support_capture_sessions`, `support_ticket_attachments` exist. - [ ] Client can initialize a capture from an allowed origin. - [ ] `POST /support/capture/finalize` creates a `support_ticket` and `support_ticket_attachments`. - [ ] Invalid origin or public key returns 401. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Should the capture widget be a standalone `@pile/capture` SDK, or is it a `pileSupport.capture()` method in a future client SDK? 2. Do we support video and screenshot in v1, or just screenshot? 3. Should the debugger payload be stored as one large JSON, or split into `console_logs`, `network_requests`, and `user_events` files? # ===== docs/SPEC-support-channels.md ===== # Spec: support-channels ## Objective Define the sixth module of the customer support layer: how tickets get in and out of Pile. `support-channels` provides the ingestion endpoints for every channel (email, Slack, MS Teams, Discord, in-app chat, Intercom, Zendesk, Plain) and the outgoing send path for replies. It is the HTTP surface that `support-migration` adapters and external providers call to create and update `support_tickets` and `support_ticket_events`. The in-app chat widget uses the **Vercel AI SDK** for the client-side chat UI. Pile provides the message persistence; the customer brings their own model if they want an AI agent. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for input and webhook payload validation. - Native Web Crypto for HMAC and signature verification. - Vercel `ai` package for the in-app chat UI components (client side). ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/channels/ email.ts # incoming/outgoing email slack.ts # Slack events + reply chat.ts # in-app chat sessions intercom.ts # Intercom webhook zendesk.ts # Zendesk webhook plain.ts # Plain webhook generic.ts # API-created messages src/global/support-channels.ts # D1 helpers src/global/schema.ts # support_channels, support_chat_sessions tables src/api/support-channels.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_channels` Configuration for each enabled channel in a workspace. | Column | Type | Notes | | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `type` | text | `email`, `slack`, `msteams`, `discord`, `chat`, `capture`, `api`, `intercom`, `zendesk`, `plain` | | `name` | text | not null | | `is_active` | boolean | default true | | `config` | text | JSON string; channel-specific non-secret settings | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, type, name)`. `config` stores things like support email address, Slack channel id, or widget color. Secrets (tokens, signing secrets) are Worker secrets referenced by `name`, never stored in `config`. ### `support_chat_sessions` A chat session ties a customer to a ticket. The Vercel AI SDK `useChat` hooks live in the client; the session record lets the backend persist the conversation. | Column | Type | Notes | | ----------------- | --------------------------- | ---------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `customer_id` | text FK → support_customers | who is chatting | | `ticket_id` | text FK → support_tickets | nullable until first message | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Index: `(organization_id, customer_id, updated_at)`. ## Code Style ```ts export const supportChannelSchema = z.object({ id: z.string(), organizationId: z.string(), type: z.enum([ "email", "slack", "msteams", "discord", "chat", "capture", "api", "intercom", "zendesk", "plain", ]), name: z.string(), isActive: z.boolean().default(true), config: z.record(z.unknown()).default({}), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type SupportChannel = z.infer; ``` - `config` is `z.record(z.unknown())` and is validated by the channel-specific code that consumes it, not by the top-level schema. - snake_case in SQL, camelCase in TypeScript. ## API Surface ### `POST /workspaces/{organizationId}/support/channels` Create a channel config. ### `GET /workspaces/{organizationId}/support/channels` List channels. ### `PATCH /workspaces/{organizationId}/support/channels/{channelId}` Update or enable/disable a channel. ### `POST /workspaces/{organizationId}/support/channels/{channelId}/send` Send an outbound message on the channel. Body: `customerId`, `textContent`, optional `markdownContent`, `ticketId`. ### `POST /support/incoming/{channelId}` Generic incoming message endpoint. Body is channel-specific but always produces a `support_tickets` row and a `support_ticket_events` row. ### `POST /support/webhooks/email/{organizationId}` Incoming email webhook. Body: `{ from, to, subject, text, html }`. Verifies `from` and `to` against configured `support_channels`. ### `POST /support/webhooks/slack/{organizationId}` Slack Events API endpoint. Verifies Slack request signature. ### `POST /support/webhooks/intercom/{organizationId}` Intercon webhook. Verifies `X-Hub-Signature` HMAC-SHA1. ### `POST /support/webhooks/zendesk/{organizationId}` Zendesk webhook. Verifies Zendesk signature. ### `POST /support/webhooks/plain/{organizationId}` Plain webhook. Verifies `plain-request-signature`. ### `POST /support/chat/sessions` Start an in-app chat session. Returns a `sessionId` and `customerId`. ### `POST /support/chat/sessions/{sessionId}/messages` Receive a customer message from the chat widget. Creates or appends to a `support_tickets` row and stores the message. Returns the stored message. ## Storage Choice `support-channels` lives in **D1**. Channel configs, chat sessions, and the ticket/event rows they create are all D1. The only exception is an optional Durable Object if a channel needs real-time pub/sub for the in-app chat; that belongs in `support-inbox`. ## Testing Strategy - Tests in `src/api/support-channels.test.ts`. - For each provider, generate a valid webhook payload and signature, post to the route, and assert a ticket is created/updated. - For chat, create a session, post a message, and assert a `support_tickets` row exists. - Test missing/invalid signatures return 401. ## Migration Path `support-channels` is the runtime half of `support-migration`. Import reads history once; `support-channels` handles ongoing events. Both call the same upsert primitives in `support-migration` or `support-tickets`. ```ts processIncomingMessage(db, organizationId, { channel: "email", customer: { email, fullName }, message: { text, subject }, }); ``` ## Boundaries ### Always - Verify webhook signatures before processing. - Validate all payloads with Zod. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Adding a new channel. - Storing channel secrets in D1 instead of Worker secrets. - Allowing `support-channels` to write directly to the workspace Durable Object. ### Never - Commit channel secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 tables `support_channels` and `support_chat_sessions` exist. - [ ] `POST /support/webhooks/intercom/{org}` creates/updates a support ticket. - [ ] `POST /support/chat/sessions/{id}/messages` creates a ticket from a chat message. - [ ] Invalid webhook signatures return 401. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Should `support-channels` own the webhook handlers for Intercom/Zendesk/Plain, or should those live in `src/agents/` and `support-channels` only own the generic `POST /support/incoming/{channelId}`? 2. Should the chat widget use the Vercel AI SDK on the client and call `POST /support/chat/sessions/{id}/messages`, or does the Worker expose a `streamText` endpoint? 3. Do we support email receiving via Cloudflare Email Workers, or do we use an external email parser that calls `POST /support/webhooks/email/{org}`? # ===== docs/SPEC-support-contacts.md ===== # Spec: support-contacts ## Objective Define the first module of the customer support layer: the contact model. This module is the foundation for `support-tickets`, `support-migration`, and `support-inbox`. `support-contacts` stores the people and organizations that can open support tickets. It must be simple enough to import from Intercom and Zendesk, and rich enough to eventually replace Plain.com's customer and tenant model. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1 (global metadata). - Zod for input and webhook payload validation. - Native provider primitives only (no custom ORM/validation wrappers). ## Commands ```bash # Format/lint pnpm exec vp check --fix # Tests pnpm test # Generate D1 migration after schema changes pnpm run db:generate # Apply D1 migration to production CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote # Deploy CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-contacts.ts # D1 helpers src/global/schema.ts # support_customers, support_companies tables src/api/support-contacts.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_customers` | Column | Type | Notes | | ----------------- | ---------------------- | ---------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `user_id` | text FK → user | optional; internal user submitting a bug | | `external_id` | text | optional Intercom/Zendesk/Plain id | | `external_source` | text | `intercom`, `zendesk`, `plain`, `manual` | | `email` | text | unique per workspace | | `full_name` | text | nullable | | `phone` | text | nullable | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, email)`. Index: `(organization_id, external_id, external_source)`. `user_id` is optional. Support customers are separate from workspace users, but an internal user can be attached to a customer for cases like bug submissions or org-internal support. ### `support_companies` | Column | Type | Notes | | ----------------- | ---------------------- | --------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `external_id` | text | optional external id | | `external_source` | text | source system | | `name` | text | not null | | `domain` | text | nullable; used for email matching | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, name)`. Index: `(organization_id, domain)`. `domain` is used for auto-resolution: an incoming email like `jane@acme.com` attaches to the company with `domain = acme.com` if one exists in the workspace. ### `support_customer_identities` | Column | Type | Notes | | ------------- | --------------------------- | --------------------------------- | | `id` | text PK | Pile UUID | | `customer_id` | text FK → support_customers | not null | | `type` | text | `email`, `phone`, `slack`, `chat` | | `value` | text | not null | | `is_primary` | boolean | default false | | `created_at` | text | ISO timestamp | Unique: `(customer_id, type, value)`. This table lets a customer have multiple identities (work email, personal email, phone, Slack DM id) without overloading the `support_customers` table. ### `support_customer_companies` | Column | Type | Notes | | ------------- | --------------------------- | ------------------------------ | | `id` | text PK | Pile UUID | | `customer_id` | text FK → support_customers | not null | | `company_id` | text FK → support_companies | not null | | `is_primary` | boolean | default false; primary company | | `created_at` | text | ISO timestamp | Unique: `(customer_id, company_id)`. Index: `(company_id)`. A customer can belong to multiple companies (Plain-style tenants). v1 always writes one row per customer, but the schema supports many from the start. ## Code Style ```ts export const supportCustomerSchema = z.object({ id: z.string(), organizationId: z.string(), userId: z.string().optional(), externalId: z.string().optional(), externalSource: z .enum(["intercom", "zendesk", "plain", "manual"]) .default("manual"), email: z.string().email(), fullName: z.string().optional(), phone: z.string().optional(), companies: z .array( z.object({ companyId: z.string(), isPrimary: z.boolean().default(false), }) ) .default([]), identities: z .array( z.object({ type: z.enum(["email", "phone", "slack", "chat"]), value: z.string(), isPrimary: z.boolean().default(false), }) ) .default([]), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type SupportCustomer = z.infer; ``` - snake_case in SQL, camelCase in TypeScript. - Native Drizzle columns; no `any`. - `external_source` is a string enum, not a custom type. ## API Surface ### `POST /workspaces/{organizationId}/support/customers` Create a customer. Body is `SupportCustomerInput` (without `id`, `createdAt`, `updatedAt`, `companies`, `identities` — those are populated by separate endpoints or nested upserts). ### `GET /workspaces/{organizationId}/support/customers` Paginated list. Query params: `limit`, `cursor`, `companyId`, `q` (search email/name). ### `GET /workspaces/{organizationId}/support/customers/{customerId}` Get one customer with companies and identities. ### `PATCH /workspaces/{organizationId}/support/customers/{customerId}` Update fields. To change companies or identities, use the nested endpoints. ### `PUT /workspaces/{organizationId}/support/customers/{customerId}/companies` Set the customer's company memberships (replaces existing rows). ### `PUT /workspaces/{organizationId}/support/customers/{customerId}/identities` Set the customer's identities (replaces existing rows). ### `POST /workspaces/{organizationId}/support/companies` Create a company. ### `GET /workspaces/{organizationId}/support/companies` Paginated list. Query params: `limit`, `cursor`, `q`. ### `GET /workspaces/{organizationId}/support/companies/{companyId}` Get one company with customer list. ## Storage Choice `support-contacts` lives in **D1** (global metadata), not in the workspace Durable Object. Contacts are cross-workspace entities used by migration, webhooks, and the inbox. D1 is the right place for indexed lookups by `email`, `external_id`, and `domain`. ## Testing Strategy - Unit tests in `src/api/support-contacts.test.ts` using the existing `@cloudflare/vitest-pool-workers` setup. - Each test creates a workspace, creates/updates customers and companies, asserts responses. - Test data is isolated per test via random organization ids. - Contract tests: `pnpm run contract:check` after route changes. ## Migration Path `support-migration` (later module) will use the same D1 tables. It inserts or updates rows from Intercom/Zendesk/Plain using `external_id` + `external_source` as the stable key. `support-contacts` must expose an upsert helper: ```ts upsertCustomerByExternal(db, organizationId, { externalId, externalSource, email, fullName, phone, companyId, identities, }); ``` The helper creates or updates `support_customers`, then syncs `support_customer_companies` and `support_customer_identities` using the primary company and identity list. ## Boundaries ### Always - Validate inputs with Zod. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Renaming `support_customers` or `support_companies`. - Adding user authentication to customer identity resolution. - Changing storage from D1 to Durable Object. ### Never - Store PII in logs or generated client docs. - Commit secrets. - Use `any`. ## Success Criteria - [ ] D1 tables `support_customers`, `support_companies`, `support_customer_identities`, `support_customer_companies` exist. - [ ] REST routes for create/list/get/update customers and companies return correct JSON. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. - [ ] `support-migration` can upsert contacts by `external_id` + `external_source`. ## Decisions 1. **Pile `user` vs support customer** — keep them separate. `support_customers` has an optional `user_id` for internal bug submissions, but the support contact is a separate entity. 2. **Leads** — no `support_leads` table for v1. `support_customers` covers all contacts; sales leads will be a future concern. 3. **Company auto-resolution** — yes. Match incoming email domain to `support_companies.domain` when a company with that domain exists. 4. **Tenants** — use a join table `support_customer_companies` so one customer can belong to multiple companies. v1 creates one row per customer, but the schema supports many. 5. **URL namespace** — use `/support/customers` and `/support/companies` to avoid collision with existing `/users` and `/teams` and to keep the support surface namespaced. # ===== docs/SPEC-support-content.md ===== # Spec: support-content ## Objective Define the fourth module of the customer support layer: reusable content and automation. `support-content` stores the things agents use to work tickets faster: canned replies (`snippets`), auto-reply rules, and labels. It does not run the support agent; it gives the agent and the UI the primitives to apply consistently. This module depends on `support-tickets` and reuses the existing `labels` table. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for input validation. - Native provider primitives only. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-content.ts # D1 helpers src/global/schema.ts # support_snippets, support_autoresponders tables src/api/support-content.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_snippets` Canned replies that an agent can insert into a message. Plain calls these `Snippets`. | Column | Type | Notes | | ------------------ | ---------------------- | ----------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `name` | text | not null; shortcut name | | `text_content` | text | plain text output | | `markdown_content` | text | nullable; rich output | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, name)`. Index: `(organization_id)` for listing. ### `support_autoresponders` Rules that send an automatic reply under a condition. Plain's `Autoresponders`. | Column | Type | Notes | | ----------------- | -------------------------- | ---------------------------------------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `name` | text | not null | | `enabled` | boolean | default true | | `trigger` | text | `ticket_created`, `customer_replied`, `out_of_hours` | | `order` | integer | not null; lower runs first | | `snippet_id` | text FK → support_snippets | nullable; the reply to send | | `conditions` | text | JSON string; optional filters (priority, source_channel, customer_tag) | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Index: `(organization_id, enabled, order)`. `conditions` is stored as a JSON string because the shape is known and validated by Zod on read/write. It is not `any`. ### `support_labels` This module does **not** create a new table. It extends the existing `labels` table by treating labels with `kind = "support"` as support labels. `support_tickets` already links to `labels` via `support_ticket_labels`. A support label is just a `labels` row where `kind` is `"support"`. ## Code Style ```ts export const supportSnippetSchema = z.object({ id: z.string(), organizationId: z.string(), name: z.string(), textContent: z.string(), markdownContent: z.string().optional(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type SupportSnippet = z.infer; ``` - snake_case in SQL, camelCase in TypeScript. - Native Drizzle columns; no `any`. - Conditions are parsed/validated with Zod, never cast. ## API Surface ### `POST /workspaces/{organizationId}/support/snippets` Create a snippet. ### `GET /workspaces/{organizationId}/support/snippets` List snippets. ### `GET /workspaces/{organizationId}/support/snippets/{snippetId}` Get one snippet. ### `PATCH /workspaces/{organizationId}/support/snippets/{snippetId}` Update a snippet. ### `POST /workspaces/{organizationId}/support/snippets/{snippetId}/insert` Returns the rendered text/markdown for a given ticket context. The client uses this to pre-fill a reply. ### `POST /workspaces/{organizationId}/support/autoresponders` Create an autoresponder rule. ### `GET /workspaces/{organizationId}/support/autoresponders` List autoresponders. ### `PATCH /workspaces/{organizationId}/support/autoresponders/{autoresponderId}` Enable/disable or update a rule. ### `POST /workspaces/{organizationId}/support/labels` Create a support label. Reuses the existing `labels` table with a `kind = "support"` marker. ### `GET /workspaces/{organizationId}/support/labels` List support labels. ## Storage Choice `support-content` lives in **D1**. Snippets and autoresponders are workspace-scoped metadata. Labels are already global in D1. ## Testing Strategy - Tests in `src/api/support-content.test.ts`. - Create snippets, apply them to a ticket, and assert the rendered output. - Create autoresponders and trigger `ticket_created` / `customer_replied` events; assert the correct reply is created as a `support_ticket_events` row. ## Migration Path `support-migration` will not import snippets or autoresponders from Intercom or Plain in v1. Only labels are migrated, if the source system has them. Intercom `tags` and Plain `labels` map to `labels` with `kind = "support"`. ```ts upsertSupportLabel(db, organizationId, { externalId, externalSource, name }); ``` ## Boundaries ### Always - Validate inputs with Zod. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Adding a new `trigger` type beyond `ticket_created`, `customer_replied`, `out_of_hours`. - Changing the `conditions` schema. - Moving snippets or autoresponders to the workspace Durable Object. ### Never - Commit secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 tables `support_snippets` and `support_autoresponders` exist. - [ ] Support labels are created using the existing `labels` table. - [ ] Snippet insert endpoint returns rendered text/markdown. - [ ] Autoresponder rules create `support_ticket_events` of type `message` when triggered. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Should `support_snippets` support variable interpolation (e.g., `{{customer.firstName}}`) in v1, or just static text? 2. Should autoresponders be allowed to assign, label, and snooze in addition to sending a reply, or is reply-only enough for v1? 3. Do we need a `support_macros` table for multi-step actions, or are snippets + autoresponders enough? # ===== docs/SPEC-support-escalation-and-automation.md ===== # Spec: Support auto-escalation ## Objective Let agents (and the teams running them) automatically promote a support ticket to a linked engineering issue when simple conditions match. Keep the support ticket as the customer record and the issue as the linked work item. This is intentionally the **first consumer** of a trigger/condition/action pattern, not a generic platform workflow engine. When a second domain needs the same shape, we will lift it into a generic `automations` table. A generic engine with one consumer is a second system. ## End users are agents - Rules are created, read, updated and deleted through the API/CLI/MCP, not a UI wizard. - A rule is a small JSON object an agent can generate from instructions. - No nested AND/OR DSL. All conditions in a rule are ANDed; OR is expressed by creating multiple rules. - No human-in-the-loop approval. Approval is for AI agent actions; rules are deterministic admin config. - Execution is synchronous after the triggering event so agents can observe the result immediately. ## What the players do (and what we keep) - **Manual link is the baseline.** Plain `i`, Linear/Intercom/Zendesk sidebar widgets all link a support thread to an existing or new issue. We already expose this via `PATCH /support/tickets/:id` (`issueId`). - **Rules are admin config.** Zendesk Triggers, Linear Triage Rules, Plain Workflows run automatically once configured. We use the same model. - **Issues land in triage/backlog.** Linear/Plain/Zendesk create issues in a non-active state. We default to `triage`. - **Context travels.** The linked issue must include customer email, channel, and a link back to the support ticket. - **No OR DSL.** Players have `all`/`any` grouping, but that is UI sugar. For an agent-first API, OR is cheaper and clearer as multiple rules. ## Data model ```ts export const supportEscalationRules = sqliteTable( "support_escalation_rules" as string, { id: text("id" as string).primaryKey(), organizationId: text("organization_id" as string) .notNull() .references(() => organization.id), name: text("name" as string).notNull(), isActive: integer("is_active" as string, { mode: "boolean" }) .notNull() .default(true), sortOrder: integer("sort_order" as string) .notNull() .default(0), conditions: text("conditions" as string).notNull(), // JSON action: text("action" as string).notNull(), // JSON createdAt: text("created_at" as string) .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at" as string) .notNull() .default(sql`CURRENT_TIMESTAMP`), }, (table) => [ index("support_escalation_rules_org_active_order_idx" as string).on( table.organizationId, table.isActive, table.sortOrder ), ] ); ``` `conditions` schema — all top-level keys are optional; when present they are ANDed: ```ts export const escalationConditionsSchema = z.object({ keywords: z.array(z.string().min(1)).optional(), channels: z.array(supportTicketChannelEnum).optional(), priorities: z.array(supportTicketPriorityEnum).optional(), statuses: z.array(supportTicketStatusEnum).optional(), sources: z.array(supportTicketSourceEnum).optional(), customerDomains: z.array(z.string()).optional(), }); ``` `action` schema for the first slice: ```ts export const escalationActionSchema = z.object({ type: z.literal("create_issue"), teamId: z.string().optional(), // default team if omitted priority: z.enum(ISSUE_PRIORITIES).optional(), status: z.enum(ISSUE_STATUSES).optional().default("triage"), labels: z.array(z.string()).optional(), }); ``` ## Triggers First slice: `support_ticket.created` only. `support_message.received`, `support_ticket.status_changed`, and `support_ticket.priority_changed` are intentionally out of scope until a customer/agent actually asks for them. They are trivial to add later by calling the same evaluator at the right moment. ## Execution - After a support ticket is created, load active rules for the workspace ordered by `sortOrder`, then `createdAt`. - Build context: `{ ticket, subject, text, customer, channel, source }`. - For each rule: - If `ticket.issueId` is already set, stop (idempotent). - If every present condition matches, execute `create_issue`. - Call `WorkspaceDO.createIssue(...)` with the configured team, priority, status, labels. - Title: support ticket title. - Description: support ticket text + customer email + channel + source + link back to support ticket. - Update `support_tickets.issueId`. - Insert `support_ticket_events` row: `type: "link_added"`, `actorType: "automation"`, `actorId: rule.id`, `metadata: { issueId, ruleId }`. - Stop; one issue per support ticket. ## Manual linking Already supported: `PATCH /support/tickets/:id` accepts `{ issueId: string | null }`. The first slice must also fix `updateTicket` to emit `link_added`/`link_changed`/`link_removed` events when `issueId` changes, with `actorType: "user"` and the acting user id. ## API - `GET /support/escalation-rules` - `POST /support/escalation-rules` - `GET /support/escalation-rules/:ruleId` - `PATCH /support/escalation-rules/:ruleId` - `DELETE /support/escalation-rules/:ruleId` (hard-delete; audit is in `support_ticket_events`) ## CLI/MCP example ```bash pile support escalation-rules create \ --org org_vortex_main \ --name "bug-from-intercom" \ --conditions '{"keywords":["bug","broken"],"channels":["intercom"]}' \ --action '{"type":"create_issue","priority":"high","status":"triage"}' ``` ## Boundaries - **Always:** Zod-validate `conditions` and `action` at write time; evaluate synchronously after ticket creation; record a `link_added` event. - **Ask first:** adding `support_message.received`/status/priority triggers; adding non-`create_issue` actions; generalizing to a cross-domain `automations` table. - **Never:** store issue state in D1; commit secrets; auto-merge. ## Success criteria - `PATCH /support/tickets/:id` with `issueId` links/unlinks and emits the right event. - `POST /support/escalation-rules` creates a typed rule. - An Intercom webhook for a ticket containing "bug" creates a linked issue when a rule matches. - `pnpm run typecheck && pnpm run check && pnpm test` green. # ===== docs/SPEC-support-import-fidelity.md ===== # Spec: Support Import Fidelity ## Objective Make the Intercom, Plain, and Zendesk support import adapters capture the full provider timeline, not just the message text. Every `part`, `timelineEntry`, and `comment` the provider returns should be preserved in Pile as a `support_ticket_event` of the closest native type. ## Success Criteria 1. Provider-specific notes (Intercom `note` parts, Plain `NoteEntry`, Zendesk `public=false` comments) are imported as `support_ticket_events` with `type = "note"`. 2. Every imported message/note/event records the original provider actor `actorType` (`customer` | `user` | `agent` | `automation`) and `actorId`. 3. Provider attachments are stored in a new `support_ticket_attachments` table linked to the event. 4. Non-message timeline items are imported as `support_ticket_events` with `type` mapped to a proper native event type and `subType` preserving the exact provider entry/event name. 5. The three adapter tests exercise notes, actor fields, attachments, metadata, `subType`, and the full event type taxonomy. 6. `pnpm run check` passes. ## Implemented Mapping ### Schema - `support_ticket_events.type` is a typed, native Pile event category (e.g. `status_change`, `label_added`, `link_added`). - `support_ticket_events.sub_type` stores the exact provider entry name (e.g. `ThreadLinkCreatedEntry`, `conversation_rating`, `Change:tags`) so every provider concept has a home. - `support_ticket_events.metadata` stores the full provider payload / delta as JSON text. - `support_ticket_attachments` links attachments to `ticketId` and `eventId` with `externalId`, `url`, `fileName`, `contentType`, `size`, `r2Key`, `createdAt`. ### Primitive - `addTicketMessage`, `addTicketNote`, `addTicketEvent`, `addSupportTicketAttachment` accept explicit `actorType`, `actorId`, `subType`, `metadata`, and `createdAt`. - `ingestSupportTimeline` creates the first inbound message, then replies and events in provider order, attaching attachments to the correct event. - `findUserByEmail` resolves a provider agent to a Pile `user` by email. - `setTicketAssignees` replaces a ticket's current assignees; only matched Pile users are linked, preserving `isPrimary` for the primary owner. ### Contact graph - `support_customers`, `support_companies`, `support_customer_companies`, and `support_customer_identities` are populated during import. - `support_customer_identities.type` is a native channel (`email`, `phone`, `slack`, `msteams`, `discord`, `whatsapp`, `chat`, `api`, `social`, `custom`). - `support_customer_identities.sub_type` stores the exact provider identity type (`EmailCustomerIdentity`, `twitter`, `facebook`, etc.). - `findOrCreateCompany` deduplicates companies by `externalId` + `externalSource` so repeated runs don't duplicate `support_companies`. ### Intercom contacts - The primary contact from `conversation.contacts` is looked up with `/contacts/{id}` to fetch `companies`, `phone`, `external_id`, `custom_attributes`, and `social_profiles`. - `companies` become `support_companies` (domain from `website`, external id from `company_id` > `id`). - `email` and `phone` become `email`/`phone` identities; `social_profiles` become `social` or `custom` identities with the provider `sub_type` preserved. - `conversation.assignee` with `type = "admin"` and a matching Pile user email populates `support_ticket_assignments`. ### Plain customers - The thread's `customer` now includes `externalId`, `identities`, `company`, and `tenantMemberships(first: 20)`. - `customer.company` becomes the primary `support_company`. - `customer.tenantMemberships.edges[].node.tenant` become additional `support_company` rows (`externalId` from `externalId` > `id`). - `customer.identities` (`EmailCustomerIdentity`, `SlackCustomerIdentity`, `DiscordCustomerIdentity`) become `support_customer_identities` with the original `__typename` as `sub_type`. - `thread.assignedTo` (when `User` with email) and `thread.additionalAssignees` resolve to Pile users and populate `support_ticket_assignments`. ### Zendesk users and organizations - `listZendeskTickets` includes `users`; `users` now include `phone` and `organization_id`. - `listAllZendeskOrganizations` fetches all organizations and maps `requester.organization_id` to a `support_company`. - `requester.email` and `requester.phone` become `email`/`phone` identities. - `ticket.assignee_id` resolves to a Pile user by email and populates `support_ticket_assignments`. ### Intercom - `conversation_parts` are sorted by `created_at` and mapped: - `comment`, `whatsapp`, `linked_message` (with `body`) → `message` - `note` (with `body`) → `note` - `open`/`close`/`snoozed`/`waiting` → `status_change` - `assigned`/`unassigned`/`assignment` → `assignment_change` - `conversation_rating`/`rating`/`survey`/`csat`/`nps` → `custom_entry` - `feedback` → `customer_event` - `custom_bot`/`custom_card` → `custom_entry` - `follow_up`/`push_notification`/`whatsapp`/`linked_message` (no `body`) → `notification` - `source_add`/`ticket_shared`/`automation_flywheel`/`log_event`/`default` → `thread_event` - everything else → `field_change` - `subType` = `part_type`. - `author.type` is normalized to `customer`/`user`/`agent`/`automation`. - `attachments` on `comment`/`note`/`whatsapp`/`linked_message` parts are captured. ### Plain - `timelineEntries` are fetched with `llmText`, actor fragments (`CustomerActor`, `DeletedCustomerActor`, `UserActor`, `SystemActor`, `MachineUserActor`), and `entry.__typename` (aliased to `typename`). - `NoteEntry` → `note` (`subType = NoteEntry`). - `ChatEntry`, `EmailEntry`, `SlackMessageEntry`, `SlackReplyEntry`, `MSTeamsMessageEntry`, `DiscordMessageEntry`, `ThreadDiscussionMessageEntry`, `MergedThreadMessageEntry`, `HelpCenterAiConversationMessageEntry` → `message` (`subType = typename`). - `ThreadStatusTransitionedEntry` → `status_change` - `ThreadPriorityChangedEntry` → `priority_change` - `ThreadAssignmentTransitionedEntry`/`ThreadAdditionalAssigneesTransitionedEntry` → `assignment_change` - `ThreadLabelsChangedEntry` → `label_added`/`label_removed` (diff of `previousLabels`/`nextLabels`) - `CustomerEventEntry` → `customer_event` - `CustomerSurveyRequestedEntry` → `custom_entry` - `ThreadServiceLevelAgreementPolicyChangedEntry`/`ServiceLevelAgreementStatusTransitionedEntry` → `custom_entry` - `ThreadLinkCreatedEntry`/`ThreadLinkTargetCreatedEntry` → `link_added` - `ThreadLinkUpdatedEntry` → `link_changed` - `ThreadLinkDeletedEntry`/`ThreadLinkTargetDeletedEntry` → `link_removed` - `ThreadDiscussionEntry` → `discussion` - `ThreadDiscussionResolvedEntry` → `discussion_resolved` - `ThreadEventEntry` → `thread_event` - `CustomEntry` → `custom_entry` - `LinearIssueThreadLinkStateTransitionedEntry` → `external_reference_changed` - All `subType` values equal `entry.typename`. - `attachments` on message-like entries are captured; Plain does not expose a persistent attachment URL on `Attachment`, only a short-lived `createAttachmentDownloadUrl` mutation, so `url` is stored as `null` and the attachment ID is preserved for later resolution. ### Zendesk - `comments` are fetched with `include=users`. - `public=false` comments → `note` (`subType = InternalComment`); `public=true` comments → `message` (`subType = Comment`). - `author_id` is resolved to a `role` from the included users; `requester_id`/`end-user` → `customer`, `agent`/`admin` → `user`, `system` → `automation`. - `via.channel` is normalized to a native message channel. - `attachments` on comments are captured. - `audits` are fetched and each audit `event` is mapped: - `Change` on `status`/`priority`/`assignee_id`/`group_id`/`tags` → `status_change`/`priority_change`/`assignment_change`/`label_added`/`label_removed` (`subType = Change:{field_name}`) - `Change` on other fields → `field_change` - `SatisfactionRating` → `custom_entry` - `Notification`/`NotificationWithCcs`/`ForwardingEvent` → `notification` - `Cc`/`FollowersCc`/`FollowerChangeAction` → `watchers_changed` - `ProblemSolvedEvent`/`ProblemsSolvedEvent` → `status_change` - `Create`/`AgentWorkspaceSwitch`/`ExternalEvent`/`ChannelFrameworkEvent`/`AgentMacroReference`/`OrganizationActivity`/`Error`/`CommentPrivacyChange` → `thread_event` - `Comment`/`VoiceComment` → skipped (already in `comments.json`) - everything else → `field_change` - A `field_change` event is synthesized from the raw `ticket` payload to preserve subject/status/priority/tags/custom fields. ## Caveats - Plain attachment URLs are not stored because Plain only returns a 3-minute signed `downloadUrl` via `createAttachmentDownloadUrl`; the attachment ID and metadata are preserved so the URL can be resolved on demand. - Sequential provider pagination remains intentional; `Promise.all` is used per page and per reply/event for order-insensitive writes. - Zendesk `Comment`/`VoiceComment` audit events are intentionally skipped because the comments endpoint already provides them; `Comment` bodies with `attachments` are in `support_ticket_attachments`. ## Boundaries - Always: provider-native parsing, Zod schemas, `pnpm run check` before commit. - Ask first: changing the public REST API contract (we will not for this slice). - Never: fabricate users, store secrets, downgrade checks, or use `any`. # ===== docs/SPEC-support-inbox.md ===== # Spec: support-inbox ## Objective Define the seventh module of the customer support layer: the agent-facing queue and inbox. `support-inbox` is a read/query layer on top of `support-tickets`, `support-team`, and `support-content`. It lists tickets, applies filters, shows queues, and returns the data an agent needs to triage and respond. It does not own the ticket data; it reads it. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for query and filter validation. - Native provider primitives only. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-inbox.ts # D1 query helpers src/global/schema.ts # support_saved_views table src/api/support-inbox.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_saved_views` Saved filters for the inbox. A view is a stored `filter` plus a `sort`. | Column | Type | Notes | | ----------------- | ---------------------- | ---------------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `user_id` | text FK → user | nullable; personal view if set, shared if null | | `name` | text | not null | | `filter` | text | JSON string; validated by Zod | | `sort` | text | JSON string; e.g., `updated_at desc` | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Index: `(organization_id, user_id)`. No other tables are added. `support-inbox` reads `support_tickets`, `support_ticket_events`, `support_ticket_assignments`, `support_ticket_labels`, `support_user_status`, `support_slas`, and `support_ticket_sla_events`. ### `support_inbox_ticket` (computed, not a table) The list API returns a computed shape that joins the core ticket, customer, primary assignee, labels, and SLA status. ```ts export const supportInboxTicketSchema = z.object({ id: z.string(), number: z.number().int(), title: z.string(), status: z.enum(["todo", "done", "snoozed"]), priority: z.enum(["low", "medium", "high", "urgent"]), customer: supportCustomerSchema, primaryAssignee: z.string().optional(), labels: z.array(z.string()), lastCustomerMessageAt: z.string().datetime().optional(), lastAgentMessageAt: z.string().datetime().optional(), sla: z .object({ firstResponseTargetAt: z.string().datetime().optional(), firstResponseBreached: z.boolean().default(false), resolutionTargetAt: z.string().datetime().optional(), resolutionBreached: z.boolean().default(false), }) .optional(), updatedAt: z.string().datetime(), }); ``` ## Code Style - All query filters are explicit Zod schemas. - No raw SQL strings for filtering; use Drizzle `where` clauses built from the parsed filter. - snake_case in SQL, camelCase in TypeScript. - No `any`. ## API Surface ### `GET /workspaces/{organizationId}/support/inbox` List tickets. Query params: `status`, `priority`, `assignedTo`, `customerId`, `label`, `channel`, `q` (search title/customer), `slaBreach`, `limit`, `cursor`. The endpoint always returns the computed `supportInboxTicket` shape. ### `GET /workspaces/{organizationId}/support/inbox/counts` Summary counts by status: `todo`, `done`, `snoozed`, plus `mine` (tickets assigned to the caller), `unassigned`. ### `GET /workspaces/{organizationId}/support/inbox/next` Returns the next ticket the agent should work, based on routing rules from `support-team`. Query: `tierId`, `status`. ### `GET /workspaces/{organizationId}/support/views` List saved views. ### `POST /workspaces/{organizationId}/support/views` Create a saved view. Body: `name`, `filter`, `sort`. ### `GET /workspaces/{organizationId}/support/views/{viewId}` Get a saved view and its current ticket list. ### `POST /workspaces/{organizationId}/support/views/{viewId}/run` Run a saved view and return the ticket list. This is the same query as `GET /support/inbox` but with stored filter/sort. ## Storage Choice `support-inbox` lives in **D1**. It is query-only. The only table it writes is `support_saved_views`. For real-time updates, `support-inbox` may later subscribe to a Durable Object broadcast from `support-channels`; that is out of v1. ## Testing Strategy - Tests in `src/api/support-inbox.test.ts`. - Seed `support_tickets`, `support_customers`, `support_ticket_assignments`, `support_ticket_sla_events`. - Test filters, counts, saved views, and `next` routing. ## Migration Path `support-inbox` does not migrate data. It exposes the read model that `support-migration` and `support-channels` populate. Saved views are manual-only in v1. ## Boundaries ### Always - Validate filters with Zod before running queries. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Adding real-time / pub-sub to the inbox. - Moving inbox queries to the workspace Durable Object. - Adding a `search` index beyond simple `LIKE` on title/customer. ### Never - Commit secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 table `support_saved_views` exists. - [ ] `GET /support/inbox` returns filtered tickets with customer, assignee, labels, and SLA. - [ ] `GET /support/inbox/counts` returns status and assignment counts. - [ ] `GET /support/inbox/next` returns the next ticket based on routing rules. - [ ] Saved views can be created and re-run. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Should the inbox use `LIKE` on `title`/`customer.name` for search, or do we need a full-text index from the start? 2. Should `support-inbox` also expose a WebSocket or SSE endpoint for real-time updates, or is polling the only v1 path? 3. Do saved views live only in D1, or should the user's personal views be stored in the workspace Durable Object? # ===== docs/SPEC-support-migration.md ===== # Spec: support-migration ## Objective Define the fifth module of the customer support layer: import and ongoing sync from external support providers. `support-migration` does two things. It bulk-imports historical conversations, customers, companies, and labels from Intercom, Zendesk, and Plain. It also exposes the primitives that webhook handlers in `support-channels` use for real-time updates. This module validates that the `support-contacts`, `support-tickets`, and `support-content` data models can represent the data from all three providers. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for credential and payload validation. - Native fetch + provider APIs (Intercom REST, Zendesk REST, Plain GraphQL). - Native Web Crypto for HMAC webhook verification. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/support-migration/ intercom.ts # Intercom bulk + upsert primitives zendesk.ts # Zendesk bulk + upsert primitives plain.ts # Plain bulk + upsert primitives types.ts # shared adapter types src/import/ # existing generic import job framework src/global/support-migration.ts # D1 helpers src/api/support-migration.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### Reused Tables `support-migration` does not create new tables for import runs. It reuses the existing: - `import_jobs` — tracks import runs. - `import_mappings` — generic source-id → target-id mapping. - `intercom_conversations` — already exists for Intercom conversation → ticket mapping. If `import_jobs` or `import_mappings` are missing fields, they are extended in their own migrations, not duplicated. ### New Mapping Tables (v1) Only `intercom_conversations` exists today. The following are added for Zendesk and Plain: | Table | Purpose | | ----------------- | ---------------------------------------------------- | | `zendesk_tickets` | Zendesk `ticket.id` → `support_tickets.id` mapping | | `plain_threads` | Plain `thread.id` → `support_tickets.id` mapping | | `plain_customers` | Plain `customer.id` → `support_customers.id` mapping | These tables are shaped like `intercom_conversations`: `id`, `organization_id`, `external_id`, `issue_id` / `ticket_id`, `created_at`. ## Code Style ```ts export const supportMigrationCredentialsSchema = z.discriminatedUnion( "source", [ z.object({ source: z.literal("intercom"), token: z.string(), clientSecret: z.string().optional(), }), z.object({ source: z.literal("zendesk"), subdomain: z.string(), token: z.string(), }), z.object({ source: z.literal("plain"), apiKey: z.string(), }), ] ); export type SupportMigrationCredentials = z.infer< typeof supportMigrationCredentialsSchema >; ``` - No `any`. Union types are modeled with `z.discriminatedUnion`. - Provider-specific credential fields are explicit. ## Adapter Interface Each source implements the existing `ImportSource` interface from `src/import/types.ts`: ```ts interface ImportSource { name: string; validate(credentials: TCredentials): ImportValidationResult; run( ctx: ImportContext, credentials: TCredentials, options: TOptions, state?: ImportRunState ): Promise; } ``` The `ctx` includes `ctx.db`, `ctx.organizationId`, `ctx.stub` (workspace DO), `ctx.importerId`. For `support-migration` the adapter uses `ctx.db` only and writes `support_*` tables; it does not call the workspace Durable Object. ## API Surface ### `POST /workspaces/{organizationId}/support/imports` Start an import. Body: ```json { "source": "intercom", "credentials": { "token": "..." }, "options": { "teamId": "...", "state": "all", "limit": 1000 } } ``` Returns an `import_id`. ### `GET /workspaces/{organizationId}/support/imports/{importId}` Get import status and counts. ### `POST /workspaces/{organizationId}/support/imports/{importId}/resume` Resume a paused import from its cursor. ### `POST /workspaces/{organizationId}/support/imports/{importId}/cancel` Cancel a running import. ### `POST /workspaces/{organizationId}/support/imports/{importId}/validate` Validate credentials without running a full import. ## Provider Mappings ### Intercom - API: `https://api.intercom.io`. - Version: `Intercom-Version: 2.16`. - Auth: Bearer token. - Endpoints: - `GET /conversations` → `support_tickets` - `GET /contacts` → `support_customers` - `GET /companies` → `support_companies` - State mapping: `open` → `todo`, `closed` → `done`, `snoozed` → `snoozed`. - Webhook: `POST /support/webhooks/intercom/{organizationId}` (handler lives in `support-channels`; calls `upsertIntercomTicket`). ### Zendesk - API: `https://{subdomain}.zendesk.com/api/v2`. - Auth: Bearer token. - Endpoints: - `GET /api/v2/tickets` → `support_tickets` - `GET /api/v2/users` → `support_customers` - `GET /api/v2/organizations` → `support_companies` - State mapping: `new`/`open` → `todo`, `pending` → `todo`, `solved`/`closed` → `done`. - Webhook: `POST /support/webhooks/zendesk/{organizationId}`. ### Plain - API: `https://core-api.uk.plain.com/graphql/v1`. - Auth: Bearer API key. - Operations: - `threads` query → `support_tickets` - `customers` query → `support_customers` - `tiers` query → `support_tiers` - `labels` query → `labels` with `kind = "support"` - State mapping: `todo` → `todo`, `done` → `done`. - Webhook: `POST /support/webhooks/plain/{organizationId}`; signed with `plain-request-signature`. ## Storage Choice `support-migration` stores import state in **D1** (`import_jobs`, mapping tables). It writes support data into the same D1 `support_*` tables. No Durable Object state is needed for migration. ## Testing Strategy - Tests in `src/api/support-migration.test.ts`. - Mock Intercom/Zendesk/Plain HTTP responses using `msw` or the existing test harness. - Assert that imports create the right `support_customers`, `support_companies`, `support_tickets`, `support_ticket_events` rows. - Assert that re-running an import with the same `external_id` is idempotent (updates, not duplicates). ## Migration Path The existing `src/import/intercom.ts` is the workspace-issue adapter. `support-migration/intercom.ts` is a new adapter that targets `support_*` tables. The two can coexist: the existing one is for `ISS-3` (engineering issue tracker use), the new one is for `ISS-9` (support). If they must merge later, they share the same credential validation and list logic. ```ts import { supportIntercomImportSource } from "../support-migration/intercom.js"; ``` ## Boundaries ### Always - Validate credentials before any network call. - Run `vp check` and `pnpm test` before committing. - Use `z.discriminatedUnion` for provider credentials. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Adding a new provider to `support-migration`. - Removing the existing `src/import/intercom.ts` workspace-issue adapter. - Moving import state to the workspace Durable Object. ### Never - Commit provider tokens or secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] `support-migration/intercom.ts` adapter creates `support_customers`, `support_companies`, `support_tickets`, `support_ticket_events`. - [ ] `support-migration/zendesk.ts` and `support-migration/plain.ts` stubs exist with credential schemas. - [ ] `POST /workspaces/{id}/support/imports` starts a job and returns an `import_id`. - [ ] Re-imports are idempotent. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Should the existing `src/import/intercom.ts` workspace-issue adapter be kept, renamed, or deleted once `support-migration/intercom.ts` exists? 2. Do we import Plain's `customers` and `tenants` first, or import `threads` first and create missing customers on the fly? 3. Should `support-migration` own the webhook handlers, or should `support-channels` own the HTTP routes and call `support-migration` upsert primitives? # ===== docs/SPEC-support-team.md ===== # Spec: support-team ## Objective Define the third module of the customer support layer: the team and assignment model. `support-team` controls who works support tickets, whether they are available, how tickets are routed, and how SLAs are tracked. It reuses Pile's existing `users`, `teams`, and `team_member` tables where possible and adds support-specific tables for status, tiers, and SLAs. This module is the foundation for the `support-inbox` and `support-migration`. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for input validation. - Native provider primitives only. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-team.ts # D1 helpers src/global/schema.ts # support_user_status, support_tiers, support_slas, support_ticket_sla_events tables src/api/support-team.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_user_status` Tracks whether a support agent is currently available. This is per-user, per-workspace. | Column | Type | Notes | | ----------------- | ---------------------- | -------------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `user_id` | text FK → user | not null | | `status` | text | `active`, `away`, `snoozed`, `offline` | | `until` | text | ISO timestamp, nullable; for `snoozed` | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, user_id)`. `active` agents are eligible for assignment. `away`/`snoozed`/`offline` agents are skipped by routing unless a ticket is explicitly assigned to them. ### `support_tiers` Customer / support tiers. Plain uses these for routing and SLA. A tier is just a named group in v1. | Column | Type | Notes | | ----------------- | ---------------------- | --------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `name` | text | not null | | `level` | integer | not null | Unique: `(organization_id, name)`. ### `support_tier_members` Which users belong to which tier. | Column | Type | Notes | | --------- | ----------------------- | --------- | | `id` | text PK | Pile UUID | | `tier_id` | text FK → support_tiers | not null | | `user_id` | text FK → user | not null | Unique: `(tier_id, user_id)`. ### `support_slas` SLA rules. Each rule applies to a priority and a tier. | Column | Type | Notes | | ------------------------ | ----------------------- | --------------------------------- | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `name` | text | not null | | `tier_id` | text FK → support_tiers | nullable; null means "all tiers" | | `priority` | text | `low`, `medium`, `high`, `urgent` | | `first_response_minutes` | integer | nullable | | `next_response_minutes` | integer | nullable | | `resolution_minutes` | integer | nullable | | `business_hours_only` | boolean | default false | | `created_at` | text | ISO timestamp | ### `support_ticket_sla_events` Tracks SLA targets and breaches for each ticket. An event is recorded when the clock starts (ticket created, customer message) or when a target is met/missed. | Column | Type | Notes | | ----------- | ------------------------- | -------------------------------------------------------------------- | | `id` | text PK | Pile UUID | | `ticket_id` | text FK → support_tickets | not null | | `sla_id` | text FK → support_slas | not null | | `type` | text | `first_response_target`, `next_response_target`, `resolution_target` | | `target_at` | text | ISO timestamp | | `met_at` | text | ISO timestamp, nullable | | `breached` | boolean | default false | Index: `(ticket_id, type)`. ## Code Style ```ts export const supportUserStatusSchema = z.object({ id: z.string(), organizationId: z.string(), userId: z.string(), status: z.enum(["active", "away", "snoozed", "offline"]), until: z.string().datetime().optional(), updatedAt: z.string().datetime(), }); export type SupportUserStatus = z.infer; ``` - snake_case in SQL, camelCase in TypeScript. - Native Drizzle columns; no `any`. ## API Surface ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/assign` Assign a ticket to a user. Body: `userId`, optional `isPrimary`. A primary assignment replaces any existing primary assignment. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/unassign` Remove an assignment. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/additional-assignees` Add additional assignees (Plain-style `addAdditionalAssignees`). ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/routing` Auto-route the ticket to an available agent based on tier, load, and round-robin. Returns the assigned `userId`. ### `POST /workspaces/{organizationId}/support/users/{userId}/status` Set a user's support status. Body: `status`, optional `until`. ### `GET /workspaces/{organizationId}/support/agents` List users who can be assigned support tickets, with their status and current open ticket count. ### `POST /workspaces/{organizationId}/support/tiers` Create a tier. ### `GET /workspaces/{organizationId}/support/tiers` List tiers. ### `POST /workspaces/{organizationId}/support/tiers/{tierId}/members` Add a user to a tier. ### `POST /workspaces/{organizationId}/support/slas` Create an SLA rule. ### `GET /workspaces/{organizationId}/support/tickets/{ticketId}/sla` Get SLA targets for the ticket. ## Storage Choice `support-team` lives in **D1**. Agent status, tiers, and SLAs are global metadata queried by the inbox and routing. Ticket assignment rows (`support_ticket_assignments`) already live in the `support-tickets` module; `support-team` writes to them through helpers. ## Testing Strategy - Tests in `src/api/support-team.test.ts`. - Set agent status, create tickets, assign, route, and assert that `away` users are skipped. - SLA tests create a rule, then create a ticket and check that `support_ticket_sla_events` rows are created with the right `target_at`. ## Migration Path `support-migration` will bring over agent assignments from Intercom and Plain. Intercom has no native assignment in the webhook we receive, so the migration sets `assigned_to` if the conversation has an `assignee`. Plain has `assignedTo` and `additionalAssignees` on every thread; these map directly to `support_ticket_assignments`. ```ts assignTicket(db, organizationId, { ticketId, userId, isPrimary, actorId, }); ``` ## Boundaries ### Always - Validate inputs with Zod. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. ### Ask First - Changing the list of `support_user_status` values. - Routing algorithm changes (round-robin, load-based, skill-based). - Moving assignment storage to the workspace Durable Object. ### Never - Commit secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 tables `support_user_status`, `support_tiers`, `support_tier_members`, `support_slas`, `support_ticket_sla_events` exist. - [ ] Assign/unassign/additional-assignee routes update `support_ticket_assignments` correctly. - [ ] Routing respects `away`/`snoozed`/`offline` status. - [ ] SLA rules generate `support_ticket_sla_events` when a ticket is created. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. ## Open Questions 1. Do we support multiple named support teams (e.g., `billing`, `technical`) or is a single support team with `tiers` enough for v1? 2. Should routing be simple round-robin, or load-based (least open tickets)? 3. Do we need business-hours logic for SLAs in v1, or is `business_hours_only` a placeholder? 4. Should agent status be per-workspace or global to the user? # ===== docs/SPEC-support-tickets.md ===== # Spec: support-tickets ## Objective Define the second module of the customer support layer: the ticket (Plain calls it a `Thread`). `support-tickets` is the conversation object. It has a customer, a status, a priority, an assignee, a timeline of events (messages, notes, status changes, assignments), and labels. It is the target for every channel (email, Slack, chat, Intercom, Zendesk, Plain) and the thing the `support-inbox` lists. This module must be compatible with the Intercom conversation we already import (`ISS-3`) and the Plain thread we want to replace. ## Tech Stack - TypeScript. - Hono + `@hono/zod-openapi`. - Drizzle ORM on D1. - Zod for input and webhook payload validation. - Native provider primitives only. ## Commands ```bash pnpm exec vp check --fix pnpm test pnpm run db:generate CLOUDFLARE_API_TOKEN=... pnpm exec wrangler d1 migrations apply pile-global -e production --remote CLOUDFLARE_API_TOKEN=... pnpm exec wrangler deploy -e production ``` ## Project Structure ``` src/global/support-tickets.ts # D1 helpers src/global/schema.ts # support_tickets, support_ticket_events, support_ticket_messages, support_ticket_notes, support_ticket_labels, support_ticket_assignments tables src/api/support-tickets.ts # REST routes src/mcp/... # generated packages/client/src/types.ts # generated packages/cli/src/commands.ts # generated migrations/ # Drizzle-generated D1 migrations ``` ## Data Model ### `support_tickets` | Column | Type | Notes | | -------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------ | | `id` | text PK | Pile UUID | | `organization_id` | text FK → organization | workspace | | `customer_id` | text FK → support_customers | who opened it | | `number` | integer | per-workspace ticket number, monotonic, not null | | `external_id` | text | optional source id | | `external_source` | text | `intercom`, `zendesk`, `plain`, `email`, `slack`, `chat`, `api`, ... | | `title` | text | not null; auto from subject or first message preview | | `status` | text | `todo`, `done`, `snoozed` | | `priority` | text | `low`, `medium`, `high`, `urgent` | | `source_channel` | text | `email`, `slack`, `msteams`, `discord`, `chat`, `capture`, `api`, `intercom`, `zendesk`, `plain` | | `issue_id` | text | optional; links to a Pile issue when a ticket is promoted to engineering work | | `last_customer_message_at` | text | ISO timestamp, nullable | | `last_agent_message_at` | text | ISO timestamp, nullable | | `created_at` | text | ISO timestamp | | `updated_at` | text | ISO timestamp | Unique: `(organization_id, number)`. Index: `(organization_id, customer_id)`, `(organization_id, status)`, `(organization_id, priority)`. `number` is a per-workspace counter. A helper `nextTicketNumber(db, organizationId)` reads/increments a row in `support_ticket_counters`. ### `support_ticket_counters` | Column | Type | Notes | | ----------------- | ------- | ----------- | | `organization_id` | text PK | workspace | | `next_number` | integer | starts at 1 | Used only for allocation; not user-facing. ### `support_ticket_events` A timeline of anything that happened on the ticket. Every row has a type. Details live in child tables. | Column | Type | Notes | | ------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | text PK | Pile UUID | | `ticket_id` | text FK → support_tickets | not null | | `type` | text | `message`, `note`, `status_change`, `priority_change`, `assignment_change`, `label_added`, `label_removed`, `customer_event`, `field_change` | | `actor_type` | text | `customer`, `user`, `machine`, `system` | | `actor_id` | text | nullable; customer, user, or machine id | | `created_at` | text | ISO timestamp | Index: `(ticket_id, created_at)`. ### `support_ticket_messages` Customer-visible events with actual content. A message is always an `event`. | Column | Type | Notes | | ------------------ | ------------------------------- | ---------------------------------------------------------- | | `id` | text PK | Pile UUID | | `event_id` | text FK → support_ticket_events | not null | | `direction` | text | `inbound` or `outbound` | | `text_content` | text | plain text, required | | `markdown_content` | text | nullable; for rich clients | | `channel` | text | `email`, `slack`, `msteams`, `discord`, `chat`, `api`, ... | | `customer_id` | text FK → support_customers | for inbound messages | | `user_id` | text FK → user | for outbound/agent messages | ### `support_ticket_notes` Internal-only events. Customers never see these. | Column | Type | Notes | | ---------- | ------------------------------- | --------- | | `id` | text PK | Pile UUID | | `event_id` | text FK → support_ticket_events | not null | | `body` | text | not null | ### `support_ticket_assignments` Plain supports one primary assignee + additional assignees. This table models all of them. | Column | Type | Notes | | ------------ | ------------------------- | ------------- | | `id` | text PK | Pile UUID | | `ticket_id` | text FK → support_tickets | not null | | `user_id` | text FK → user | not null | | `is_primary` | boolean | default false | Unique: `(ticket_id, user_id)`. Index: `(ticket_id, is_primary)`. ### `support_ticket_labels` | Column | Type | Notes | | ----------- | ------------------------- | -------------------------------------------- | | `id` | text PK | Pile UUID | | `ticket_id` | text FK → support_tickets | not null | | `label_id` | text FK → labels | not null; reuse existing Pile `labels` table | Unique: `(ticket_id, label_id)`. ## Code Style ```ts export const supportTicketSchema = z.object({ id: z.string(), organizationId: z.string(), customerId: z.string(), number: z.number().int(), externalId: z.string().optional(), externalSource: z .enum([ "intercom", "zendesk", "plain", "email", "slack", "msteams", "discord", "chat", "api", "manual", ]) .default("manual"), title: z.string(), status: z.enum(["todo", "done", "snoozed"]), priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"), sourceChannel: z.enum([ "email", "slack", "msteams", "discord", "chat", "capture", "api", "intercom", "zendesk", "plain", ]), issueId: z.string().optional(), lastCustomerMessageAt: z.string().datetime().optional(), lastAgentMessageAt: z.string().datetime().optional(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type SupportTicket = z.infer; ``` - snake_case in SQL, camelCase in TypeScript. - Native Drizzle columns; no `any`. - `status` starts with `todo`, `done`, `snoozed` — enough to map Intercom and Plain. ## API Surface ### `POST /workspaces/{organizationId}/support/tickets` Create a ticket. Body: `SupportTicketInput` (customer id, title, source channel, optional priority, optional external ids). The route creates the ticket, the first message (if provided), and the initial customer identity. ### `GET /workspaces/{organizationId}/support/tickets` Paginated list. Query: `limit`, `cursor`, `customerId`, `status`, `priority`, `assignedTo`, `q`. ### `GET /workspaces/{organizationId}/support/tickets/{ticketId}` Get a ticket with customer, companies, labels, primary assignee, and the most recent events. ### `GET /workspaces/{organizationId}/support/tickets/{ticketId}/events` Timeline: paginated list of events with messages/notes inlined. ### `PATCH /workspaces/{organizationId}/support/tickets/{ticketId}` Update title, status, priority, labels, assignees. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/messages` Add an outbound message or a note. Body includes `type` (`message` or `note`), `textContent`, optional `markdownContent`, optional `channel`. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/replies` Reply to the customer (Plain's `replyToThread`). Returns the created message. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/notes` Add an internal note (Plain's `createNote`). ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/done` Mark as `done`. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/todo` Mark as `todo`. ### `POST /workspaces/{organizationId}/support/tickets/{ticketId}/snooze` Mark as `snoozed` until a given `until` timestamp. ## Storage Choice `support-tickets` lives in **D1**. Tickets are queried by `organization_id`, `status`, `priority`, `assignedTo`, and `customer_id`. The workspace Durable Object is wrong here because tickets need cross-workspace indexing for the inbox and agent queries. ## Testing Strategy - Tests in `src/api/support-tickets.test.ts`. - Create a workspace + customer, create a ticket, add events, change status, assert timeline. - Test `support-migration` integration: import an Intercom conversation and verify it becomes a ticket with the right status mapping. - Contract tests after route changes. ## Migration Path `support-migration` will convert Intercom conversations and Plain threads into `support_tickets`: | Source | Target | | ------------------ | --------- | | Intercom `open` | `todo` | | Intercom `closed` | `done` | | Intercom `snoozed` | `snoozed` | | Plain `todo` | `todo` | | Plain `done` | `done` | Conversations become `support_ticket_events` of type `message`. The first message uses the conversation `source.body`. Replies become additional `message` events. ```ts createTicketFromIntercom(db, organizationId, customerId, conversation, { title, status, priority, }); ``` ## Boundaries ### Always - Validate inputs with Zod. - Run `vp check` and `pnpm test` before committing. - Keep D1 schema changes in Drizzle migrations. - Use the same status mapping for Intercom, Plain, and internal tickets. ### Ask First - Adding new `status` values beyond `todo`, `done`, `snoozed`. - Storing event payloads as JSON instead of child tables. - Moving `support_tickets` to the workspace Durable Object. ### Never - Commit secrets. - Store PII in logs or generated client docs. - Use `any`. ## Success Criteria - [ ] D1 tables `support_tickets`, `support_ticket_counters`, `support_ticket_events`, `support_ticket_messages`, `support_ticket_notes`, `support_ticket_assignments`, `support_ticket_labels` exist. - [ ] REST routes for create/list/get/update tickets and events return correct JSON. - [ ] Numbering is per-workspace and monotonic. - [ ] OpenAPI/MCP/CLI artifacts are regenerated. - [ ] `vp check` passes. - [ ] `pnpm test` passes with new tests. - [ ] Intercom conversation import maps to a `support_ticket` with the correct status and timeline. ## Decisions 1. **No `waiting` status for v1.** `todo` + `last_customer_message_at` is enough. Add a `waiting` status later if the inbox needs it. 2. **Dedicated `support_tickets` table.** Pile `issues` and support tickets have different lifecycles. `support_tickets` has an optional `issue_id` for the clean link when a ticket is promoted to engineering work. 3. **Vercel AI SDK is a primitive, not an autonomous agent.** It powers the in-app chat widget and agent-generated suggestions. Final outbound messages are sent through the API by a user or an external agent the customer builds. Pile provides the primitives; it does not run the support agent. 4. **`support_ticket_counters` stays separate for v1.** A generic `workspace_counters` table is cleaner but would require touching existing issue numbering. Separate is safer until support is stable. # ===== docs/agent-providers.md ===== # Agent Providers The tracker is provider-agnostic at the core: every agent is a registered provider with a `dispatch`/`poll` contract, and each workspace configures its own providers. Credentials are per-workspace, stored in that workspace's own Durable Object, write-only via the API, and never shared between workspaces. ## Adding an agent No wizard. The API is the form: 1. `GET /workspaces/{org}/agent/providers/catalog` — which agents exist, and for each: **hosted cloud** vs **your computer or server**, plus the fields that mode needs. 2. `PUT /workspaces/{org}/agent/providers/{agentId}` with `mode` and those fields. 3. `POST /workspaces/{org}/agent/providers/{agentId}/health` — the key works. ```bash # Codex on OpenAI's cloud curl -X PUT "$BASE/workspaces/$ORG/agent/providers/codex" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"mode":"hosted","token":""}' # Codex on your machine (self-hosted executor) curl -X PUT "$BASE/workspaces/$ORG/agent/providers/codex" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"mode":"byo","token":""}' ``` `mode` is stored on `config.mode`. Omitting it keeps the old bag-of-fields upsert (no required-field check). Sending `mode` validates required catalog fields and stamps provider-specific discriminants (Codex `environment.type`, etc.). ## Devin Cloud (hosted) The default path. A workspace supplies its own Devin API credentials; sessions run on Devin's hosted infrastructure. No outpost, no self-hosted workers. ```bash curl -X PUT "$BASE/workspaces/$ORG/agent/providers/devin" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "token": "", "providerOrgId": "org-…" }' ``` Then `POST /workspaces/$ORG/issues/{id}/dispatch` with `{"agentId":"devin"}` creates a standard hosted Devin session on Devin's infrastructure. To run Devin on your own compute, use `devin-cli` instead — it runs the vendor's CLI headlessly inside your Daytona sandbox and bills to your own Devin account, bypassing the organization sessions API entirely. ## Compute (optional, per-workspace) The headless CLI providers (`codex-cli`, `devin-cli`, `cursor-cli`, `claude-cli`) provision a dedicated sandbox per session on your own compute provider (Daytona is the reference integration). The `compute*` fields override the deployment-level env vars: | field | maps to | | ----------------- | -------------------------------------------------------- | | `computeApiKey` | `DAYTONA_API_KEY` | | `computeApiUrl` | `DAYTONA_API_URL` (default `https://app.daytona.io/api`) | | `computeSnapshot` | `DAYTONA_SNAPSHOT` | | `computeVolumeId` | `DAYTONA_VOLUME_ID` | Two compute backends exist behind the same runner/result contract (`src/agents/compute.ts`), selected by `COMPUTE_PROVIDER`: - `daytona` (default) — Daytona sandboxes via the `DAYTONA_*` env vars above. - `cloudflare` — Cloudflare Sandbox (Workers Containers) via the Worker's own `SANDBOX` binding and `Dockerfile.sandbox` image. No external API key or snapshot registry; env vars are injected per-process and the sandbox sleeps after `sleepAfter` (4h) if polling stops. `destroy()` runs on terminal results, same as Daytona. Per-provider images (`SANDBOX_CURSOR`/`Dockerfile.sandbox-cursor`, and the Devin/Codex equivalents) bake each CLI at build time so dispatch skips per-run install; verified end-to-end for cursor-cli on `CursorSandbox` (VTX-265). **Headless browser (optional).** Build the devin/cursor images with `--build-arg LANE_BROWSER=1` (or `image_vars = { LANE_BROWSER = "1" }` on the `[[containers]]` entry) to bake Google Chrome and [`agent-browser`](https://github.com/vercel-labs/agent-browser). The runner detects the binary, exports `PILE_BROWSER`, `CHROME_PATH`, `PUPPETEER_EXECUTABLE_PATH`, `AGENT_BROWSER_EXECUTABLE_PATH` and `AGENT_BROWSER_ARGS` (`--no-sandbox,--disable-dev-shm-usage`) to the agent, and appends a "Headless browser" section to the prompt so UI-touching lanes run e2e suites and screenshot their change instead of shipping blind. Off by default to stay under the ~2GB image limit; images without it are unchanged. A workspace can also select the backend via `config.computeProvider` (`"daytona"` | `"cloudflare"`) in the provider upsert — it overrides the deployment-level `COMPUTE_PROVIDER`. If unset, the deployment-level `DAYTONA_*` env vars apply, so a self-hosted deployment can set one compute provider for all workspaces; on a hosted deployment each workspace brings its own. Sandboxes are deleted when the session reaches a terminal state, with an `autoStopInterval` safety ceiling on Daytona (`sleepAfter` on Cloudflare). Daytona compute path verified live 2026-09-23 (ISS-92) against snapshot vortex-cli-runner-v2 and sandbox label scheme vortex.session. ## Claude Code (`claude-cli`) Runs Claude Code headless (`claude -p --output-format stream-json`) in a sandbox, same runner/result contract as the other CLI providers. Set `token` (or `CLAUDE_CODE_OAUTH_TOKEN`) to the output of `claude setup-token` so the lane rides a Claude Pro/Max subscription instead of per-token API billing; an `sk-ant-api` key also works and is passed as `ANTHROPIC_API_KEY`. `config.model` / `CLAUDE_CLI_MODEL` overrides the default `sonnet`. With `COMPUTE_PROVIDER=cloudflare` it uses the shared `SANDBOX` binding and installs the CLI per run. ## Subscription credential pool (PILE-285) `claude-cli` and `codex-cli` accept an ordered credential pool so cheap review/triage lanes can ride subscriptions with fallback. Set it per workspace as `config.credentialPool` in the provider upsert (encrypted at rest with the rest of `config`; responses return entries without `secret`), or deployment-wide as `AGENT_CREDENTIAL_POOL` (JSON array): ```json [ { "kind": "claudeSubscription", "secret": "sk-ant-oat01-…", "label": "max-1", "purposes": ["review", "preflight"] }, { "kind": "claudeSubscription", "secret": "<~/.claude/.credentials.json>", "label": "max-2" }, { "kind": "anthropicApiKey", "secret": "sk-ant-api03-…", "label": "metered" } ] ``` | kind | secret | consumed by | | -------------------- | ----------------------------------------------------------- | -------------------------------- | | `claudeSubscription` | `claude setup-token` token or `~/.claude/.credentials.json` | `claude-cli` | | `anthropicApiKey` | `sk-ant-api…` key (per-token billing) | `claude-cli` | | `codexOAuth` | ChatGPT-login `~/.codex/auth.json` (raw or base64) | `codex-cli` | | `geminiOAuth` | `~/.gemini/oauth_creds.json` | probed only — no Gemini lane yet | On dispatch the provider walks its kinds in pool order and takes the first entry that (a) lists the lane's `purpose` in `purposes` (entries without `purposes` serve every lane) and (b) passes the offline subscription probe: - `ok` — token present and not expiring within 60s (or no expiry recorded). - `refreshable` — access token expired but a refresh token is stored; the CLI refreshes it on first call (Codex: `exp` decoded from the `tokens.access_token` JWT; Claude/Gemini: `expiresAt` / `expiry_date`). - `expired` / `invalid` — skipped. The choice (label, kind, probe state, skipped entries) is logged as a `status` lane event. If no entry qualifies, the provider's single configured credential (`token`) is the last fallback; otherwise dispatch fails with every candidate's reason. Provider health reports each entry's probe state and expiry. Refreshed tokens live only in the sandbox and are not written back to the pool. ### Lane isolation Agent CLIs, `.pile/setup.sh`, and CLI installers run with `scrubbed_env()` — the allowlisted agent env described under [Lane credential posture](#lane-credential-posture), never the runner's own environment. `LANE_RESTRICTED=1` (deployment env) additionally runs lanes in **restricted mode**: - PATH shims for `git`, `curl`, `wget`, `gh`, `ssh`, `scp`, `rsync`, `nc`, … refuse `git push`/`send-pack`/`credential`, remote mutation (`git remote add|set-url|…`), credential/remote/url/alias config (`git config`, `-c`, `GIT_CONFIG_*`), network-only tools outright, and `curl`/`wget` to hosts off the allowlist (GitHub, npm, PyPI, localhost, the Pile API host, plus `LANE_NET_ALLOWLIST` — comma-separated). Blocked commands exit `126` with `pile restricted lane: … blocked: `. - The GitHub token is kept out of `.git/config` while the agent runs; the runner sets it only around its own fetch/push. Shims are a guardrail on the agent's PATH, not a kernel sandbox — the hard boundary is that no credential is reachable from the agent's env or repo config. ## Cursor Cloud Agents The `cursor` provider targets Cursor's Cloud Agents v1 API (`POST https://api.cursor.com/v1/agents`). `token` is a Cursor API key (user or enterprise service account). ```bash curl -X PUT "$BASE/workspaces/$ORG/agent/providers/cursor" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "token": "", "config": { "repoUrl": "https://github.com/org/repo", "startingRef": "main", "autoCreatePR": true } }' ``` Dispatch → durable agent + run (`providerSessionId` is `bc-…/run-…`); poll maps `CREATING/RUNNING/FINISHED/ERROR/CANCELLED/EXPIRED` onto tracker statuses and lifts `git.branches[].prUrl` into `url`. `config.repoUrl` is the default repo; an issue's `repo` field overrides it. Model per dispatch via `model`. **Self-hosted (BYOM).** Cursor's equivalent of a Devin outpost — workers you run that execute tool calls while the agent loop stays in Cursor's cloud: - `config.env: {"type":"machine","name":""}` — a _My Machines_ worker (`agent worker --name --api-key start`), bound to the repos in its `--worker-dir` checkouts. Personal/user API key. - `config.env: {"type":"pool","name":""}` — a _Team Pool_ worker (`agent worker --pool start`). Requires a Cursor Enterprise **service account** key — personal keys can't start pool workers. The adapter already sends `env: { type: "pool" }`. That is not ISS-64. ISS-64 is the missing **controller**: a Daytona snapshot that runs `agent worker --pool`, a service-account key, and provision/reap of that sandbox the way Outpost does for Devin. Until that snapshot exists, Cursor BYOM still cannot clone a private repo from a Pile dispatch. Do not add more adapter code for this. Workers need outbound HTTPS only. There is no `metadata` field on v1 agents — tracker context rides inside the prompt. v1 has no webhooks yet; status is polled (same as Devin). The legacy v0 API does support HMAC-signed `statusChange` webhooks if push is ever required. Pile's inbound webhook route will accept them if Cursor adds v1 push. ## cf-agent (Cloudflare Agents SDK workers) The `cf-agent` provider targets any worker exposing the Agents SDK router shape (`GET {agentsPath}/{agent}/{conversation}` → `messages` + `settlements`) plus a dispatch route. The flue worker is the reference implementation; `flue` is a registered alias of the same provider. ```bash curl -X PUT "$BASE/workspaces/$ORG/agent/providers/cf-agent" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "token": "", "config": { "endpoint": "https://my-agent.workers.dev", "agent": "engineering", "dispatchPath": "/dispatch/pile", "agentsPath": "/agents" } }' ``` `config.endpoint: "service-binding"` routes over the deployment's `FLUE_WORKER` binding instead of the public URL — required for same-account `workers.dev` targets, which Cloudflare blocks over the edge (error 1042). Write-back is push-style: the worker PATCHes the session and POSTs activities; `poll` is the recovery path (maps conversation `settlements` onto `completed/failed/canceled`). ## Watching a session `GET /workspaces/{org}/agent/sessions` returns the full session rows — including `result` text — which gets heavy on poll loops. Pass `?summary=1` (also on `GET …/agent/sessions/{id}`) for lane-row scalars only: `id`, `issueId`, `agentId`, `provider`, `status`, `prUrl`, `prState`, the timestamps, and the derived `stalled` badge. `pile fleet` and `pile agent sessions watch` already use it; dashboards and other polling consumers should too. Push consumers can subscribe to `/realtime` or the session event stream instead of polling. `GET /workspaces/{org}/agent/sessions/{id}/stream` streams the activity log as a Vercel AI SDK **UI-message-stream** (`x-vercel-ai-ui-message-stream: v1`) SSE feed: `thought`→`reasoning-*`, `response`→`text-*`, `error`→`error`, other types→`data-{type}` parts — consumable by any `useChat`-compatible client. The stream **live-tails**: after replaying history it emits new activities as they land (2s DO poll), closes with `data-session-status` + `finish` when the session reaches a terminal status or a ~90s budget expires — reconnect for a continued tail. `POST /workspaces/{org}/agent/sessions/{id}/cancel` cancels a session: provider-side termination when the provider supports it (Cursor `runs/{id}/ cancel`, Devin `DELETE /sessions/{id}`), then the local session is marked `canceled` either way. `POST /workspaces/{org}/agent/sessions/{id}/poll` writes provider status back and, when a new `prUrl` appears, records it as a session **artifact** (same path as `POST …/artifacts`). ### Issue progress comment (PILE-290) Every non-preflight lane keeps **one** comment on its issue (`externalSource: "agent"`, `externalId: "{sessionId}:progress"`) that is edited in place rather than re-posted — observability for anyone not running `pile fleet`. It is posted when the lane first reports `running` and shows: - the session link (provider URL, else the Pile session route) and the `pile agent sessions watch` command; - the current step — the newest `action`/`thought`/`response`/`error`/ `elicitation` activity; - elapsed time plus an ETA from the median duration of the agent's last 20 completed lanes in the workspace (flagged once overdue); - branch / PR and the latest task list, when reported. Activity edits refresh it immediately except `thought`/`response`, which are throttled to one edit per 30s; no-op polls refresh elapsed/ETA at most every 5 minutes. On a terminal status it freezes with the final duration; the separate completion/failure result comment still posts. External lanes feed it through `POST …/agent/sessions/{id}/report` with two optional fields alongside `status`/`result`/`prUrl`/`branch`: ```json { "step": "Running tests", "todos": [ { "content": "Read issue", "status": "completed" }, { "content": "Run tests", "status": "in_progress" } ] } ``` `todos` (≤50 items, `pending | in_progress | completed`) replaces the displayed list wholesale; invalid lists return 400. ## Lane events Every lane (agent session) has an append-only event stream in the workspace DO. `GET /workspaces/{org}/agent/sessions/{id}/stream` serves it as raw SSE (`id:` = event id, `event:` = type, `data:` = `{id,type,message,payload,createdAt}`). With no `Last-Event-ID` it tails from the newest event; `Last-Event-ID: 0` replays the whole run. The stream closes once the session is terminal. Session-event writers: `applyAgentSessionResult` / `addAgentActivity` / `createAgentSession` in `src/workspace/durable-object.ts`, PR sync + nudges in `src/agents/sweep.ts`. Lifecycle, in order: `created` (a queued lane starts `waiting` and is promoted to `created`) → `running` ⇄ `waiting` → `completed` | `failed` | `canceled`. Each field change emits `session.{field}`; the first transition into a terminal status emits `session.terminal` then `session.summary`, and a `child.terminal` on the parent lane if there is one. PR events keep landing after terminal, because `syncOpenPrSessions` polls GitHub for every session with an open PR. | type | fires when | payload | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | `activity` | Any activity is appended (`thought`, `response`, `error`, `elicitation`, …). | `{activity}` (the full activity row) | | `session.status` | Session status changes. | `{field, old, new}` | | `session.result` | Result text changes. | `{field, old, new}` | | `session.prUrl` | PR URL is first set or changes. | `{field, old, new}` | | `session.prState` | Session PR state changes (runner report or PR sync). | `{field, old, new}` | | `session.branch` | Branch changes. | `{field, old, new}` | | `session.terminal` | First transition from non-terminal to `completed`/`failed`/`canceled`. | `{status}` | | `session.summary` | Same transition, right after `session.terminal`. One digest per run (see below). | `{status, durationMs, prUrl, branch, agentId, digest?}` | | `session.needs_input` | An `elicitation` activity lands, meaning the lane is asking a human. Also notifies the human assignee (or the dispatcher) with `lane_needs_input`. | `{issueId}` | | `pr.ci_failed` | PR sync sees check runs go to `failing` (edge-triggered against the issue's previous `prCheckState`). Also nudges the lane. | `{prUrl, headSha, checkState, failingChecks[]}` | | `pr.conflict` | PR is open and GitHub reports `mergeable: false`. Deduped per `headSha`. Conflicts confined to the repo's `.pile/config.json` `conflict.generated` paths are fixed by a scripted sandbox run; anything else nudges the lane. | `{prUrl, headSha}` | | `pr.conflict_fix` | Deterministic conflict resolution (PILE-251): the scripted fixer's lifecycle — `started` (sandbox launched, merge+regen in flight) then `resolved`/`source_conflict`/`failed`. Only `source_conflict`/`failed` fall through to the lane. | `{prUrl, headSha, state, files?}` | | `pr.conflict_lane` | The conflict needs an agent — source files are in the conflict set, the repo declares no `conflict.generated`, or the fixer failed. Fires `pr.conflict` event automations once per `headSha`; the nudge itself dedupes on delivery. | `{prUrl, headSha}` | | `pr.branch_update` | PR sync saw a managed lane PR (`issue-*` or the issue's linked branch) `behind` the base with no conflicts and fired GitHub update-branch. Deduped per `headSha`. | `{prUrl, headSha}` | | `pr.review_requested` | PR is open and has requested reviewers. Deduped per `headSha`. | `{prUrl, headSha, reviewers}` (count) | | `pr.review` | A submitted review is first seen (webhook or PR sync), deduped per review id. Reviews with feedback nudge the lane with the review body and fire `pr.review` event automations; non-approve verdicts (changes requested, or a commented review with a body) also fire `pr.review_changes` (PILE-274). | `{prUrl, headSha, reviewId, state, reviewer}` | | `pr.review_threads` | The lane pushed a non-merge commit after review feedback was delivered to it: the PR review threads it was sent are resolved via GitHub GraphQL. Checked once per `headSha` (sweep, plus a `pull_request.synchronize` fast path). | `{prUrl, headSha, resolved[]}` (thread ids) | | `review.requested` | PR review lane (PILE-273) dispatched for a PR head. Written on the repo-less `purpose: "review"` session; the `pile-review` check run is created `in_progress` first. One per `headSha`. | `{prUrl, repo, pullNumber, headSha, checkRunId}` | | `review.published` | The sweep published a terminal review session: `pile-review` completed (approve→success, comment→neutral, request-changes→failure; failed lane→neutral), the verdict posted as a `pull_request_review` (plain comment for approve or when GitHub rejects the review), and actionable verdicts nudged the work lane as a `prompt.followup`. | `{prUrl, headSha, verdict, conclusion, commentUrl, reviewId, checkRunId}` | | `pr.merged` / `pr.closed` / `pr.draft` | PR sync sees the PR state change to a non-`open` value. | `{prUrl, prState, headSha}` | | `prompt.followup` | A follow-up prompt was delivered to the live lane: `POST …/prompt`, an issue comment, a PR review, or a CI/conflict nudge. | `{prompt}` (route) · `{commentId}` · `{issueId}` · `{issueId, prUrl}` | | `prompt.followup_failed` | The provider rejected a PR-review or CI/conflict follow-up. | `{issueId}` or `{issueId, prUrl}` | | `prompt.followup_skipped` | A follow-up was throttled inside the provider's throttle window, or had nowhere to land — lane not resumable (`failed`/`canceled`) or the provider has no follow-up channel. | `{commentId}` · `{issueId}` · `{issueId, prUrl}` | | `issue.escalated` | Nudge budget exhausted: the lane already took 3 PR nudges (delivered or redispatched) on the current `headSha`, or 5 across its redispatch chain. Fires once per lane; posts an issue comment (lane, what failed, what was tried) and moves the issue to `triage`. No further nudges until a fresh retry. | `{issueId, prUrl, headSha?, reason, rounds, key}` | | `child.terminal` | A child lane (`parentSessionId` set) reaches terminal. Written on the **parent's** stream. | `{childSessionId, childIssueId, status, prUrl, branch, result}` (≤2000 chr) | | `agent_session.created` | `createAgentSession` ran for an issue. This is a realtime/webhook event (`emit`), **not** a session-stream row. Siblings: `agent_session.updated/completed/failed/canceled`. | `{session, issue}` | | type | fires when | payload | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `activity` | Any activity is appended (`thought`, `response`, `error`, `elicitation`, …). | `{activity}` (the full activity row) | | `session.status` | Session status changes. | `{field, old, new}` | | `session.result` | Result text changes. | `{field, old, new}` | | `session.prUrl` | PR URL is first set or changes. | `{field, old, new}` | | `session.prState` | Session PR state changes (runner report or PR sync). | `{field, old, new}` | | `session.branch` | Branch changes. | `{field, old, new}` | | `session.terminal` | First transition from non-terminal to `completed`/`failed`/`canceled`. | `{status}` | | `session.summary` | Same transition, right after `session.terminal`. One digest per run (see below). | `{status, durationMs, prUrl, branch, agentId, digest?}` | | `session.needs_input` | An `elicitation` activity lands, meaning the lane is asking a human. Also notifies the human assignee (or the dispatcher) with `lane_needs_input`. | `{issueId}` | | `pr.ci_failed` | PR sync sees check runs go to `failing` (edge-triggered against the issue's previous `prCheckState`). Also nudges the lane. | `{prUrl, headSha, checkState, failingChecks[]}` | | `pr.conflict` | PR is open and GitHub reports `mergeable: false`. Deduped per `headSha`. Also nudges the lane. | `{prUrl, headSha}` | | `pr.review_requested` | PR is open and has requested reviewers. Deduped per `headSha`. | `{prUrl, headSha, reviewers}` (count) | | `pr.merged` / `pr.closed` / `pr.draft` | PR sync sees the PR state change to a non-`open` value. | `{prUrl, prState, headSha}` | | `prompt.followup` | A follow-up prompt was delivered to the live lane: `POST …/prompt`, an issue comment, a PR review, or a CI/conflict nudge. | `{prompt}` (route) · `{commentId}` · `{issueId}` · `{issueId, prUrl}` | | `prompt.followup_failed` | The provider rejected a PR-review or CI/conflict follow-up. | `{issueId}` or `{issueId, prUrl}` | | `prompt.followup_skipped` | A follow-up was throttled inside the provider's throttle window, or had nowhere to land — lane not resumable (`failed`/`canceled`) or the provider has no follow-up channel. | `{commentId}` · `{issueId}` · `{issueId, prUrl}` | | `child.terminal` | A child lane (`parentSessionId` set) reaches terminal. Written on the **parent's** stream. | `{childSessionId, childIssueId, status, prUrl, branch, result}` (≤2000 chr) | | `agent_session.created` | `createAgentSession` ran for an issue. This is a realtime/webhook event (`emit`), **not** a session-stream row. Siblings: `agent_session.updated/completed/failed/canceled`. | `{session, issue}` | Other rows that also land on the stream: `session.url` / `session.providerSessionId` (same `{field, old, new}` shape), `issue.prUrl` / `issue.prState` / `issue.branch` / `issue.status` (issue writeback), `lane.queued` / `lane.dedupe` (pre-dispatch dedupe), `lane.tool` (one per lane MCP tool call, `{tool, permission, ok}`), and `log` (runner log lines, message only). `session.summary`: the `digest` key appears only when the provider's `result` is JSON with a `digest` object, e.g. `{durationSec, filesChanged, commits}`. Plain-text results just omit it. `durationMs` is measured from session `createdAt`. The event fires exactly once per run, on the first terminal transition. Later terminal→terminal updates (a late poll, a webhook replay, cancel after completion) don't emit it again. A follow-up prompt that moves the session back to `running` starts a new run, and that run's own terminal transition emits a new summary. ## Lane tools (MCP) Lanes get a purpose-built MCP server instead of raw `gh`/`git` shell access for GitHub operations, so permissions are enforced per tool rather than "has a shell or not": ``` POST /workspaces/{org}/agent/sessions/{id}/mcp Authorization: Bearer ``` Same per-session HMAC token as `PILE_LOG_URL`/`PILE_TOKEN_URL`. Sandbox runners get the URL as `PILE_LANE_MCP_URL` (with `LANE_TOKEN`), and `POST …/agent/sessions/register` returns it as `mcpUrl`. The endpoint is stateless streamable HTTP (JSON responses); the GitHub installation token is minted server-side, scoped to the session issue's repo, and never returned. Tools live in `src/agents/lane-tools.ts`. Each declares one permission: | permission | tools | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lane:report` | `get_lane_context`, `report_progress`, `set_output` | | `repo:read` | `get_repository`, `get_file_contents`, `list_pull_requests`, `get_pull_request`, `get_pull_request_diff`, `list_pull_request_files`, `list_pull_request_reviews`, `list_review_comments`, `list_review_threads`, `list_check_runs`, `get_check_run_logs`, `get_issue`, `list_issue_comments`, `find_similar_issues`, `compare_commits` | | `pr:write` | `create_pull_request`, `update_pull_request`, `comment_on_pull_request`, `add_labels`, `update_pull_request_branch` — only on PRs headed at the lane's own branch | | `review:write` | `create_pull_request_review` (no self-approval), `reply_to_review_comment`, `resolve_review_thread` (lane's PR only) | | `checks:write` | `rerun_failed_jobs` | Tiers bundle permissions: `readonly` (`repo:read`, `lane:report`), `review` (+ `review:write`), `contribute` (+ `pr:write`; the default), `maintain` (+ `checks:write`). Set per repo in organization metadata, keyed by `owner/name` with a `*` fallback; `deny` removes individual tools: ```json { "laneTools": { "*": "readonly", "VortexNYC/pile": { "tier": "maintain", "deny": ["update_pull_request"] } } } ``` Only permitted tools are listed by `tools/list`; the call handler re-checks the policy, and every call lands on the lane stream as a `lane.tool` event. ## Timeouts A cron sweep cancels running sessions that exceed the workspace provider `config.timeout` (minutes, default **60**) or go silent for `config.inactivityTimeout` (minutes, default **20**). Silence is not “no Pile activity”: the sweep probes `getState` (hash / compute `lastSeen`) or `poll` before killing, so a Devin session that never streams still lives while the provider payload changes. A probe error does **not** cancel; max-runtime is the hard cap. ```json { "timeout": 60, "inactivityTimeout": 20 } ``` ## Health `POST /workspaces/{org}/agent/providers/{agentId}/health` probes credentials and config without starting a session. A bad token should fail here, not eight hours into a run. ## Webhooks Providers can push instead of waiting for poll: - Authenticated: `POST /workspaces/{org}/agent/providers/{agentId}/hooks` (workspace API key, `agent:write`). - Inbound: `POST /webhooks/agent/{org}/{agentId}` with `X-Pile-Webhook-Secret` (or `Authorization: Bearer`) matching `config.webhookSecret`. The secret is write-only; reads show `hasWebhookSecret`. Payload must include `session_id` / `sessionId` / `id` (provider-side id). `status`, `result`, `pr_url` are applied onto the matching session and land on the timeline. Each provider's `parseWebhook` maps its native shape first, falling back to the generic parser: - **Devin** — native statuses (`exit` → `completed`, etc.) via `session_id`. - **Cursor** — `{agent: {id}, run: {id, status}}` or flat `agentId`/`runId`; rebuilds the composite `/` session id and maps `RUNNING`/`FINISHED`/`ERROR`/etc. Cursor Cloud Agents v1 still has no documented webhooks — poll remains the recovery path. - **Codex** — `id`/`session_id` + OpenAI statuses (`in_progress`, `requires_action`, `failed`; `idle` → `completed`). - **Flue / cf-agent** — `conversationId`/`conversation_id`/`sessionId` plus a settlement `outcome` (`completed`/`aborted`/`failed`) or tracker status. - **codex-cli** — webhook payloads carry the tracker `sessionId`; the generic parser covers it. - **devin-cli** — runs `devin -p` headlessly inside a Daytona sandbox using the workspace's `credentials.toml`; poll-only, no webhooks, and does not use the Devin organization sessions API. Verified live via Daytona sandbox dispatch (ISS-80). Dedicated `DevinSandbox` per-provider image verified live end-to-end (ISS-90, 2026-09-23). - **cursor-cli** — runs `cursor-agent -p --force --trust` headlessly inside a Daytona sandbox using the workspace's Cursor API key; poll-only, does not use the Cursor cloud agents API. Verified live via Daytona sandbox dispatch (ISS-81). ## Agent environment (ISS-31) Workspace-scoped files the agent can read without cloning the repo. This is storage + fetch, **not** prompt injection (ISS-43 was canceled). Allowed paths: `AGENTS.md`, `skills/.md`, `rules/.md`. | route | perm | notes | | ------------------------------------------------------- | ------------ | ------------------- | | `GET /workspaces/{org}/agent/environment` | `agent:read` | list | | `GET /workspaces/{org}/agent/environment/file?path=` | `agent:read` | one file | | `PUT /workspaces/{org}/agent/environment` | `admin` | `{ path, content }` | | `DELETE /workspaces/{org}/agent/environment/file?path=` | `admin` | | ## Codex (OpenAI Agents API) The `codex` provider targets the OpenAI Agents API (`https://api.openai.com/v1/agents/sessions`). The workspace token is the OpenAI API key. ```bash curl -X PUT "$BASE/workspaces/$ORG/agent/providers/codex" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "token": "", "config": { "environment": { "type": "openai_hosted" } } }' ``` Dispatch creates a managed-harness session. Poll maps `in_progress` → `running`, `requires_action` → `waiting`, `failed` → `failed`, and `idle` + assistant output → `completed`. If the final assistant message contains a GitHub PR URL, it is lifted into `prUrl`. `config.environment` can be set to `{"type":"self_hosted", ...}` for repositories that require a private checkout; the workspace must then start and connect an OpenAI executor to `session.environment.remote_url`. ## API | route | perm | notes | | --------------------------------------------------------- | ------------- | --------------------------------------- | | `GET /workspaces/{org}/agent/providers/catalog` | `agent:read` | agents + hosted/BYO fields | | `GET /workspaces/{org}/agent/providers` | `agent:read` | secrets redacted (`hasToken` etc.) | | `PUT /workspaces/{org}/agent/providers/{agentId}` | `admin` | upsert; `mode` validates catalog fields | | `DELETE /workspaces/{org}/agent/providers/{agentId}` | `admin` | revert to deployment defaults | | `POST /workspaces/{org}/agent/providers/{agentId}/health` | `admin` | credential/config probe, no session | | `POST /workspaces/{org}/agent/providers/{agentId}/hooks` | `agent:write` | authenticated push | | `POST /webhooks/agent/{org}/{agentId}` | secret | inbound push (`config.webhookSecret`) | ## Custom agents Any agent can integrate without a provider at all: the workspace API (issues, comments, `agent/sessions`, `agent/sessions/{id}/activities`, webhooks, MCP) is the full surface. Registering a new provider is for agents that want dispatch + poll through the tracker's provider interface — see `src/agents/provider.ts`. Stream-json live events verified (ISS-99). Live transcript streaming verified (ISS-98). ## Repo environment contract — `.pile/config.json` A repo can commit `.pile/config.json` at its root to declare the environment lanes run in. The file is read at dispatch time through the GitHub contents API (resolved against the issue's branch when set), and every field is optional: ```json { "agents": ["devin", "devin-cli"], "model": "swe-2", "setup": ".pile/setup.sh", "env": ["DATABASE_URL", "NPM_TOKEN"], "hooks": { "setup": "pnpm install --frozen-lockfile", "stop": "pnpm run check" } } ``` | field | effect | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `agents` | Allowlist — dispatch with any other agentId is rejected (400). | | `model` | Default model when the dispatch request doesn't name one. | | `setup` | Documented setup hook. `.pile/setup.sh` runs after clone either way. | | `env` | Env-var allowlist — caller-supplied `extraEnv` keys not named here are dropped before they reach the lane. Infra env (lane DB etc.) is exempt. | | `review` | Opt-in PR review lane (`{agent?, model?}`, read from the PR's base ref). See below. | ### PR review lane (PILE-273) With `review` set, every `pull_request` `opened`/`synchronize`/`reopened`/ `ready_for_review` on a branch Pile tracks (webhook fast path, PR sync as backstop) dispatches one repo-less review session per `headSha` — `purpose: "review"`, agent `review.agent` (default `devin-cli`), model `review.model` (else the provider default) — with the PR diff inlined. It runs alongside the working lane and never counts as the issue's active lane. - A `pile-review` check run is created `in_progress` with the installation token, then completed when the session ends: approve → `success`, comment → `neutral`, request-changes → `failure` (so a required `pile-review` gates the merge queue). A lane that fails or is canceled completes it `neutral`. - Actionable verdicts (request-changes, comment) land as a real `pull_request_review` — `REQUEST_CHANGES`/`COMMENT` — so the webhook and sweep detection treat it like any reviewer: `pr.review` fires and the author lane gets the verdict as a `prompt.followup` (`review-` dedupe is shared between both paths). A verdict the app can't post on its own PR degrades to a `COMMENT` review, then to a plain comment. Approvals stay a plain PR comment — nothing for the lane to act on. - The verdict body uses the callout ladder: `[!CAUTION]` will break → `[!IMPORTANT]` must address → ℹ️ minor → ✅ clean. - Diffs touching only `conflict.generated` paths get a `skipped` check run and no lane; mixed diffs exclude the generated files from the inlined diff. - `pile-review` is excluded from Pile's CI state, so a request-changes verdict never fires the CI-failure nudge. ### `.pile/config.json` fields | field | effect | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `agents` | Allowlist — dispatch with any other agentId is rejected (400). | | `model` | Default model when the dispatch request doesn't name one. | | `setup` | Documented setup hook. `.pile/setup.sh` runs after clone either way. | | `env` | Env-var allowlist — caller-supplied `extraEnv` keys not named here are dropped before they reach the lane. Infra env (lane DB etc.) is exempt. | | `triggers` | Event→lane triggers — see below. | | `hooks` | Lane lifecycle hooks — see below. | ### Event→lane triggers `triggers` maps repo events to lane dispatches — one primitive for review, triage, plan, mention, and future event-driven lanes: ```json { "triggers": [ { "on": "pr.opened", "agent": "devin-cli", "prompt": "Review this PR." }, { "on": "issue.created", "agent": "devin", "prompt": "Triage this issue." }, { "on": "label.added", "label": "needs-plan", "agent": "devin", "model": "swe-2", "prompt": "Write an implementation plan." }, { "on": "mention", "agent": "devin-cli", "prompt": "Answer the mention." } ] } ``` | `on` | fires when | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `issue.created` | A GitHub issue is opened in the repo (and mirrored into Pile). | | `pr.opened` | A pull request is opened. | | `pr.synchronize` | New commits are pushed to a pull request. | | `ci.failed` | The sweep sees a lane PR's checks go red (`pr.ci_failed`). | | `mention` | A comment on a mirrored issue or PR contains `handle` (default `@pile`). Only repo owners/members/collaborators and linked Pile users fire it; bots never do. Runs alongside the built-in `@pile` lane routing. | | `label.added` | A label is added to a mirrored issue or a PR — only `label` when set, any label otherwise. | | `pr.review` | A review with a body (or a changes-requested verdict) lands on a lane PR. | | `pr.changes_requested` | A review on a lane PR requests changes (`pr.review_changes`). | | `pr.conflict` | The sweep sees a lane PR conflict with its base on non-generated files — once per conflicting head sha. | Each matching trigger dispatches `agent` (subject to the `agents` allowlist) with `prompt` plus the event details as lane instructions; `model` falls back to the top-level `model`, and the `env` allowlist applies. PR events land on the issue that owns the PR branch; other PRs get a per-PR issue (`repo:github:::pr:`), created only when a trigger matches. Triggers are read from the repo's default branch, never the PR head, and are processed by the same `fireEventAutomations` path as workspace event automations — which accept these event names as `triggerValue` too. Dispatch keeps the one-active-lane-per-issue guard, so an event on an issue whose lane is still running is skipped (logged). A lane a trigger dispatched never fires repo triggers from its own PR's `ci.failed`, `pr.review`, `pr.changes_requested` or `pr.conflict` — that would be a self-feed loop; the lane gets the fix prompt as a nudge instead. Workspace event automations still run. ### Lane lifecycle hooks `hooks` holds bash commands the lane runner (`cursor-cli`, `devin-cli`) reads from the lane's own checkout and runs from the repo root. Every hook gets `PILE_HOOK`, `PILE_BRANCH`, `PILE_BASE_SHA` and `PILE_CHANGED_FILES` (path to a newline-separated list of files changed vs the lane's base — use it to scope checks to touched packages) in its env; output streams into the lane transcript and each run lands in the session digest under `hooks`. | hook | when | nonzero exit | | ----------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `setup` | after clone, after `.pile/setup.sh`, before the agent | logged, lane continues | | `postCheckout` | after every checkout — fresh clone and kept-sandbox resume | logged, lane continues | | `prePush` | before each push | push blocked, lane fails with the hook output | | `stop` | after each agent turn, before commit/push | agent resumes with the failure output and the hook re-runs, up to `stopMaxAttempts` times (default 2, max 5) | | `stopMaxAttempts` | — | cap on stop-hook self-heal resumes | The `stop` hook makes lanes self-verifying: a lane that would have pushed without testing gets its own red check back as a prompt and fixes it before any PR exists. If the hook is still red after the last attempt the lane pushes anyway, `digest.stopHook` records `{status: "failed", attempts, exit}`, and the PR body flags it. A malformed `hooks` block is ignored without affecting the rest of the file. ## Lane credential posture Sandbox lanes are treated as compromised by default: - **Per-session GitHub token.** Each lane gets an installation token restricted to the issue's repository. Every mint (dispatch, follow-up, `POST …/sessions/{id}/github-token` refresh) is registered against the session and revokes the token it replaces, so a lane holds at most one live token. - **Bound to session lifetime.** The runner revokes its token (`DELETE /installation/token`) and strips it from the git remote at run end; the sweep revokes any token still registered once the session is terminal, including kept follow-up sandboxes. The refresh endpoint refuses terminal sessions. - **Refresh before expiry.** The runner gets `GITHUB_TOKEN_EXPIRES_AT` and re-mints through the lane-token endpoint 5 minutes before expiry, plus before clone and push. - **Masked output.** The runner masks secret env values, decoded credential blobs, and known token shapes in everything it prints, the result file, and the event stream; the log-ingest endpoint and the provider poll scrub again server-side. - **Minimal agent env.** The agent subprocess (and `.pile/setup.sh`) get an allowlisted env: basic process vars, git identity, issue/repo metadata, the Pile API key, the agent's own credential, and the `extraEnv` keys the repo's `env` allowlist admitted. `GITHUB_TOKEN`, the lane token and its URLs, credential blobs, and the runner bundle never reach it. Repositories that also install the Pile GitHub App get a per-repo default agent: `PATCH /workspaces/{org}/github/installations/{id}` with `{"defaultAgentId": "devin-cli"}`. Dispatch on an issue in that repo uses it when the request doesn't name an agent. # ===== docs/blume-2.md ===== # Blume 2.0 — upgrade readout Blume 2.0 shipped 2026-09-24. We run `blume ^1.6.0` in `packages/docs` with the config in `packages/docs/blume.config.ts`. This is the delta and what we should actually take. ## Breaking changes that touch us - **`ai` → `agents`.** Machine-readable settings (`llmsTxt`, `mcp`) moved to a top-level `agents` key. Our config renames the key; flags carry over. - **Adapter-based architecture.** Search, deployment, content sources, API references, analytics, and Ask AI are now imported adapters. `openapi.sources` stays conceptually the same but plugs into the adapter model; `deployment` becomes an imported adapter from the deployment namespace. - **Config shape cleanups**: `theme.layout` removed (we don't use it), `markdown.codeBlocks` merged into `markdown.code` (we don't use it), `lastModified` is a flat value, `analytics` is now an array of `blume/analytics` adapters. - **Stricter CLI**: unknown/misspelled flags now error with suggestions. - **Component overrides** are statically planned and validated at build time — overrides that don't resolve fail the build instead of silently not rendering. - `blume upgrade` codemod exists (optional `--claude`/`--codex` agent-assist flags). AsyncAPI support became an optional peer dep. ## New capabilities and whether we want them | Feature | Verdict | Why | | ---------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `llms.txt` + `llms-full.txt` | **Adopt** | Already on via our config; 2.0 makes it first-class. Agents reading docs is our whole story. | | Raw Markdown at `.md` URLs | **Adopt** | Free agent-consumable docs; zero work beyond upgrade. | | `blume eval` | **Adopt** | Tests whether an agent can answer questions using only our docs. That's a regression gate for the agent-native claim — add evals for "how do I pull ticket artifacts" / "how do I drive the widget protocol headless". | | Hosted docs MCP server | **Evaluate** | We already run our own MCP at `pile.nyc`. Docs-MCP would expose docs _content_ to agents — different surface, possibly complementary. Don't duplicate tool surfaces. | | Ask AI adapters (`gateway`, `openrouter`, `openaiCompatible`, …) | **Skip for now** | Token spend on doc Q&A competes with our own MCP/agent surfaces; revisit only if docs-search questions are actually failing. | | Analytics adapters | **Skip** | Minimalism — no analytics until we need funnel data on docs. | | Content-derived navigation | **Adopt** | Removes hand-maintained nav drift. | | Migration from Mintlify/Docusaurus/etc. | N/A | We're already on Blume. | ## Recommended path 1. `pnpm -C packages/docs add blume@^2` then `blume upgrade` in `packages/docs`. 2. Rename `ai` → `agents` in `blume.config.ts`; move `deployment` and `openapi` to their adapter imports. 3. Verify `llms.txt`, `llms-full.txt`, and `.md` routes on `docs.pile.nyc` after deploy. 4. Add a small `blume eval` question set covering the agent surfaces (artifacts endpoint, widget headless protocol, `agent context pull`). 5. Defer hosted docs-MCP and Ask AI until there's a concrete gap. No urgency to upgrade today — 1.6 works and nothing in 2.0 is a security fix — but the migration is small (one config file) and `blume eval` + raw Markdown are directly on-mission. Bundle it with the next docs change rather than a standalone PR. # ===== docs/cf-migration-report.md ===== # cf migration report — wrangler.toml → cloudflare.config.ts Analysis for PILE-243. Produced by running `cf migrate` (cf 1.0.0-beta.7) against `wrangler.toml` in a scratch copy of the repo — no repository files were modified. `cf migrate --dry-run` reports "Would update 4 file(s): cloudflare.config.ts, wrangler.config.ts, package.json, pnpm-lock.yaml" and leaves `wrangler.toml` in place. ## Verdict The generated skeleton is structurally correct but **incomplete by design**: it emits a literal `throw new Error("Migration incomplete…")` at the top of `cloudflare.config.ts` until every `TODO(@cloudflare)` comment is resolved. The five flagged areas are covered below. Do not merge a config change until each risk row is verified. ## Files the migration touches | File | Change | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cloudflare.config.ts` | New. `defineConfig((ctx) => switch (ctx.mode))` returning `{ worker: {...} }` per mode. | | `wrangler.config.ts` | New. `defineWranglerConfig` with `types.generate: false` in both modes (wrangler ≥4.100 experimental config; wrangler 4.129 in this repo supports it via `--experimental-new-config`). | | `package.json` | `cf` added as a dev dependency (skipped here via `--no-install`). | | `pnpm-lock.yaml` | Lockfile update for the `cf` dep. | | `wrangler.toml` | **Left in place.** Plain `wrangler` commands keep reading it. | Bundler note: `@cloudflare/vite-plugin` is not declared, so cf selected the **wrangler bundler** and `cf dev` / `cf deploy` delegate builds back to wrangler. This matches the issue's interim posture (cf for ops, wrangler for deploys). ## 1. Durable Object bindings (5 bindings × 2 modes) Wrangler semantic (per env): ```toml [[durable_objects.bindings]] name = "WORKSPACE_DURABLE_OBJECT" class_name = "WorkspaceDO" ``` (no `script_name` → self-referencing binding) Generated cf equivalent: ```ts WORKSPACE_DURABLE_OBJECT: bindings.durableObject({ worker: "pile", // "pile-dev" in the default branch exportName: "WorkspaceDO", }), ``` | wrangler field | cf field | Risk | | --------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` (binding) | object key under `env` | Faithful — binding name preserved. | | `class_name` | `exportName` | Faithful, but **stringly-typed**: when `worker` is a string, `exportName` is just `string` — no check that the class is actually in the worker's `exports` map. A typo deploys and fails only at runtime. Passing a `WorkerDefinition` instead of a name enables `InferDurableNamespaces` type-checking. | | omitted `script_name` | `worker: ""` | Self-reference by name must match the branch's `worker.name` (`pile` vs `pile-dev`). Generated correctly in both branches; keep them in sync if names become variables. | ## 2. DO migrations — `[[migrations]] new_sqlite_classes` → `worker.exports` (HIGH RISK) Wrangler semantic: three ordered migration tags provisioned SQLite-backed DO namespaces: - `v1`: `new_sqlite_classes = ["WorkspaceDO"]` - `v2`: `new_sqlite_classes = ["Sandbox"]` - `v3`: `new_sqlite_classes = ["CursorSandbox", "DevinSandbox", "CodexSandbox"]` `cf migrate` does **not** translate these. The cf model replaces the migration chain with a declarative `exports` lifecycle map on the worker: ```ts exports: { WorkspaceDO: exports.durableObject({ storage: "sqlite" }), Sandbox: exports.durableObject({ storage: "sqlite", container: sandbox }), CursorSandbox: exports.durableObject({ storage: "sqlite", container: cursorSandbox }), DevinSandbox: exports.durableObject({ storage: "sqlite", container: devinSandbox }), CodexSandbox: exports.durableObject({ storage: "sqlite", container: codexSandbox }), }, ``` Risks: - **`storage` must be `"sqlite"`** for all five classes — that's what `new_sqlite_classes` meant (the AGENTS.md `new_sqlite_classes` vs `new_classes` gotcha carries over verbatim). `"legacy-kv"` would provision KV-backed namespaces: new DOs would have the wrong storage engine and `WorkspaceDO`'s per-workspace issue state would be unreachable. - **Omission is dangerous.** The `exports` map is the declared end state of the worker's DO namespaces — the same map supports `state: "deleted"`, `"renamed"`, `"transferred"`, `"expecting-transfer"` tombstones. A missing live class is ambiguous at best and risks namespace detach/deletion semantics at worst. All five classes must be listed, in both mode branches. - Ordering (`v1` → `v2` → `v3`) is history-only: all three tags are already applied to `pile` in production, and a fresh `pile-dev` deploy reaches the same end state with all five declared at once. No ordering risk. - Export names verified against the entrypoint: `src/index.ts` exports `WorkspaceDO`, `Sandbox`, `CursorSandbox`, `DevinSandbox`, `CodexSandbox`. - The `exports` field must appear inside each mode's `worker` object — the generated file has no `exports` key at all yet. - Future lifecycle ops (delete/rename/transfer a class) move from wrangler migration tags to `exports.durableObject({ state: ... })` entries. ## 3. Containers — `[[containers]]` → `defineContainer` + `exports.durableObject({ container })` (HIGH RISK) Wrangler semantic: four `[[containers]]` blocks per env, each linking a container app to a DO class via `class_name` (`Sandbox`, `CursorSandbox`, `DevinSandbox`, `CodexSandbox`), with `image`, `max_instances = 20`, `instance_type = "standard-1"`. Dev images are Dockerfiles; production images are pinned registry digests (`registry.cloudflare.com/…/pile--production@sha256:…`). `cf migrate` does not migrate containers at all. The cf model (per `@cloudflare/config` types): ```ts const sandbox = defineContainer({ name: "pile-sandbox-production", // see naming risk below image: { reference: "registry.cloudflare.com/…@sha256:…" }, // or { dockerfile: "./Dockerfile.sandbox" } maxInstances: 20, instanceType: "standard-1", }); export default defineConfig((ctx) => ({ worker: { /* … exports.durableObject({ storage: "sqlite", container: sandbox }) */ }, containers: [sandbox /* … */], })); ``` Risks: - **App naming must match wrangler's derivation.** Wrangler names apps `-` and appends `-` for named environments, so the live apps are `pile-sandbox-production`, `pile-cursorsandbox-production`, `pile-devinsandbox-production`, `pile-codexsandbox-production` (dev: `pile-dev-sandbox`, …). The registry tags in `wrangler.toml` confirm this. `cf` requires an explicit `name`; a different name provisions **new** container applications, orphaning existing instances and doubling quota. - **Two container config shapes exist.** `StandardContainerConfig` (has `image`, `maxInstances`, `instanceType`, `schedulingPolicy: "default" | "regional"`) vs `DurableObjectContainerConfig` (`schedulingPolicy: "durable-object"`, `images: Record` — named images the DO can start). Wrangler's `class_name` linkage is implicitly DO-managed. Whether to map to the standard shape + `exports.*.container` reference, or the durable-object shape, needs verification against `cf deploy` behavior — the types alone don't settle it. - **Per-mode divergence.** Dev builds from Dockerfiles (`{ dockerfile: "./Dockerfile.sandbox*" }`); production pins digests (`{ reference: … }`) precisely because cloudflare-ci has no Docker daemon. Containers must be returned inside the `switch (ctx.mode)` branches, not at top level, to preserve this. - `cf deploy --containers-rollout` exists (`immediate`/`gradual`/`none`) and `cf containers applications instances` covers instance inspection for ops. - Releasing a new image remains a docker-host step (`wrangler containers build -p -t …`, then update the digest) — the wrangler.toml comment documenting that workflow should move to the new config. ## 4. D1 `migrations_dir` → no config equivalent (MEDIUM RISK) Wrangler semantic: `migrations_dir = "migrations"` on the `[[d1_databases]]` binding tells `wrangler d1 migrations {list,apply}` where the SQL files live. cf model: `bindings.d1({ name, id })` has **no migrations field** — confirmed in `D1BindingOptions` (`id`, `name`, `dev` only). Migrations are a pure CLI concern under cf: ``` cf d1 migrations apply --dir ./migrations ``` - `--dir` defaults to `./migrations` and `--pattern` defaults to `/*.sql` — both match this repo's flat `migrations/NNNN_*.sql` layout, so behavior is preserved by default. (Drizzle's nested layout would need `--pattern "/*/migration.sql"`.) - `--table` defaults to `d1_migrations`, same as wrangler — applied-migration bookkeeping carries over. - **Interface change:** cf takes the database (name/ID) directly; there's no binding-name resolution or `-e production` coupling. Current call sites: - `.github/workflows/migrate.yml`: `wrangler d1 migrations apply issuetracker-global --env production --remote` - `scripts/selfhost.sh`: `wrangler d1 migrations apply D1 --remote` - `scripts/backup.sh` / `scripts/dr-restore.sh`: `wrangler d1 export pile-global --remote … -e production` - **Pre-existing discrepancy flagged:** migrate.yml targets `issuetracker-global` while `wrangler.toml` declares `database_name = "pile-global"` (prod binding pins `database_id c4f71628-3f93-4be1-ac11-48b5611f5934`, so the binding itself is unaffected — but the remote name wrangler resolves during `migrations apply` differs from the declared one). Worth confirming the real database name before moving the step to `cf d1 migrations apply `. ## 5. Env split — `[env.production]` → `switch (ctx.mode)` (LOW/MEDIUM RISK) Generated shape: ```ts export default defineConfig((ctx) => { switch (ctx.mode) { case "production": { return { worker: { name: "pile", … } }; } default: { return { worker: { name: "pile-dev", … } }; } } }); ``` Selected by `cf … --mode production`; wrangler's experimental new-config loader maps `--env` to `mode` (`-e production` keeps working via `--experimental-new-config`), and `CLOUDFLARE_ENV` also feeds `mode`. Semantic check against wrangler's inheritance rules (verified in the wrangler 4.129 source — `triggers`, `migrations`, `exports`, `compatibility_*`, `name` are **inheritable**; `vars`, bindings, `containers`, `send_email`, `queues` are **not**): - `triggers.crons` is inheritable → the production worker does run the `*/5 * * * *` cron today, and the generated config correctly puts `triggers.scheduled` in both branches. - Production redeclares every non-inheritable binding, so the duplicated maps in the generated file are faithful — nothing was silently inherited. - `routes = [{ pattern = "pile.nyc", custom_domain = true }]` → `domains: ["pile.nyc"]` on the production branch only. Correct for a custom domain (a non-custom route would instead be `triggers.fetch({ pattern, zone })`). Dev keeps `workers.dev` (cf `workersDev` defaults to `true`, matching wrangler's default). - The prod-only vars (`DAYTONA_*`, `DEVIN_MODEL`, `PUBLIC_API_URL`, `FEEDBACK_CHANNEL_ID`) appear only in the production branch — faithful. Risks: - **`default:` swallows typos.** `--mode prod` (or any unknown mode) silently evaluates the dev/self-host branch — under wrangler, `-e prod` errors because no such env exists. The final config should guard `default` (e.g. `case "development"` / throw on unknown mode) rather than leaving open-ended fallthrough. - Mode string must be threaded everywhere: `cf deploy --mode production`, `cf workers types --mode production`, wrangler's `-e`→mode mapping, and CI's `deploy`/migrate steps. ## Other observations - **Secrets:** `.dev.vars.example` was detected but not migrated (only `secrets.required` entries move). Secrets stay CLI-managed: `wrangler secret put` → `cf workers secrets update`, bulk → `cf workers secrets bulk`, deploy-time file → `cf deploy --secrets-file`. Verify `.dev.vars` is still honored by `cf dev` before switching the dev workflow. - **Type generation:** the generated `wrangler.config.ts` sets `types.generate: false` — cf owns env typing (`InferEnv` / `cf workers types`). `pnpm run types` currently runs `wrangler types` against `wrangler.toml` and regenerates the committed `worker-configuration.d.ts`; that script needs a cf equivalent when the config lands. - **Dual source of truth during transition:** `wrangler.toml` stays, and plain `wrangler deploy`/`wrangler dev` keep reading it, while `cf` reads `cloudflare.config.ts`. Until the toml is retired, every binding change has to land in two places — define which file is authoritative per operation (per this issue: wrangler.toml for deploys) and schedule its deletion. - **`cf` version pinning:** repo policy prefers pinned deps ≥7 days old; `cf@1.0.0-beta.7` is what the migration installs — pin it, don't float `latest`. - **`cf dev` bundles its own workerd** (1.20260930.2 in the global install) while the repo pins `compatibility_date = "2026-07-30"` to match `workerd 1.20260730.1` — keep the date pinned; if `cf dev` is adopted for local dev, verify the runtime version skew is acceptable. - **Clean-tree requirement:** `cf migrate` refuses to run on a dirty worktree (`--force` overrides) and `--install` edits package.json/lockfile. ## Ops surface under cf (per the issue's interim posture) | Task | wrangler | cf | | ------------------- | ------------------------------- | -------------------------------------------------------------- | | Container instances | `wrangler containers instances` | `cf containers applications instances` | | D1 query | `wrangler d1 execute` | `cf d1 query ` / `cf d1 raw` | | Worker versions | `wrangler versions list` | `cf workers versions` | | Logs | `wrangler tail` | `cf logs` (query/rayid/datasets — verify tail parity) | | Startup check | `wrangler check startup` | `cf workers check` | | D1 migrations | `wrangler d1 migrations apply` | `cf d1 migrations apply [--dir]` | | Secrets | `wrangler secret` | `cf workers secrets` | | Deploy | `wrangler deploy -e production` | keep wrangler (per issue); `cf deploy --mode production` later | | Dev | `wrangler dev` | `cf dev` (delegates to wrangler build) | | Env types | `wrangler types` | `cf workers types` | ## Review checklist before merging a config change - [ ] `exports` map lists all 5 DO classes with `storage: "sqlite"` in both mode branches; `exportName` in `env` bindings matches export keys. - [ ] 4 `defineContainer` entries per mode with wrangler-derived names (`pile-*-production` / `pile-dev-*`), correct image form per mode, `maxInstances: 20`, `instanceType: "standard-1"`, and the right scheduling shape verified against `cf deploy`. - [ ] `container` references wired onto the four sandbox `exports.durableObject` entries. - [ ] `default:` mode branch no longer silently accepts unknown modes. - [ ] `cf d1 migrations apply` call sites updated (CI, selfhost.sh, backup/dr-restore scripts) and the `issuetracker-global` vs `pile-global` name question resolved. - [ ] `pnpm run types` moved to `cf workers types`; `worker-configuration.d.ts` regeneration verified. - [ ] `.dev.vars` handling under `cf dev` confirmed. - [ ] Plan agreed for retiring `wrangler.toml` (or scoping it to deploy-only while `cloudflare.config.ts` exists). - [ ] `cf` pinned in `package.json`; `contract:check` regenerated artifacts unaffected. # ===== docs/compliance.md ===== # Compliance self-assessment > **System of record:** the living register tracks in CompAI. This file > is the point-in-time self-assessment — update CompAI, not this doc, > when a gap moves. Scope: Pile, self-hostable by design — for self-hosted deployments the deployer is the data controller and this doc is a template. For Vortex's own hosted deployment (`pile.nyc`), this is the assessment. Audience: the operator, then any auditor or compliance platform we adopt. Method: SOC 2 Trust Services Criteria as the frame. For each criterion: what exists today, evidence pointer, verdict. "N/A" means genuinely inapplicable, not "didn't bother." Legend: **met** / **partial** / **gap** / **N/A**. ## CC1–CC5 — Environment, comms, risk, monitoring, controls | Criterion | State | Evidence | | ------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CC1.x Control environment | partial | AGENTS.md encodes the invariants (pnpm only, no `any`, native-first, no AI attribution). Solo operator, no board. | | CC2.1 Internal comms of security objectives | met | AGENTS.md hard rules; this doc; `SECURITY.md`. | | CC2.2 External comms to customers | gap | No ToS / privacy policy for the hosted deployment. Self-hosters are their own controllers, but `pile.nyc` signups have nothing to read. | | CC3.x Risk assessment | partial | Risks mitigated in code (rls middleware, signature-gated webhooks, fail-closed cap gate); no consolidated written risk register — the gap register below is the seed. | | CC4.1 Ongoing monitoring | partial | Worker observability + request logging middleware; no standing alerting on backup cadence or queue depth yet. | | CC5.x Control activities | met | Controls are in code and tests: rls() per-route permission gates, webhook signature verification, atomic usage cap enforcement, idempotent webhook dedup. | ## CC6 — Logical access | Criterion | State | Evidence | | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CC6.1 Access provisioning | met | Workspace-scoped API tokens (Better Auth apiKey plugin) verified per-request; human sessions via Better Auth; membership checked in `workspaceAuthMiddleware`. | | CC6.2 Access removal | met | Token delete + member removal are real verbs; workspace delete purges D1 + R2 + DO storage. | | CC6.3 Access review | **gap** | **No scheduled review.** Quarterly: enumerate members per org, API tokens, Cloudflare account access, GitHub collaborators, 1Password/Veil vaults; diff against expectation. First review next calendar quarter. | | CC6.6 Least privilege | met | rls() scopes by permission ("read"/"write"/"admin", project roles); workspace tokens are org-scoped; no superuser token exists. | | CC6.7 Data transmission | met | TLS end-to-end via Cloudflare; webhook payloads HMAC-signed; secrets never logged (verified by leak-focused tests). | | CC6.x Encryption at rest | met | Provider tokens AES-256-GCM (`encryptSecret`, KEK from `AGENT_SETTINGS_KEK`/`BETTER_AUTH_SECRET`); Better Auth `encryptOAuthTokens` on; D1/DO/R2 encrypted by Cloudflare at rest. | ## CC7 — System operations | Criterion | State | Evidence | | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | CC7.1 Vulnerability detection | met | trufflehog CI (verified secrets), secretlint gate, dependabot weekly npm + actions updates, knip. | | CC7.2 Security monitoring | partial | Request logging + error responses with codes; no WAF tuning beyond Cloudflare defaults — acceptable now, revisit at public launch. | | CC7.3 Incident response | met | `docs/incident-response.md` — severity ladder, comms template, postmortem format. | | CC7.4 Incident recovery | met | `scripts/dr-restore.sh` — verified restore drill (39/39 tables, exact counts); `docs/runbook-dr.md`. | ## CC8 — Change management | Criterion | State | Evidence | | --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CC8.1 Changes authorized & tested | partial | CI gates everything (vp check, tests, contract check); solo dev means no mandatory second review — anything touching authz/crypto/billing gets an adversarial self-review pass before deploy. Deploys are `wrangler deploy` from `main` only. | ## CC9 — Vendors & continuity | Criterion | State | Evidence | | ------------------------- | ----- | -------------------------------------------------------------------------------------------------- | | CC9.1 Vendor register | met | See below. | | CC9.2 Business continuity | met | RPO 24h / RTO 4h documented; D1 export + DO export + R2 manifest; restore drill proven end-to-end. | ## Data classification | Tier | Data | Handling | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | **Secrets** | `BETTER_AUTH_SECRET`, `AGENT_SETTINGS_KEK`, `DISPATCH_SECRET`, `BILLING_WEBHOOK_SECRET`, `VORTEX_BILLING_API_KEY`, GitHub App private key, provider tokens (encrypted), webhook secrets | env/secret store only; never in D1 plaintext, logs, audit, or API responses | | **Confidential** | issues, comments, support tickets, customer emails, agent sessions/artifacts, workspace metadata, billing accounts | org-scoped; rls() or signature/token gates every route | | **Internal** | usage_records, webhook_deliveries, audit events, request logs | ops-only; no customer PII beyond identifiers | ## Privacy notes (P-series) We collect: member emails/names (Better Auth), org membership, issue + support content (which may embed customer names/emails in ticket text), capture recordings, agent session traces. CA employee data = CCPA-covered. - **No privacy notice yet** for hosted `pile.nyc` members — gap (see PILE-177); self-hosters publish their own. - **Data export** exists: `GET /workspaces/{org}/export` dumps D1 metadata + full DO state; workspace delete is a real verb. - Retention: backups rotate on a schedule; DR dumps are local-only and gitignored. ## Vendor register Every vendor that touches customer data or production access. | Vendor | Role | Customer data | Access | Assurance | | ---------------- | ------------------------------------------------------------ | ------------------------------------------------- | -------------------------------------- | -------------------------------- | | Cloudflare | Workers, D1, Durable Objects, R2, Queues, Email Routing, DNS | everything — compute + storage + email in transit | full prod | SOC 2 (cloudflare.com/trust-hub) | | Vortex Billing | subscriptions, invoices, usage rating | org ids, plan state, usage counts | billing API + webhooks | internal (Vortex) | | GitHub | source hosting, optional repo integration | repo metadata via GitHub App | repo-scoped tokens, per-request minted | SOC 2 | | GitLab | optional integration | project-scoped PATs (encrypted at rest) | webhook + API | user-supplied instances vary | | Better Auth | auth framework | identity/session data in our D1 | none (self-hosted lib) | library, not a processor | | 1Password / Veil | secret escrow + agent creds | KEK-class secrets | escrow only | SOC 2 | ## Gap register | # | Gap | Severity | Action | | --- | ------------------------------------------------- | --------------------- | ------------------------------------------------------------ | | G1 | No privacy policy / ToS for hosted pile.nyc | high | PILE-177 — minimal plain-English doc before external signups | | G2 | No scheduled access review | medium | Quarterly checklist — CC6.3 above | | G3 | No standing alert on backup cadence / queue depth | medium | Monitor cron/queue heartbeats; cheap win | | G4 | SAML/SCIM/audit-log streaming | low (enterprise gate) | PILE-185/186/187 | | G5 | Consolidated risk register | medium | Promote this doc's register when it outgrows a table | ## What an auditor gets asked for, and where it lives | Ask | Answer | | ------------------------ | --------------------------------------------------------------------------- | | "Access control policy" | AGENTS.md hard rules + rls() middleware + CC6 above | | "Incident response plan" | `docs/incident-response.md` | | "Backups work" | `scripts/backup.sh` + `dr-restore.sh` + verified drill (docs/runbook-dr.md) | | "Vendor SOC reports" | vendor register above | | "Change management" | git history + CI gates + CC8.1 note | | "Data deletion" | `DELETE /workspaces/{id}` purge + export path | | "Secrets handling" | env-only secrets, AES-256-GCM token encryption, secretlint+trufflehog CI | # ===== docs/email-setup.md ===== # Email setup (per zone) Pile sends and receives email through Cloudflare Email Routing + the `send_email` binding — no SMTP, no third-party ESP. ## One-time zone setup (production: pile.nyc) Email Routing isn't covered by the wrangler OAuth token used for deploys; do this once in the dashboard (or with an `Email Routing:Edit` API token): 1. **Enable Email Routing** — zone → Email → Email Routing → _Get started_. Cloudflare adds MX + SPF records automatically. 2. **Catch-all rule** → destination: worker `pile`. The worker's `email()` handler parses with `postal-mime`, maps the recipient to an active `support_channels` row (`type: "email"`, `name` = the address), and queues a deduped `processIncomingMessage`. Replies thread via `In-Reply-To`/`References`. 3. **Email Sending authorization** — add the domain under Email Routing → Send Email so `env.EMAIL` (binding `EMAIL`, sender `notifications@pile.nyc`) can deliver. Cloudflare adds DKIM/SPF/DMARC. Or via API with a scoped token: ```bash curl -X POST "$CF/zones/$ZONE_ID/email/routing/enable" -H "Authorization: Bearer $CF_API_TOKEN" # then create the catch-all rule -> worker "pile" under # /zones/$ZONE_ID/email/routing/rules/catch_all ``` ## Per-workspace ```bash # the channel name IS the inbound address the rule routes to POST /workspaces/{org}/support/channels { "type": "email", "name": "support@yourdomain.com" } ``` Outbound replies send from that same address and thread on the customer's last inbound `Message-ID`. ## Customer intake addresses (PILE-325) An `email_inboxes` row routes inbound mail to the customer intake flow instead of support tickets — for merchant/rep document drop-off (`intake@yourdomain.com`, or a per-customer address): ```bash POST /workspaces/{org}/email-inboxes { "address": "intake@yourdomain.com", "customerId": null, "projectId": null } ``` - Recipient address must match the inbox `address` exactly (lowercased). - `customerId` pins every mail to that customer; when null the worker resolves by sender domain ↔ customer `url` host, and creates a customer when nothing matches (freemail senders are named by display name, never by domain). - Attachments land in R2 under `{org}/intake/{hash}/` and are listed on the intake item; bodies and metadata are filed on the record and readable via `GET /workspaces/{org}/customers/{id}/intake`. - Dedup is on `Message-ID` (or a SHA-256 of the raw MIME when the header is missing), so retries and redeliveries can't double-file. ## Templates & transport - Templates: `src/email/templates.tsx` (React Email — ticket reply, changelog shipped). Render with `renderTicketReply`/`renderChangelogShipped`. - Transport: `src/email/send.ts` — `mimetext` builds the MIME (text + optional HTML, `Message-ID`, `In-Reply-To`, `References`), `EmailMessage` + `env.EMAIL.send` delivers. - Opt-out: `emailOptOut` on contacts; one-click unsubscribe at `POST /support/unsubscribe` (no existence oracle). ## Local dev No setup needed — Miniflare stubs `EMAIL` and captures sends in tests (`src/email/send.test.ts`, `src/api/changelog.test.ts`). Inbound handler is covered by `src/channels/email.test.ts`. # ===== docs/incident-response.md ===== # Incident response `docs/runbook-dr.md` has the verbs. This doc has the judgment: severity, who tells whom, and what a clean incident looks like. ## Severity ladder | Sev | Definition | Examples | Response | | ------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **S1** | Secrets exposed, data lost, or auth/tenant boundary broken | `BETTER_AUTH_SECRET`/KEK leaked; cross-workspace issue or ticket data readable (rls bypass); webhook secret forged; billing state corrupted | Stop the bleed first (revoke tokens, rotate secret, roll back deploy). Comms within **24h** to affected workspaces — what happened, what leaked, what we did, what they must do. Postmortem mandatory. | | **S2** | Control broken, no confirmed exposure | Backup script silently failing; queue backlog growing; email binding dead so users never get replies; DO migration drift | Fix inside a day. Comms only if customer-visible. Postmortem if the cause wasn't obvious. | | **S3** | Degraded but correct | Elevated 5xx on `pile.nyc`; cron sweep late; R2 upload failed but state intact | Fix on the normal track. Log it. | When unsure between S1 and S2, treat as S1 for 30 minutes while you prove which it is. Downgrading later is free; upgrading late is not. ## First hour — the checklist 1. **Contain.** Pick the verb before you investigate: - Compromised API token / session → revoke via `DELETE /workspaces/{org}/tokens/{id}` (or ban the user via Better Auth admin endpoints). - Compromised workspace → `DELETE /workspaces/{id}` purge path (D1 rows + R2 objects + DO storage) or operator-level org wipe. - Secret suspicion (`BETTER_AUTH_SECRET`, `DISPATCH_SECRET`, `BILLING_WEBHOOK_SECRET`) → `wrangler secret put` rotate + redeploy; sessions/tokens minted under the old secret die. - Bad deploy → `wrangler rollback` / redeploy last good version id. - D1 bad → the Worker fails closed; fix D1, everything recovers. - Billing webhook secret forged → rotate `BILLING_WEBHOOK_SECRET`, audit `billing_accounts` rows changed in the window. 2. **Preserve.** Snapshot before you clean: `scripts/backup.sh` for a point-in-time D1 + DO + R2 capture, Cloudflare dashboard logs for the window, relevant `webhook_deliveries`/`audit` rows. 3. **Decide the sev** from the ladder. S1 means comms drafting starts now, not after root cause. Whoever declares the sev is the **incident lead** — they own containment, comms, and status page updates until they hand off explicitly. ## Comms Customer-visible incidents also go on the public status page (https://status.pile.nyc — use https://pile.openstatus.dev until the custom domain's TLS is live) — when and how is in `docs/status-page.md`. S1 template — send to every affected workspace owner, plain text: ```text Subject: Pile security incident — What happened: What was exposed: What we did: What you must do: Timeline: Postmortem follows within 5 days. ``` Rules: name what leaked or say "no evidence of exposure" — never vague "might have been affected." If credentials left the platform, the "what you must do" line is a rotation list per token/key. ## Postmortem (S1 mandatory, S2 if non-obvious) ```markdown # Postmortem — ## What happened ## Timeline (detected / contained / resolved) ## Root cause ## What worked ## What didn't ## Action items (each: owner, due) ``` Blameless: the question is always "what property of the system allowed this," never "who fumbled." An incident where fail-closed paths fired and nothing leaked is a success story — write it down too; that's the evidence an auditor wants to see. ## Bus factor If the founder is unavailable, the recovery story is: 1. Production secrets are managed via Veil/1Password — vault access is the choke point; a trusted second must hold it. 2. Cloudflare account access + this repo + `docs/runbook-dr.md` are the entire operational surface. 3. Restore = `scripts/dr-restore.sh` (D1 per-table fixpoint) + workspace exports + R2 manifest. The drill was proven on a real dump — 39/39 tables, exact counts. Write the second person down by name next to the escrowed secrets. Undocumented trust is the same as no recovery path. # ===== docs/linear-parity.md ===== # Linear API / CLI / MCP / Docs — Parity Audit This is the durable gap map for replacing Linear operationally. It is based on Linear's public GraphQL schema (`packages/sdk/src/schema.graphql` from `linear/linear`) and the Pile codebase at the time of writing. ## Methodology - Parsed the Linear SDK SDL with `graphql`'s `buildSchema`. - Counts root fields: **170 queries**, **374 mutations**, **82 subscriptions**. - Cross-referenced with Pile `src/api/*.ts`, `src/global/schema.ts`, `src/mcp/mcp-tools.ts`, and `ROADMAP.md`. - Status legend: - **Done** — shipped and wired in the API/DO/schema. - **Partial** — model or route exists, but coverage is thin. - **Missing** — not implemented. - **N/A** — not in scope for the backend-first target (e.g., native mobile UI). ## Executive summary Pile covers the core issue-tracking surface (issues, comments, labels, states, projects, cycles, attachments, history, subscribers, relations, reactions, batch updates) plus roadmaps, initiatives, a working GitHub integration, Linear migration, Better Auth session support, and a multi-provider agent dispatch layer (Devin cloud/outpost, Cursor cloud/BYOM, cf-agent) with cancel and live session streaming. The biggest gaps versus Linear are: 1. **Teams** — Linear is multi-team inside an organization. Pile has workspaces (Better Auth organizations) and teams, with per-team issue scoping and visibility. 2. **Notifications & delivery preferences** — Linear has user notifications, delivery preferences, and outgoing webhooks. Pile has in-app notifications and outbound webhooks for issue/comment lifecycle events, including retries and a delivery log. Per-user delivery preferences (in-app/webhook/email flags + muted types) via `GET/PUT /notification-preferences`, enforced in the notify path. No email/push transport yet. 3. **Agent/AI surfaces** — Linear has agent sessions, activities, skills, and AI conversations. Pile has agent sessions and activities; skills/conversations are not yet modeled. 4. **CLI / SDK parity** — Pile has an OpenAPI-generated client and MCP tools but no standalone CLI parity and no GraphQL API. The published docs site exists in `packages/docs` (Blume) with OpenAPI reference and `llms.txt`. 5. **Zendesk/GitLab/Intercom integrations** — GitHub and Slack are wired (Chat SDK adapter; mentions + `/pile` slash command + channel notifications). ## Pile surface inventory Routes currently registered in `src/api/index.ts`: ``` GET /workspaces POST /workspaces GET /workspaces/{id} GET /workspaces/slug/{slug} GET /workspaces/{organizationId}/issues POST /workspaces/{organizationId}/issues GET /workspaces/{organizationId}/issues/{id} PATCH /workspaces/{organizationId}/issues/{id} DELETE /workspaces/{organizationId}/issues/{id} POST /workspaces/{organizationId}/issues/{id}/dispatch GET /workspaces/{organizationId}/issues/{issueId}/attachments GET /workspaces/{organizationId}/issues/{issueId}/comments POST /workspaces/{organizationId}/issues/{issueId}/comments GET /workspaces/{organizationId}/issues/{issueId}/comments/{id} PATCH /workspaces/{organizationId}/issues/{issueId}/comments/{id} DELETE /workspaces/{organizationId}/issues/{issueId}/comments/{id} GET /workspaces/{organizationId}/issues/{issueId}/history GET /workspaces/{organizationId}/issues/{issueId}/relations POST /workspaces/{organizationId}/issues/{issueId}/relations DELETE /workspaces/{organizationId}/issues/{issueId}/relations/{id} GET /workspaces/{organizationId}/issues/{issueId}/subscribers POST /workspaces/{organizationId}/issues/{issueId}/subscribers DELETE /workspaces/{organizationId}/issues/{issueId}/subscribers/{id} GET /workspaces/{organizationId}/cycles POST /workspaces/{organizationId}/cycles GET /workspaces/{organizationId}/cycles/{id} PATCH /workspaces/{organizationId}/cycles/{id} DELETE /workspaces/{organizationId}/cycles/{id} POST /workspaces/{organizationId}/github/install POST /workspaces/{organizationId}/github/users POST /workspaces/{organizationId}/labels GET /workspaces/{organizationId}/labels GET /workspaces/{organizationId}/labels/{id} PATCH /workspaces/{organizationId}/labels/{id} DELETE /workspaces/{organizationId}/labels/{id} POST /workspaces/{organizationId}/linear-users GET /workspaces/{organizationId}/linear-users GET /workspaces/{organizationId}/linear-users/{linearId} POST /workspaces/{organizationId}/memberships GET /workspaces/{organizationId}/memberships POST /workspaces/{organizationId}/invitations GET /workspaces/{organizationId}/invitations DELETE /workspaces/{organizationId}/invitations/{id} POST /workspaces/{organizationId}/invitations/{id}/resend POST /workspaces/{organizationId}/migrate/linear POST /workspaces/{organizationId}/projects GET /workspaces/{organizationId}/projects GET /workspaces/{organizationId}/projects/{id} PATCH /workspaces/{organizationId}/projects/{id} DELETE /workspaces/{organizationId}/projects/{id} POST /workspaces/{organizationId}/roadmaps GET /workspaces/{organizationId}/roadmaps GET /workspaces/{organizationId}/roadmaps/{id} PATCH /workspaces/{organizationId}/roadmaps/{id} DELETE /workspaces/{organizationId}/roadmaps/{id} GET /workspaces/{organizationId}/roadmaps/{id}/initiatives POST /workspaces/{organizationId}/initiatives GET /workspaces/{organizationId}/initiatives GET /workspaces/{organizationId}/initiatives/{id} PATCH /workspaces/{organizationId}/initiatives/{id} DELETE /workspaces/{organizationId}/initiatives/{id} POST /workspaces/{organizationId}/states GET /workspaces/{organizationId}/states GET /workspaces/{organizationId}/states/{id} PATCH /workspaces/{organizationId}/states/{id} DELETE /workspaces/{organizationId}/states/{id} POST /workspaces/{organizationId}/templates GET /workspaces/{organizationId}/templates GET /workspaces/{organizationId}/templates/{id} PATCH /workspaces/{organizationId}/templates/{id} DELETE /workspaces/{organizationId}/templates/{id} POST /workspaces/{organizationId}/tokens GET /workspaces/{organizationId}/tokens DELETE /workspaces/{organizationId}/tokens/{id} GET /workspaces/{organizationId}/ws ``` Plus `/openapi.json`, `/mcp`, `/api/auth/*`, `/github` webhooks, and health. ## Gap map by domain | Domain | Linear surface (representative) | Pile status | Notes / gap | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Auth & sessions** | `viewer`, `authenticationSessions`, `logout`, OAuth, passkeys, SAML/SCIM | Partial | Better Auth email/password mounted at `/api/auth/*`; workspace tokens for agents. OAuth apps/clients managed via Better Auth apiKey; passkeys/SAML not configured. | | **Users & memberships** | `users`, `teams/members`, `userUpdate`, `organization` | Done | Better Auth `user`/`member`/`invitation` tables; human + agent users live in `user`; memberships/invitations are Better Auth. | | **Teams** | `teams`, `teamCreate`, `teamUpdate`, `teamDelete`, `teamMemberships`, `teamSettings` | Done | `GET/POST/PATCH/DELETE /workspaces/{id}/teams` + `/.../members` using Better Auth `team` and `teamMember` tables. | | **Issues** | `issue`, `issues`, `issueCreate`, `issueUpdate`, `issueArchive`, `issueDelete`, `issueBatchUpdate`, `issueSearch`, `issueRelations`, `issueHistory`, `issueSubscribers` | Partial | Full CRUD, history, subscribers, relations, batch update, identifiers (`KEY-123`), full-text search, status/resolution model. No triage queue, `issueVcs` branch-name helper live, no estimates. | | **Comments** | `comment`, `comments`, `commentCreate`, `commentUpdate`, `commentDelete`, `commentResolve`, `commentUnresolve`, `reactions` | Partial | CRUD for issue/document comments, resolve/unresolve, reactions on issues and comments. | | **Labels** | `issueLabels`, `issueLabelCreate`, `issueLabelUpdate`, `issueLabelDelete`, `issueLabelArchive` | Done | Full CRUD. GitHub label sync exists. | | **States / workflow** | `teams/states`, `workflowStateCreate`, etc. | Done | `states` table + CRUD. Migration maps Linear state types. | | **Projects** | `projects`, `projectCreate`, `projectUpdate`, `projectArchive`, `projectUpdateReminder`, `projectStatus` | Partial | `projects` table + CRUD; `project_updates`, `project_milestones`, `project_update_reminders` with full CRUD. `projectArchive`/`unarchive` and `health` roll-up live. | | **Cycles** | `cycles`, `cycleCreate`, `cycleUpdate`, `cycleArchive`, `cycleShiftAll`, `cycleStartUpcomingCycleToday` | Partial | `cycles` table + CRUD, rollover, capacity, `cycleShiftAll` and `start-today` endpoints. GitHub milestones map to cycles. `cycleArchive`/`unarchive` live. | | **Initiatives / roadmaps / releases** | `initiatives`, `initiativeCreate/Update/Delete`, `roadmaps`, `roadmapCreate`, `releasePipelines`, `release` | Partial | `roadmaps` and `initiatives` tables with CRUD, roadmap-to-initiative nesting, date/status. Release pipelines and releases modeled in the DO with CRUD and project linking. | | **Documents** | `documents`, `documentCreate/Update/Delete`, `documentContentHistory` | Done | `documents` + `document_content_history` + `document_spaces` + `document_shares` + `document_watchers` in the DO; BlockNote JSON content, spaces (Confluence-style), nested pages, project/issue/initiative linkage, trash/restore, version history, page comments with resolve/unresolve, public share links, watch→notify, full-text doc search, templates (`is_template`). | | | **Customers** | `customers`, `customerNeeds`, `customerStatuses`, `customerTiers` | Done | `customers`, `customer_tiers`, `customer_statuses`, `customer_needs` in the DO with full CRUD and issue/project need linking. | | | **Attachments** | `attachments`, `attachmentCreate/Delete/Update`, `attachmentLink*` (GitHub, Slack, etc.) | Partial | `attachments` table and route. GitHub attachments via migration. No deep link types (Slack, Intercom, etc.). | | **Search & filters** | `search`, `issueSearch`, `customViews`, `customViewCreate`, `aiConversation*`, `semanticSearch` | Partial | Full-text search across issue title, description, identifier, and comments; saved views with filters and search. No semantic/AI search. | | **Notifications** | `notifications`, `notificationSubscriptionCreate`, `notificationDeliveryPreferences`, `pushSubscriptions` | Partial | In-app notifications for issue/comment lifecycle events; list/unread/mark read. Email transport is live via send_email. Push is still missing. | | **Webhooks** | `webhooks`, `webhookCreate/Update/Delete`, `oauthClient*` | Partial | Outbound Pile webhooks for issue/comment lifecycle events, retries, delivery log, and subscription CRUD. Inbound GitHub webhooks + idempotency. No OAuth app management. | | **Integrations** | `integration*`, `jira*`, `github`, `gitlab`, `slack`, `zendesk` | Partial | GitHub App install + issue/comment/PR/label/milestone/assignee sync. Slack via Chat SDK (OAuth install, verified events, mention/`/pile` issue creation, channel notifications). No Jira/GitLab/Zendesk. | | **Import / migration** | `import` tooling, CSV, Jira | Partial | `POST /workspaces/{id}/migrate/linear` imports a Linear team into a Pile workspace. No Jira/CSV. | | | **Agent / AI surfaces** | `agentSessions`, `agentActivities`, `agentSkills`, `aiConversation*`, `prompt*` | Partial | `agent_sessions`/`agent_activities` in the workspace DO; multi-provider dispatch (Devin, Cursor, cf-agent), cancel route, live UI-message-stream SSE tail, per-workspace provider credentials. No skills or AI conversation model yet. | | **Audit / admin** | `auditEntries`, `auditEntryTypes`, `usage`, `emailIntakeAddress` | Partial | `audit_log` in the DO — issue/document/customer/release mutations recorded with field-level diffs. `GET /audit-log` with entity filters. No `emailIntakeAddress` or usage metering. | | | **API shape** | GraphQL single endpoint, typed SDK, Relay pagination | Partial | Hono/OpenAPI REST with generated OpenAPI/MCP/client. No GraphQL. Pagination is cursor-based on `createdAt,id`. | | **CLI** | `@linear/cli` style commands | Partial | `packages/cli` exists but is minimal; not feature-complete. | | **MCP** | MCP server exposure | Partial | `src/mcp/server.ts` exposes OpenAPI routes as tools. Grows automatically with routes. | | **Docs** | developers.linear.app style docs | Done | Blume docs site in `packages/docs` with OpenAPI reference, `llms.txt`, MCP, and public doc endpoints. | ## Concrete operation counts from the schema | Root | Count | Top resource prefixes | | ------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Query | 170 | `issue*`, `project*`, `initiative*`, `agent*`, `customer*`, `release*`, `team*`, `document*`, `search`, `auditEntries` | | Mutation | 374 | `issue*`, `project*`, `initiative*`, `integration*`, `attachment*`, `customer*`, `cycle*`, `agent*`, `comment*`, `team*`, `user*`, `webhook*` | | Subscription | 82 | `issue*`, `document*`, `project*`, `team*`, `agent*`, `comment*`, `notification*`, `initiative*`, `roadmap*`, `cycle*`, `favorite*`, `workflow*` | Pile currently exposes **113 OpenAPI paths / 177 MCP tools**. Linear's public GraphQL surface has **626 root operations** (170 + 374 + 82), so Pile covers the high-frequency subset but not the long tail. ## Biggest blockers to "never touch Linear again" 1. **Zendesk/GitLab/Intercom integrations** — GitHub and Slack are wired. 2. **Email/push transport** — delivery preferences are modeled and enforced; no email/push senders yet. 3. **Triage queue, estimates, OAuth apps** — smaller workflow gaps (see gap map). ## Recommended next slices Based on the gap map and the current backend-first priority, the next high-leverage slices are: 1. **Zendesk / GitLab / Intercom integrations** — expand beyond GitHub and Slack. 2. **Published docs site and CLI parity** — make the OpenAPI/MCP surface usable as a docs site and a real CLI. ## How to update this document When a domain moves from **Missing** to **Partial** or **Done**, update this file and the relevant GitHub issue. When a new major capability ships, add it to the Pile status column and adjust the executive summary. # ===== docs/onboarding.md ===== # Onboarding — agent setup checklist Everything a workspace needs to go from zero to a dispatched lane. Every step is an API call; `GET /workspaces/{org}/agent/setup-status` is the machine- readable version of this checklist — it reports `ready`/`missing` per provider. ```bash BASE=https://pile.nyc ORG=your_org_id KEY=your_workspace_api_key ``` ## 1. Workspace + API key Create a workspace and mint an API key with `agent:read`, `agent:write`, and `admin` permissions (tokens endpoint under `/workspaces/{org}/tokens`). ## 2. GitHub Install the Pile GitHub App on the repos lanes will touch, then link the installation: ```bash curl -X POST "$BASE/workspaces/$ORG/github/installations" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"installationId": ""}' ``` Per repo, optionally pin a default agent and a `.pile/config.json` contract — see `docs/agent-providers.md` ("Repo environment contract"). ## 3. Provider credentials ```bash curl "$BASE/workspaces/$ORG/agent/providers/catalog" \ -H "Authorization: Bearer $KEY" # which agents exist + required fields curl -X PUT "$BASE/workspaces/$ORG/agent/providers/devin" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"mode":"hosted","token":""}' curl -X POST "$BASE/workspaces/$ORG/agent/providers/devin/health" \ -H "Authorization: Bearer $KEY" # credential probe, no session ``` ## 4. Git identity (for lane-authored commits) ```bash curl -X POST "$BASE/workspaces/$ORG/git/identities" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"repo":"owner/repo","name":"My Bot","email":"bot@example.com","githubUsername":"my-bot"}' ``` Name/email (+ optional signing-key reference and GitHub username) lanes sign commits with. ## 5. Readiness check ```bash curl "$BASE/workspaces/$ORG/agent/setup-status" -H "Authorization: Bearer $KEY" # → {"githubConnected": true, "providers": [{"agentId":"devin","ready":true,"missing":[]}]} ``` ## 6. First dispatch ```bash ISSUE=$(curl -X POST "$BASE/workspaces/$ORG/issues" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"title":"first lane","repo":"owner/repo"}' | jq -r .id) curl -X POST "$BASE/workspaces/$ORG/issues/$ISSUE/dispatch" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{}' ``` Watch the lane's event stream: `GET /workspaces/{org}/agent/sessions/{id}/stream`. ## External agents Agents Pile didn't dispatch register themselves instead — see `POST /workspaces/{org}/agent/sessions/register` (returns a lane token for `/report` + `/logs`). Pile tracks them like any dispatched lane. ## Support If your escalation rules misfire, contact support-escalations@vortex.nyc. # ===== docs/privacy.md ===== # Privacy notice (hosted pile.nyc) Plain-English version. For self-hosted deployments, the deployer is the data controller and publishes their own notice — this one covers only the Vortex-operated service at `pile.nyc`. ## What we collect - **Account data** — email and name (Better Auth) for workspace members. - **Workspace content** — issues, comments, documents, support tickets, and message text you create. Ticket content may contain your own customers' names/emails if you put them there. - **Capture artifacts** — session recordings, console logs, and screenshots submitted through the capture SDK, recording links, or support widget. Captures are initiated by an operator or an end user with the link; we don't record anyone passively. - **Support widget identity** — anonymous session ids by default, or email/external-id when the embedding product passes a signed identity (`user_hash`). Votes and chats are attributed to that identity. - **Billing state** — plan, usage counts, Vortex customer id. Payment details live in Vortex Billing, not here. - **Operational data** — API request logs, webhook deliveries, audit events, error telemetry. ## What we do with it - Run the product: issue tracking, support inbox, agent dispatch, capture replay, billing enforcement. - Send transactional email (verification, password reset, invitations, ticket replies, changelog notifications you voted on). - Nothing else. No ads, no selling data, no training models on your content. ## Subprocessors | Vendor | Role | | --------------- | ------------------------------------------------------------ | | Cloudflare | compute, storage (D1/Durable Objects/R2), email routing, DNS | | Vortex | billing (customers, subscriptions, usage rating) | | GitHub / GitLab | only if you connect them; scoped tokens, encrypted at rest | ## Your rights - **Access/export** — `GET /workspaces/{org}/export` returns the full workspace state (D1 metadata + issue store). Or ask us. - **Deletion** — workspace delete purges D1 rows, R2 objects, and Durable Object storage. Members can delete their own account. - **Correction** — update profile/workspace data via the API. - California residents: CCPA rights apply — email privacy@pile.nyc. ## Retention Active workspace data lives until you delete it or the workspace. Operational logs rotate. Backups exist for disaster recovery (RPO ~24h); deleted data ages out of backups on rotation. ## Security - TLS everywhere; D1/DO/R2 encrypted at rest. - Third-party tokens (GitLab PATs, OAuth tokens) AES-256-GCM encrypted under a KEK that never touches the database. - Webhook deliveries are signature-verified; workspace data is gated by per-route permission middleware. - Incidents: see `docs/incident-response.md`. Report issues per `SECURITY.md`. Questions: privacy@pile.nyc. # ===== docs/realtime.md ===== # Realtime Events `GET /workspaces/{org}/realtime` is a WebSocket endpoint that streams `RealtimeEvent` frames for the workspace: `issue.created`, `issue.updated`, `issue.deleted`, `comment.*`, `document.*`, `pr.updated`, and so on. The connection lands on the workspace's Durable Object, so every client sees the same event stream plus outbound webhook deliveries. ```bash # Non-browser clients: normal Bearer auth on the upgrade request. curl -si -N \ -H "Authorization: Bearer $KEY" \ -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $KEY16" \ "$BASE/workspaces/$ORG/realtime" ``` A successful upgrade returns `101 Switching Protocols` and the first frame is `{"type":"connected","organizationId":"..."}`. Non-upgrade requests get `426 UPGRADE_REQUIRED`. ## Browser clients (`?token=`) `window.WebSocket` cannot set request headers, so the realtime endpoints (`/realtime` and the legacy `/ws` alias) also accept the workspace API key as a query parameter: ```js const ws = new WebSocket( `wss://pile.example.com/workspaces/${org}/realtime?token=${key}` ); ``` `?token=` is scoped to those paths only — it is not a general auth mechanism, since URLs end up in logs and browser history. Prefer a short-lived or least-privilege key, and do not embed it in anything linkable. `Authorization: Bearer` still works and takes precedence when both are present. ## Ping / pong Send `{"type":"ping"}`; the DO replies `{"type":"pong"}`. Use it to keep the connection alive or detect a dead peer — hibernating Durable Objects do not emit WebSocket protocol pings on their own. ## Consumers `pile fleet` subscribes to this stream by default: `agent_session.*` events update lane rows directly, `issue.*`/`pr.updated` events refresh issue labels and the PR column, and an event for the selected lane triggers a `/state` fetch for its log tail. The CLI reconnects with exponential backoff and falls back to REST polling while the socket is down (plus a slow periodic resync, since events are not replayed). `pile fleet --poll` forces the old poll-every-`--interval` path for debugging. # ===== docs/runbook-dr.md ===== # Disaster recovery runbook Pile state lives in four places: | Store | What's in it | Backup | | ------------------------- | ---------------------------------------------------------- | ---------------------------------------------- | | `pile-global` (D1) | Orgs, members, auth, tickets, votes, changelog, channels | `wrangler d1 export` (schema + data) | | `WorkspaceDO` (DO SQLite) | Issues, comments, history, attachments refs, notifications | `GET /workspaces/{org}/export` | | `pile-attachments` (R2) | Upload/capture blobs | object manifest; objects are content-addressed | | Config/secrets | `wrangler.toml` (committed) + `wrangler secret` values | secrets live in Veil/1Password, not the dump | ## Cadence `./scripts/backup.sh` — writes `backups//` with `d1-schema.sql`, `d1-data.sql`, `workspace-.json`, `r2-manifest.json`. Run before any migration batch and weekly at minimum. RPO target: 24h (manual until scheduled). RTO target: 4h. ## Restore drill (verified 2026-09-25) ```bash ./scripts/dr-restore.sh pile-dr-restore-test backups/ ``` Creates a fresh D1, applies the schema, then restores data per-table with FK fixpoint retries and verifies row counts against the dump. Verified end-to-end: 39/39 tables, exact row counts. **Why per-table?** `wrangler d1 execute --remote --file` batches statements concurrently, so a monolithic dump fails on FK ordering (`no such table` / `FOREIGN KEY constraint failed`). A single-transaction workaround is impossible — D1 rejects `BEGIN TRANSACTION` remotely. Local verification of a dump is still one step: `sqlite3 restored.db < d1-*.sql && PRAGMA foreign_key_check`. ## Recovery steps 1. **D1** — `dr-restore.sh` into a new database; point `[[d1_databases]].database_id` in `wrangler.toml` at it; deploy. 2. **DO workspaces** — each workspace DO rehydrates from `workspace-.json` via the import path used for workspace migration (`exportState`/`importState` RPC on `WorkspaceDO`). 3. **R2** — objects are content-addressed; re-upload from local copies or re-capture. Missing objects surface as broken attachment links, not data corruption. 4. **Secrets** — re-set `wrangler secret` values (`BETTER_AUTH_SECRET`, etc.) from Veil. 5. **DNS/domain** — `pile.nyc` and `docs.pile.nyc` workers custom domains reattach via zone API. 6. **Queue** — webhook queue is at-least-once with delivery dedupe; replay is idempotent, no action needed. ## Validation after restore - `GET /health` green. - `GET /workspaces/org_vortex_main/issues?limit=5` returns issues. - Spot-check a support ticket thread and the public board. ## Roles Incident lead restores; second reviewer confirms row counts and closes the drill ticket in Pile (`ISS` team). # ===== docs/self-hosting.md ===== # Self-hosting **Managed is the supported path.** Pile is operated at `pile.nyc`; the deployment below is documented for completeness and for running a dev environment — not offered as a supported self-host distribution. If you run it yourself, you own upgrades, secrets, and the container fleet. ## What a deployment is One Cloudflare Worker + bindings (see `wrangler.toml`, which is canonical): | binding | purpose | | ----------------------------- | ------------------------------ | | `D1` (`pile-global`) | global metadata | | `WORKSPACE_DURABLE_OBJECT` | per-workspace issue/session DB | | `SANDBOX*` DOs + 4 containers | lane compute | | `WEBHOOK_QUEUE` | webhook processing | | `ATTACHMENTS_BUCKET` (R2) | attachments/cache | | `EMAIL` | transactional mail | ## Minimal secrets | secret | why | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `BETTER_AUTH_SECRET` | session signing | | `BETTER_AUTH_URL`, `ALLOWED_ORIGINS` | auth + CORS for your domain | | `AGENT_SETTINGS_KEK`, `TOKEN_HASH_SECRET` | at-rest encryption for provider creds | | `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY` | GitHub App (repo access, webhooks, PR ops) | | `GITHUB_WEBHOOK_SECRET` | webhook signature verification | | provider keys (e.g. `DEVIN_TOKEN`) | only if you want deployment-level defaults — workspaces can supply their own via the provider-config API | ## Bring-up ```bash pnpm install pnpm exec wrangler d1 create pile-global # put the id in wrangler.toml pnpm exec wrangler d1 migrations apply pile-global --remote -e production pnpm exec wrangler deploy -e production ``` Then create a workspace through the API and follow `docs/onboarding.md` — the `setup-status` endpoint will flag anything still missing. ## Caveats - The four sandbox container images (`Dockerfile.sandbox*`) must build in the deploy environment; container support requires the appropriate Workers plan. - `devin-cli` lanes additionally need Daytona (`DAYTONA_API_KEY`, snapshot, volume) or Cloudflare containers compute (`COMPUTE_PROVIDER=cloudflare`). - Cron triggers (the agent sweep) are declared in `wrangler.toml` — no separate scheduler to run. # ===== docs/status-page.md ===== # Status page Public status for `pile.nyc` lives at **https://status.pile.nyc** (fallback while the custom domain is pending: https://pile.openstatus.dev). It is hosted on OpenStatus in a separate Pile workspace (page id `5671`). Owner: **Pile**. This is a Pile-internal service with no Vortex dependency. Whoever declares the incident is the incident lead (see `docs/incident-response.md`) and posts updates here until they hand off explicitly. ## Decision: OpenStatus, not a Pile-owned worker | Option | For | Against | | ----------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | **OpenStatus (hosted, open source)** — chosen | Probes run off Cloudflare from 5 regions; zero code; incidents UI + API | Third-party account; one more secret | | Pile worker reading `/health` + incidents table | Fully owned, same stack | Runs on the same Cloudflare account it reports on — a CF or account outage takes the status page down with it | A status page that shares fate with the thing it watches is not a status page. OpenStatus is open source, so self-hosting stays an exit if the hosted plan ever stops fitting. Revisit only if that happens. ## Health check One monitor, `pile.nyc API` (OpenStatus id `11888`): - `GET https://pile.nyc/health`, every **1 minute** - Regions: `iad`, `sjc`, `fra`, `syd`, `nrt` - Passes on HTTP `200` `/health` (`src/platform/health.ts`) probes D1 and the workspace Durable Object, and reports email/compute config presence. It returns `200` for `healthy` **and** `degraded`, and `503` only when every check fails. So the public page shows **hard-down only** — that is intentional for v1: missing email or compute config is an operator problem, not an outage customers need to read about. Note that `degraded` also covers failed D1 or workspace-DO probes, so partial infrastructure outages that _are_ customer-visible can still show green — post those manually per the publishing rules below. Degraded states are otherwise handled on the normal S3 track. `/status` on the API is a 308 to `/health` for scripts; humans go to `status.pile.nyc`. ## Uptime target **99.9% monthly** for the `pile.nyc API` monitor (about 43 minutes of downtime per 30 days). This is an internal objective, not a contractual SLA. Missing it in a month gets a Pile issue with the cause and a fix. ## Publishing incidents Post on the status page whenever an incident is **customer-visible**: | Sev (see `incident-response.md`) | Post? | When | | -------------------------------- | --------------------------------------- | ------------------------------ | | S1 | Yes | Within 30 minutes of declaring | | S2 | Only if customer-visible | Within 1 hour | | S3 | Only if user-facing errors are elevated | When confirmed | Updates follow OpenStatus states — `investigating` → `identified` → `monitoring` → `resolved` — and each one says what users see and what we are doing, in one or two sentences. Post a new update at least every hour until resolved. Never put secrets, workspace names, or customer data on the public page; S1 detail goes in the direct comms from `incident-response.md`. Scheduled maintenance that can cause downtime gets a maintenance window on the page before it starts. ## Access - Dashboard: OpenStatus, Pile workspace. - API key: Veil, entry `openstatus`. - DNS: `status.pile.nyc` is a CNAME to `cname.vercel-dns.com`. Vercel domain verification needs the TXT record shown in OpenStatus → Settings → Custom Domain; until it is added, TLS on `status.pile.nyc` fails and the `pile.openstatus.dev` URL is the one to share.