Publish agent-generated static HTML documents behind stable, shareable links.
AgentPlan is a small service for AI agents (and their humans) that need to turn a generated HTML file — a plan, a report, a dashboard — into a URL. Upload from the browser, the API, or the CLI:
npx agentplan upload ./plan.htmlEvery upload gets a stable link like https://agentplan.app/p/launch-plan-x7k2,
immutable version history, and owner-controlled visibility — private (owner
only, the default), public (anyone with the link), or password (anyone with
the link and the password).
- Next.js (App Router) application deployed on Vercel with the Node.js runtime.
- PlanetScale Postgres with Drizzle ORM for drafts, versions, tokens, and audit events.
- Private Cloudflare R2 bucket (S3-compatible API) for all uploaded HTML — visibility is enforced by the application, never by the storage layer.
- Better Auth with verified email/password and optional GitHub OAuth (offered
only when
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETare set) for browser sessions; scoped API tokens (ap_live_…, stored as SHA-256 hashes) for agents and the CLI. On an empty database, onlyADMIN_BOOTSTRAP_EMAILcan register; that identity becomes the single initial admin. Admins can disable sign-ups, change account plans and roles, delete accounts, and moderate individual uploads under/dashboard/admin. - Uploaded HTML is treated as hostile. It is never rendered into the application DOM;
it is served from an isolated route and displayed inside a sandboxed iframe
(
sandbox="allow-scripts allow-forms allow-modals allow-popups", noallow-same-origin).
Browser ─┐ ┌── PlanetScale Postgres (Drizzle)
├─ Next.js App Router (Vercel) ─┤
CLI / ───┘ • pages + server actions └── Cloudflare R2 (private, S3 API)
Agents • /api/v1 (token auth)
• /p/{slug} sandboxed viewer
Request paths:
- Browser → same-origin API routes and small server actions → draft/token services → Postgres + R2. Upload rate reservations happen before multipart parsing. Authorization uses sessions and owner-scoped queries.
- Agent / CLI →
POST /api/v1/draftswithAuthorization: Bearer ap_live_…→ scope-checked → same services. - Viewer →
/p/{slug}renders an iframe whosesrcis/p/{slug}/content; the content route re-authorizes server-side and streams the HTML from R2.
Key directories:
app/ Routes: landing, dashboard, /p/[slug] viewer, /api/v1, /api/auth
components/ UI (auth buttons, dashboard widgets)
db/ Drizzle schema, pooled client, owner-scoped queries
drizzle/ Generated, committed SQL migrations
lib/
auth/ Better Auth instance + session helpers (requireUser, getOptionalUser)
drafts/ Upload/version/restore service + slug generation
storage/ ObjectStorage interface: R2 (prod) and fs (dev/CI)
tokens/ Token generation, hashing, bearer authentication
api/ Request auth, response envelopes, serializers
validation/ Zod schemas + upload validation
packages/cli/ The `agentplan` CLI (npm workspace)
tests/ unit / security / integration (Vitest) + e2e (Playwright)
Requirements: Node.js 24+, npm, and a Postgres instance (a PlanetScale branch or local Postgres).
npm ci- Copy
.env.exampleto.envand fill in values (PlanetScale URLs,ADMIN_BOOTSTRAP_EMAIL, Better Auth secret, R2 credentials, and optional GitHub OAuth). Email/password development also needs an email webhook; without one, development suppresses delivery and an unverified account cannot sign in. npm run db:migrate(usesDATABASE_URL_DIRECT)npm run dev
For local development without R2 credentials, set STORAGE_DRIVER=fs to store
uploaded HTML on the local filesystem instead. This driver is disabled in
production builds.
npm run check # lint + typecheck + unit/security/integration tests + build
npm run test:e2e # Playwright hostile-HTML browser tests (needs DATABASE_URL)The test suite runs against a real Postgres database. Point TEST_DATABASE_URL
(unit/integration) or DATABASE_URL (e2e) at a disposable branch or local instance;
tests that require a database skip automatically when it is absent.
Install-free usage via npx agentplan, or npm link inside packages/cli for
local development.
agentplan login # store an API token (created in the dashboard)
agentplan logout
agentplan upload ./plan.html # new draft, private by default
agentplan upload ./plan.html --public
agentplan upload ./plan.html --password hunter2 # password-protected (visible in process args)
agentplan upload ./plan.html --password-stdin # safer: read password from piped/redirected stdin
agentplan upload ./plan.html --title "Launch plan"
agentplan upload ./plan.html --draft <id> # add a new version to an existing draft
agentplan upload ./plan.html --json # machine-readable output on stdout
agentplan list [--json]
agentplan open <id>Authentication precedence: AGENTPLAN_TOKEN → stored login → interactive prompt.
Tokens are stored in the OS configuration directory (~/.config/agentplan on Linux/macOS)
with owner-only permissions. --json writes only JSON to stdout; diagnostics go to
stderr; missing or revoked tokens exit non-zero. Custom API endpoints must use HTTPS;
cleartext HTTP is accepted only for localhost. Authenticated requests never follow
redirects, and interactive token entry is hidden.
All routes are under /api/v1 and authenticate with Authorization: Bearer ap_live_…
(agents) or a browser session cookie.
POST /api/v1/drafts (multipart file upload)
GET /api/v1/drafts
GET /api/v1/drafts/:id
PATCH /api/v1/drafts/:id { title?, visibility? }
DELETE /api/v1/drafts/:id
POST /api/v1/drafts/:id/versions (multipart file upload)
GET /api/v1/drafts/:id/versions
POST /api/v1/drafts/:id/versions/:versionId/restore
GET /api/v1/tokens (session only)
POST /api/v1/tokens (session only)
DELETE /api/v1/tokens/:id (session only)
Errors have a stable shape agents can match on:
{ "error": { "code": "INVALID_FILE_TYPE", "message": "Only HTML files are supported." } }Free-plan limits (all server-enforced; tunable via AP_* env vars, defaults in
lib/limits/plans.ts):
| Limit | Default |
|---|---|
| Upload size | 2 MiB per HTML file |
| Drafts per user | 100 |
| Versions kept per draft | 100 (oldest are pruned, uploads never hard-fail) |
| Total storage per user | 250 MiB |
| Active API tokens per user | 25 |
| Uploads per user | 30 / 10 min and 300 / day |
| Token create/revoke operations | 60 / hour and 200 / day |
| Draft password attempts | 10 / 15 min per draft + IP |
Exceeded quotas return 403 QUOTA_EXCEEDED; rate limits return 429 RATE_LIMITED
with a Retry-After header. Rate limiting is a fixed-window counter in Postgres, so
it needs no extra infrastructure and is correct across serverless instances.
Soft-deleted drafts (and their stored objects) are hard-deleted after 7 days by a
daily cron (/api/cron/purge, authorized via CRON_SECRET).
Revoked/expired token rows are removed after 30 days, and ordinary audit events
after 180 days. Pending user-deletion cleanup jobs are retained until object cleanup
completes; their object keys and target identifier are erased at completion.
Admins can switch a user between free and unlimited from the user list.
The CLI remains available for operators (needs DATABASE_URL, loaded from
.env automatically):
bun scripts/set-user-plan.ts someone@example.com unlimited # back to normal: … freeThe admin content view searches live uploads by title, slug, or owner email. Removing an upload makes every viewer and API route return not found immediately; its private objects follow the same 7-day hard-deletion window as an owner-initiated draft deletion.
AgentPlan hosts arbitrary, hostile HTML. See SECURITY.md for the full threat model. In short:
- Uploaded HTML is served only from an isolated route inside a sandboxed iframe, in an opaque origin. Uploaded scripts run, but cannot read AgentPlan cookies, touch the parent DOM, navigate the parent, or make credentialed requests to AgentPlan.
- All HTML lives in a private R2 bucket; visibility is an application-level decision
enforced on every request. Private drafts return
404to non-owners. - Password-protected drafts store a salted scrypt hash of the password. Entering the correct password issues an HMAC-signed, draft-scoped, HttpOnly access cookie (12h); the content route serves the HTML only with a valid cookie (or to the owner) and never caches it publicly. The owner always bypasses the prompt.
- API tokens are stored only as SHA-256 hashes and compared in constant time.
- Protected drafts use random title-independent slugs. Moving a public draft back to private/password rotates its slug.
- Application pages use a per-request nonce CSP; API responses are private/no-store.
- Only
.env.exampleplaceholders are committed; CI scans the working tree and complete reachable history for recognized credential formats.
Production deployment is a deliberate, credentialed step. Runbook:
- Import the public GitHub repo into Vercel; enable Fluid Compute.
- Create a private Cloudflare R2 bucket and an S3 API token scoped to it.
- In PlanetScale, create a restricted application role (not the admin role) and
use its PgBouncer (pooled) URL for
DATABASE_URL; keep the direct URL for migrations only. - Configure production env vars in Vercel:
DATABASE_URL,DATABASE_URL_DIRECT,BETTER_AUTH_SECRET,BETTER_AUTH_URL,ADMIN_BOOTSTRAP_EMAIL,AUTH_EMAIL_WEBHOOK_URL,AUTH_EMAIL_WEBHOOK_SECRET,AUTH_EMAIL_FROM,GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET(optional),NEXT_PUBLIC_APP_URL,R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET,CRON_SECRET(authorizes the daily purge cron). The email webhook receives bearer-authenticatedPOSTJSON with{ kind, to, name, url, from }, must deliver the link without logging it, and return a 2xx response. - Run migrations against production using the direct URL:
npm run db:migrate. Migration0005_security_hardening.sqlrotates every existing non-public draft slug, intentionally invalidating previously shared private/password URLs. - Deploy, verify auth on the Vercel domain, then attach
agentplan.appand redirectwww→ apex. - In every deployment, update
BETTER_AUTH_URL/NEXT_PUBLIC_APP_URL. If GitHub OAuth is enabled, also set its callback tohttps://agentplan.app/api/auth/callback/github; redeploy. - Verify public/private behavior, email verification/reset delivery, API
private/no-store headers,
/.well-known/security.txt, and the nonce CSP on the final domain.
Choose PlanetScale and Vercel regions in the same geography where possible.