Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ jobs:
bun build apps/hook/server/index.ts --compile --target=bun-windows-x64 --outfile plannotator-win32-x64.exe
sha256sum plannotator-win32-x64.exe > plannotator-win32-x64.exe.sha256

# Paste service binaries
bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-arm64 --outfile plannotator-paste-darwin-arm64
sha256sum plannotator-paste-darwin-arm64 > plannotator-paste-darwin-arm64.sha256

bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-x64 --outfile plannotator-paste-darwin-x64
sha256sum plannotator-paste-darwin-x64 > plannotator-paste-darwin-x64.sha256

bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-x64 --outfile plannotator-paste-linux-x64
sha256sum plannotator-paste-linux-x64 > plannotator-paste-linux-x64.sha256

bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-arm64 --outfile plannotator-paste-linux-arm64
sha256sum plannotator-paste-linux-arm64 > plannotator-paste-linux-arm64.sha256

bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-x64 --outfile plannotator-paste-win32-x64.exe
sha256sum plannotator-paste-win32-x64.exe > plannotator-paste-win32-x64.exe.sha256

- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
Expand Down
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ plannotator/
│ │ └── review-editor.html # Built code review app
│ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai)
│ │ └── astro.config.mjs # Astro 5 static site with content collections
│ ├── paste-service/ # Paste service for short URL sharing
│ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors)
│ │ ├── stores/ # Storage backends (fs, kv, s3)
│ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts)
│ └── review/ # Standalone review server (for development)
│ ├── index.html
│ ├── index.tsx
Expand All @@ -30,6 +34,7 @@ plannotator/
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
│ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.)
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── integrations.ts # Obsidian, Bear integrations
Expand Down Expand Up @@ -73,6 +78,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://paste.plannotator.ai`. |

**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control.

Expand Down Expand Up @@ -170,6 +176,15 @@ Send Annotations → feedback sent to agent session

All servers use random ports locally or fixed port (`19432`) in remote mode.

### Paste Service (`apps/paste-service/`)

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/paste` | POST | Store compressed plan data, returns `{ id }` |
| `/api/paste/:id` | GET | Retrieve stored compressed data |

Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted).

## Plan Version History

Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version).
Expand Down Expand Up @@ -262,7 +277,7 @@ Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>

**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`

Shares full plan + annotations via URL hash using deflate compression.
Shares full plan + annotations via URL hash using deflate compression. For large plans, short URLs are created via the paste service (user must explicitly confirm).

**Payload format:**

Expand Down
27 changes: 24 additions & 3 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand All @@ -55,6 +56,9 @@ const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled";
// Custom share portal URL for self-hosting
const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined;

// Paste service URL for short URL sharing
const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;

if (args[0] === "review") {
// ============================================
// CODE REVIEW MODE
Expand All @@ -79,7 +83,13 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: reviewHtmlContent,
onReady: handleReviewServerReady,
onReady: async (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);

if (isRemote && sharingEnabled && rawPatch) {
await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {});
}
},
});

// Wait for user feedback
Expand Down Expand Up @@ -126,7 +136,13 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: planHtmlContent,
onReady: handleAnnotateServerReady,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);

if (isRemote && sharingEnabled) {
await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {});
}
},
});

// Wait for user feedback
Expand Down Expand Up @@ -173,9 +189,14 @@ if (args[0] === "review") {
permissionMode,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
htmlContent: planHtmlContent,
onReady: (url, isRemote, port) => {
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);

if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
}
},
});

Expand Down
79 changes: 64 additions & 15 deletions apps/marketing/src/content/docs/guides/self-hosting.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
---
title: "Self-Hosting"
description: "Self-host the Plannotator share portal for private plan sharing."
description: "Deploy and self-host the full Plannotator system — hook, share portal, and paste service."
sidebar:
order: 23
section: "Guides"
---

The share portal is a static single-page application. It has no backend, no database, and makes no network requests. All plan data is encoded in the URL hash.
Plannotator has three components. Only the hook is required.

## Build
## Components

| Component | Required | What it does |
|-----------|----------|--------------|
| Hook | Yes | Local binary that intercepts `ExitPlanMode`, runs the review UI |
| Share Portal | Optional | Static site that renders shared plans. When you open a share link, this is what loads in your browser. |
| Paste Service | Optional | Storage backend for the share portal. When a plan is too large for a URL, the paste service holds the compressed data and the portal fetches it on load. |

### How sharing works

Small plans are encoded entirely in the URL hash — the share portal reads the hash and renders the plan. No backend involved. The data remains private — it never leaves the URL.

Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the compressed plan is sent to the paste service, which stores it and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. The share URL becomes `share.plannotator.ai/p/aBcDeFgH` (or `your-portal.example.com/p/aBcDeFgH` if self-hosting). When someone opens that link, the portal fetches the compressed data from the paste service, decompresses it, and renders the plan.

**Without paste service:** Sharing still works for plans that fit in a URL. Those plans stay completely private — the data lives only in the URL hash and never touches a server. Large plans show a warning that the URL may be truncated by messaging apps.

**With paste service:** Large plans get short, reliable URLs that work everywhere. Plannotator temporarily stores the compressed plan data — it auto-deletes after the configured TTL.

## 1. Install the Hook

See [Installation](/docs/getting-started/installation/) for hook setup instructions.

## 2. Deploy the Share Portal

The share portal is a static single-page application. It has no backend, no database, and makes no network requests beyond fetching paste data for short URLs.

### Build

```bash
bun install
Expand All @@ -17,11 +43,11 @@ bun run build:portal

Output: `apps/portal/dist/`

## Deploy
### Deploy

Upload the `dist/` folder to any static hosting provider.

### Nginx
#### Nginx

```nginx
server {
Expand All @@ -32,35 +58,58 @@ server {
}
```

### AWS S3 + CloudFront
#### AWS S3 + CloudFront

```bash
aws s3 sync apps/portal/dist/ s3://your-bucket/ --delete
```

Configure the CloudFront distribution to return `/index.html` for 404s (SPA routing).

### Vercel / Netlify / Cloudflare Pages
#### Vercel / Netlify / Cloudflare Pages

Point to the repository root:
- **Build command**: `bun run build:portal`
- **Output directory**: `apps/portal/dist`

## Configure Plannotator
## 3. Deploy the Paste Service

The paste service accepts compressed plan data and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. Pastes auto-delete after the configured TTL. No database required.

The paste service is fully open source — the same codebase you're looking at.

Set the `PLANNOTATOR_SHARE_URL` environment variable to your portal's URL:
### Run the binary

Download the paste service binary for your platform from [GitHub Releases](https://github.com/backnotprop/plannotator/releases). Binaries are available for macOS (ARM64, x64), Linux (x64, ARM64), and Windows (x64).

```bash
export PLANNOTATOR_SHARE_URL=https://plannotator.internal.example.com
chmod +x plannotator-paste-*
./plannotator-paste-darwin-arm64 # or whichever matches your platform
```

All share links generated by Plannotator will now point to your self-hosted portal. The import dialog placeholder updates automatically.
Pastes stored to `~/.plannotator/pastes/` by default.

### Configuration

When `PLANNOTATOR_SHARE_URL` is not set, the default `https://share.plannotator.ai` is used.
| Variable | Default | Description |
|----------|---------|-------------|
| `PASTE_PORT` | `19433` | Server port |
| `PASTE_DATA_DIR` | `~/.plannotator/pastes` | Storage directory |
| `PASTE_TTL_DAYS` | `7` | Auto-delete after N days |
| `PASTE_MAX_SIZE` | `524288` | Max payload size (512KB) |
| `PASTE_ALLOWED_ORIGINS` | (see defaults) | CORS allowed origins |

## 4. Connect the Components

```bash
export PLANNOTATOR_SHARE_URL=https://your-portal.example.com
export PLANNOTATOR_PASTE_URL=https://your-paste.example.com
```

## Verify
## 5. Verify

1. Start a plan review in Claude Code or OpenCode
2. Add an annotation, click Export → Share
2. Add annotations, click **Export****Share**
3. Confirm the share URL starts with your configured domain
4. Open the link — the plan and annotations should render correctly
4. If the plan is large, click **Create short link** when prompted
5. Open the short URL — the plan should render correctly
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
title: "Sharing & Collaboration"
description: "Share plans and annotations via URL — no backend required."
description: "Share plans and annotations via URL with optional short links for large plans."
sidebar:
order: 21
section: "Guides"
---

Plannotator lets you share plans and annotations with teammates via URL. All data is encoded in the URL hash — no backend, no accounts, no server stores anything.
Plannotator lets you share plans and annotations with teammates via URL. Small plans are encoded entirely in the URL hash — no backend, no accounts, no server stores anything. Large plans can optionally use short URLs via the [paste service](/docs/guides/self-hosting/#3-deploy-the-paste-service).

## How sharing works

Expand Down Expand Up @@ -57,13 +57,34 @@ When sharing is disabled:
- The "Copy Share Link" quick action is removed
- The Import Review option is hidden

## Short URLs for large plans

When a plan is too large for a URL (~2KB+ compressed), messaging apps like Slack and WhatsApp may truncate it. Plannotator can create a short link by temporarily storing the compressed plan in a paste service.

### How it works

1. Click **Export** → **Share**
2. If the URL is large, you'll see a notice: "This plan is too large for a URL"
3. Click **Create short link** to confirm
4. The compressed plan is temporarily stored, then automatically deleted after the configured TTL
5. A short URL like `share.plannotator.ai/p/aBcDeFgH` is generated
6. Both the short URL and the full hash URL are shown — the short URL is safe for messaging apps

### Privacy

- Plans are only uploaded when you explicitly click "Create short link" — no data leaves your machine until you confirm
- Pastes auto-expire and are permanently deleted (hosted: a few days, self-hosted: configurable via `PASTE_TTL_DAYS`)
- The paste service is fully open source — you can audit exactly what it does
- Self-hosters can run their own paste service for complete control — see the [self-hosting guide](/docs/guides/self-hosting/)
- If the paste service is unavailable, the full hash URL is always available as fallback

## Self-hosting the share portal

By default, share URLs point to `https://share.plannotator.ai`. You can self-host the portal and point Plannotator at your instance. See the [self-hosting guide](/docs/guides/self-hosting/) for details.

## Privacy model

- Plans and annotations are never sent to any server
- The share portal is a static page — it only reads the URL hash client-side
- Plans and annotations are never sent to any server — the data lives entirely in the URL hash
- The share portal is a static page — it only reads the hash and renders client-side
- No analytics, no tracking, no cookies on the share portal
- If you self-host, you have complete control over the infrastructure
- Short URLs are opt-in — data is only uploaded when you explicitly click "Create short link" (see [Short URLs for large plans](#short-urls-for-large-plans) for details)
39 changes: 39 additions & 0 deletions apps/marketing/src/content/docs/reference/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,42 @@ Body:
"annotations": []
}
```

## Paste service

Stores compressed plan data for short URL sharing. Runs as a separate service from the plan/review/annotate servers.

Default: `https://paste.plannotator.ai` (or self-hosted)

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/paste` | POST | Store compressed plan data, returns `{ id }` |
| `/api/paste/:id` | GET | Retrieve stored compressed data |

### POST `/api/paste`

Body:

```json
{
"data": "<compressed base64 string>"
}
```

Returns: `{ "id": "aBcDeFgH" }` (201 Created)

Limits: 512KB max payload. Auto-deleted after configured TTL (default: 7 days).

### GET `/api/paste/:id`

Returns:

```json
{
"data": "<compressed base64 string>"
}
```

Or: `{ "error": "Paste not found or expired" }` (404)

Cached for 1 hour (`Cache-Control: public, max-age=3600`).
18 changes: 18 additions & 0 deletions apps/marketing/src/content/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ All Plannotator environment variables and their defaults.
| `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off sharing. Hides share UI and import options. |
| `PLANNOTATOR_SHARE_URL` | `https://share.plannotator.ai` | Base URL for share links. Set this when self-hosting the share portal. |

## Paste service variables

| Variable | Default | Description |
|----------|---------|-------------|
| `PLANNOTATOR_PASTE_URL` | `https://paste.plannotator.ai` | Base URL of the paste service API. Set this when self-hosting the paste service. |

### Self-hosted paste service

When running your own paste service binary, these variables configure it:

| Variable | Default | Description |
|----------|---------|-------------|
| `PASTE_PORT` | `19433` | Server port |
| `PASTE_DATA_DIR` | `~/.plannotator/pastes` | Filesystem storage directory |
| `PASTE_TTL_DAYS` | `7` | Paste expiration in days |
| `PASTE_MAX_SIZE` | `524288` | Max payload size in bytes (512KB) |
| `PASTE_ALLOWED_ORIGINS` | `https://share.plannotator.ai,http://localhost:3001` | CORS allowed origins (comma-separated) |

## Install script variables

| Variable | Default | Description |
Expand Down
Loading