The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Purl listing page.
Save anything. Ask questions. Get answers.
Live preview: https://purl.nublson.com
Purl is an AI-powered read-it-later app and personal knowledge base. You paste URLs (or upload files): web pages, PDFs, YouTube videos, and audio. Purl ingests the content, stores chunked text with vector embeddings, and answers questions by searching what you saved — optionally scoped with @ mentions to specific items.
Plans: Free, Pro, and BYOK are enforced server-side (see docs/commercial-model.md). New signups get a 7-day Pro trial (no card required). Stripe Checkout handles the one-time Pro payment; webhooks sync status to Postgres.
The product goal: one place to stash material you care about, then query it later with citations instead of digging through bookmarks.
| Feature | Free | Pro ($39 one-time) | BYOK (free) |
|---|---|---|---|
| Save links (100 lifetime cap) | Yes | Unlimited | Unlimited |
| Full-text search | Yes | Yes | Yes |
| AI extraction & embeddings | No | Yes (150/mo) | Yes (unlimited) |
| Semantic search | No | Yes | Yes |
| PDF/audio upload | No | Yes | Yes |
| AI chat | No | 300 msg/mo | Unlimited |
Exact limits are in docs/commercial-model.md.
openai/text-embedding-3-small), stores in Postgres with pgvector; tracks per-link ingest status (pending, processing, completed, failed, skipped for edge cases like heavy SPAs).openai/text-embedding-3-small). OpenAI directly for Whisper transcription only (OPENAI_API_KEY). Keys live in server environment variables only.providerOptions.gateway with the signed-in user id and tags so the Vercel AI dashboard can filter spend and usage by person and surface (feature:chat, env:… from VERCEL_ENV / NODE_ENV; feature:ingest on save pipelines; feature:semantic-search when the model runs vector search over saved chunks).safeFetch with optional proxy/DNS controls (see AGENTS.md). For reliable YouTube transcripts on Vercel, configure SAFE_OUTBOUND_HTTP_PROXY in production.@ mentions to focus the model on specific saved links; mentions persist on messages.src/app/sw.ts) that builds to public/sw.js (generated on pnpm build, gitignored). Enables Install in Chrome/Edge and similar where the platform supports it, with runtime caching via Serwist's Next.js defaults and a static offline shell at /~offline. Serwist is disabled in pnpm dev to avoid service-worker cache surprises during development — use pnpm build && pnpm start (or your production URL) to exercise installability and the SW.Saving a link is synchronous through metadata resolution and the database row; heavy work runs afterward so the API can return quickly.
POST /api/links with a URL, or POST /api/upload with a PDF/audio file (files go to Supabase Storage; the Link stores the public URL).detectContentType (SSRF-safe HEAD / sniff) plus scrapeLinkMetadata (Open Graph HTML, PDF Content-Disposition / size, YouTube oEmbed). Duplicates of the same URL refresh metadata and reset ingestion.Link row is created (default PENDING) with title, favicon, thumbnail, domain, and contentType (WEB, PDF, YOUTUBE, or AUDIO).prepareIngestForLink enforces plan limits, then uses Next.js after() to run the right handler: ingestWeb, ingestPdf, ingestYoutube, or ingestAudio. Free accounts skip extraction (metadata-only; ingest SKIPPED).PROCESSING → fetch or extract plain text → split into chunks (with a synthetic metadata chunk first) → Vercel AI Gateway embeddings (openai/text-embedding-3-small) → replace LinkContent rows and attach pgvector values → COMPLETED. Failures set ingestFailureReason (SCRAPE_FAILED, LINK_NOT_FOUND, OTHER, etc.) alongside FAILED. Re-ingest reuses the same pipeline without re-scraping listing metadata.Web pages (WEB). Article-style HTML is fetched with safeFetch, parsed in jsdom, and the main content is extracted with Mozilla's Readability (scrapeWebContent). That matches how Firefox's reader mode chooses "the article," but it is not universal: many SPAs and other client-rendered sites return a thin HTML shell to crawlers, so Readability finds little or nothing and ingest may FAIL. A small set of hosts that need a full browser are rejected early (UnsupportedSpaError → ingest SKIPPED).
Realtime subscribers get updates when ingestion finishes via notifyLinksAfterIngest (which calls broadcastLinksChanged).
These are called out explicitly because the repo is going public:
Marketing vs. product: The landing page copy mentions ideas such as collections and a weekly digest. Those are not built in the current schema or app — treat them as roadmap, not shipped features.
@serwist/next), web manifest + precache / offline fallbackAutomation lives under .github/workflows/. Every PR and manual release is gated by these pipelines.
pr-checks.ymlRuns on pull_request to develop and main: Setup & validation → Prisma (generate client + type fixes) → Lint and type check (in parallel) → Tests and production build (in parallel, after lint and type check pass). Concurrency is per-PR so new pushes cancel stale runs.
release.ymlRuns on workflow_dispatch (manual): Merge develop into main, then build validation so production is only promoted after a green build.
Purl is built around untrusted input (arbitrary URLs and uploaded files). A few layers matter in production:
fetch. Ingest, OG/thumbnail probes, PDF fetch, content-type sniffing, and similar paths go through safeFetch: HTTP(S) only, blocked private/link-local/reserved targets, redirect handling with per-hop host checks, DNS resolution pinned before connect (mitigates classic DNS rebinding against the pre-check), optional response size caps (e.g. PDF proxy). Optional egress proxy and custom DNS servers are documented in AGENTS.md.proxy redirects unauthenticated users away from private routes and can require email verification before app access./api/chat, /api/links, /api/upload, chats, etc.) resolve the session server-side and scope work to the signed-in user (e.g. chat mention IDs are validated against ownership).AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN after vercel env pull). Whisper transcription still calls OpenAI directly via OPENAI_API_KEY. These keys are never exposed to the client.UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set, the proxy applies per-IP limits to /api/auth/*, POST /api/chat, POST /api/links, and POST /api/upload (see proxy-rate-limit.ts). Without Upstash, limits are disabled — fine locally, not ideal for production.SUPABASE_SERVICE_ROLE_KEY and similar values are server-only. The browser uses the Supabase anon key for Realtime only; .env stays gitignored.safe-outbound-fetch / upload limits in code).Reporting a vulnerability: use GitHub Security Advisories for this repository so details stay private until patched.
Billing uses Stripe Checkout (POST /api/billing/checkout) for the one-time Pro payment, Customer Portal (POST /api/billing/portal), and webhooks (POST /api/billing/webhook). Plan entitlements and usage limits are enforced in-app from Postgres (see src/lib/entitlements.ts); webhooks keep the plan row in sync.
Env (see .env.example): STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY (server-only), STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_PRO (one-time price ID), optional ADMIN_TOKEN for POST /api/admin/grants.
Local webhook testing: create a one-time Pro price in Stripe Test mode, put the price ID in .env, then:
Copy the CLI signing secret into STRIPE_WEBHOOK_SECRET for that shell session. Trigger flows with stripe trigger checkout.session.completed (and exercise checkout from the app). Go-live: recreate the product and a Live webhook endpoint; rotate keys and webhook secret per environment.
pnpm (this repo includes pnpm-lock.yaml)Create a .env file in the repo root. See .env.example for the full list; minimum for core behavior:
Notes:
DATABASE_URL is required (Prisma + Better Auth).AI_GATEWAY_API_KEY (or VERCEL_OIDC_TOKEN on Vercel / after vercel env pull) is required for chat and embeddings (ingest + semantic search) via AI Gateway. Enable AI Gateway in the Vercel project settings for OIDC-based auth. Optional: configure per-user limits in the project AI Gateway settings; the app passes the Better Auth user id on gateway calls.OPENAI_API_KEY is required for Whisper transcription (audio ingest / URLs). Omit only if you do not use audio transcription.RESEND_API_KEY is not set, signup can still work, but verification emails will not send..env.example — copy those keys for a working auth setup.Open http://localhost:3000.
PWA / install: With pnpm dev, the service worker is not active. After a production build, public/sw.js exists locally; run pnpm start and open the app in Chromium to use Install or to test offline navigation to /~offline.
Tests use Vitest and focus on critical logic (formatters, link grouping, auth routing, API behavior, ingest pipeline). They intentionally avoid shallow UI-only wrappers.
More contributor notes (Prisma, Sentry, outbound proxy env): see AGENTS.md.