# Shane's Periodic Table of Life: full developer docs Generated from https://shanejli.com/docs. Index: https://shanejli.com/llms.txt --- # Overview shanejli.com is Shane (Juntao) Li's personal site: a periodic table of small apps ("elements"), all backed by one public JSON API. These docs exist so AI agents can use that API without reading the source. ## Architecture - Frontend: Next.js 15 App Router on Vercel, at `https://shanejli.com`. - Backend: Hono on Bun on Railway, at `https://shanebackend-production.up.railway.app`. One PostgreSQL database. - AI features (classification, enrichment) run server-side through a Claude-first LLM chain with free-tier fallbacks. ## Base URLs Both of these reach the same backend: - `https://shanebackend-production.up.railway.app/api/...` (canonical) - `https://shanejli.com/api/...` (rewrite; handy for relative URLs on the site's own pages) ## Reads are public, writes need a token Almost every GET is unauthenticated. Writes require a bearer token: either a browser session JWT or a personal access token (PAT) with the right scope. See [Auth and Tokens](/docs/auth). ## These docs | Endpoint | What you get | |---|---| | `/llms.txt` | index of these pages (llmstxt.org convention) | | `/llms-full.txt` | every page concatenated, one fetch | | `/docs` | human-readable index | | `/docs/` | rendered page | | `/docs/raw/` | the same page as raw markdown | ## Repos - `github.com/shane1595042264/shaneFrontend` (Next.js, Turborepo) - `github.com/shane1595042264/shaneBackend` (Hono, Drizzle ORM) Docs source of truth lives in `shaneFrontend/apps/shell/lib/docs/` and ships with each deploy. --- # Auth and Tokens One bearer header, two credential kinds. The backend branches on the `pat_` prefix. ## Credential kinds - **Session JWT**: minted by Google OAuth in the browser (sign in at shanejli.com). Full power: bypasses all scope checks and all per-minute rate limits. - **Personal access token (PAT)**: `pat_` + 32 random bytes base64url. Scoped and rate limited. This is what agents should use. Send either as `Authorization: Bearer `. ## Minting a PAT Easiest: sign in and mint at `https://shanejli.com/settings/tokens`. Programmatic (requires a session JWT; a PAT can never mint another PAT, by design): ```bash curl -X POST https://shanebackend-production.up.railway.app/api/auth/tokens \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"name":"my-agent","scopes":["entries:write","comments:write"]}' # 201 {"id":"","token":"pat_..."} raw token shown exactly once ``` - List: `GET /api/auth/tokens`. Revoke: `DELETE /api/auth/tokens/:id` (204). - PATs never expire; revocation is the only kill switch. - Who am I: `GET /api/auth/me` returns your user object for either credential kind. ## Scopes `entries:write`, `suggestions:write`, `comments:write`, `reactions:write`, `knowledge:write`, `trips:write`, `practice:write` A PAT without the required scope gets 403 `Token missing required scope: `. Some routes need only authentication, no scope (for example journal image uploads and the loans module). ## Rate limits (PATs only, rolling 60s, 429 + Retry-After) | Bucket | Limit/min | Covers | |---|---|---| | journal-entries-write | 30 | entry create, append, revert, trash | | journal-suggestions-write | 30 | suggest, approve, reject, withdraw | | journal-comments-write | 30 | journal comments | | journal-reactions-write | 60 | journal reactions | | courses-write | 60 | all courses writes | | scoreboard-write | 60 | all scoreboard writes | | tea-entries-write | 30 | tea writes | | skincare-write | 60 | skincare writes | | knowledge-notes-single / -batch | 30 / 5 | note ingest | | knowledge-entries-write | 30 | knowledge entry writes | | vocabulary-writes / vocabulary-enrich | 30 / 10 | vocabulary writes / enrich | | practice-* | 30 (sync and vocab reviews 120) | practice writes | | rng-capitalist-evaluate / -plaid | 10 / 10 | rng evaluate / Plaid | Journal image uploads have a separate 100 per rolling 24h per-user quota (applies to JWTs too) with an honest `Retry-After`. ## Admin-gated surfaces A few routes are JWT-plus-admin-email only (practice settings PATCH, activity ingest). PATs always get 403 there; agents should skip them. --- # API Conventions One page of rules shared by every module, so the per-module docs stay short. ## Base URLs - Backend origin: `https://shanebackend-production.up.railway.app` - `https://shanejli.com/api/*` rewrites to the same backend (afterFiles rewrite), so relative `/api/...` URLs work from the site's own pages. ## Errors Every error is JSON `{"error": "message"}`. Validation failures (bad params, query, or body) return 400 with the zod error object. Unhandled throws return 500 `{"error":"Internal server error"}`. ## Auth posture - Missing/invalid token on a protected route: 401. - PAT lacking the required scope: 403 `Token missing required scope: `. - A row that exists but is not yours: usually 404, not 403 (deliberately indistinct; loans is the exception and 403s). ## Wire format - JSON bodies. Request field names are camelCase in most modules; the journal and comment surfaces use snake_case for multi-word request fields (`parent_comment_id`, `base_version_num`, `target_version_num`). Responses are camelCase everywhere. - Dates that key resources are `YYYY-MM-DD` strings, validated as real calendar dates (2026-02-30 is a 400, not a 500). - Free-text fields are trimmed before validation; whitespace-only input is a 400. ## Pagination Keyset, newest-first, via `limit` (1 to 100) and `cursor`. The cursor value differs by module: the journal cursor is the last entry's DATE (`YYYY-MM-DD`); trips, loans, tea, scoreboard matches, and rng history use a `createdAt` ISO timestamp. Read `nextCursor` from each response; null means done. ## Optimistic concurrency (If-Match) Racy journal mutations (revert, suggestion approve) require an `If-Match` header carrying the entry's current version number as a plain integer (from `GET /api/journal/entries/:date`, field `currentVersionNum`). - Missing header: 428 - Non-numeric: 400 - Stale: 409 with `{"error":"Version conflict","currentVersionNum":}` so you can rebase and retry. ## Rate limits Per-PAT rolling 60 second buckets (JWT browser sessions bypass); see the bucket table in [Auth and Tokens](/docs/auth). 429 responses carry `Retry-After`. ## CORS Allowed request headers are `Content-Type`, `Authorization`, `If-Match`, `X-Tea-Pin`. A new custom header needs a backend change; the symptom of forgetting is a browser-only "Failed to fetch". --- # Journal API The collaborative wiki-journal at /journal. Mounted at `/api/journal`. Verified against production 2026-08-31. ## Semantics you must know first - One entry per calendar date, site-wide. The first poster becomes the permanent author. - Entry bodies are append-only. There is NO edit endpoint for anyone, author included; `PATCH /entries/:date` always returns 405. Content changes only via appends, approved suggestions, or revert. - Authors append; non-authors suggest. An author gets 403 trying to suggest on their own entry, a non-author gets 403 trying to append. - Trashing an entry (`DELETE /entries/:date`, author only, soft) has no undo and the date still 409s on re-create. Do not create test entries on dates you care about. - Appends and versions are immutable forever. Comments are the only hard delete. ## Quickstart ```bash B=https://shanebackend-production.up.railway.app # needs scope entries:write curl -X POST $B/api/journal/entries \ -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \ -d '{"date":"2026-09-01","content":"# Title\n\nGFM markdown body..."}' # 201 {entry, currentVersionNum: 1} | 409 date taken ``` ## Endpoints Reads (public): | Method | Path | Notes | |---|---|---| | GET | /entries | `?limit=1..100&cursor=YYYY-MM-DD&from=&to=&q=`; `q` is case-insensitive substring over body and appends; returns `{entries, nextCursor}` | | GET | /entries/:date | `{entry, author, content, currentVersionNum, appends}`; the If-Match seed | | GET | /entries/:date/versions | full content per version | | GET | /entries/:date/versions/:num | one version | | GET | /entries/:date/appends | append timeline | | GET | /entries/:date/neighbors | `{prev, next}` published dates | | GET | /entries/:date/suggestions | `?status=pending\|approved\|rejected\|withdrawn` | | GET | /suggestions/:id | one suggestion | | GET | /entries/:date/comments | with author objects | | GET | /entries/:date/reactions | `{summary, mine}` | | GET | /comments/:id/reactions | `{summary, mine}` | Writes: | Method | Path | Scope | Body | Key errors | |---|---|---|---|---| | POST | /entries | entries:write | `{date, content}` (content trimmed 1..100k) | 409 date taken | | POST | /entries/:date/appends | entries:write | `{content}` 1..100k | 403 not author | | POST | /entries/:date/revert | entries:write + If-Match | `{target_version_num}` | 428/400/409 If-Match; 403 not author; nonexistent target currently 500s, check /versions first | | DELETE | /entries/:date | entries:write | none | 404 not author; irreversible | | POST | /entries/:date/suggestions | suggestions:write | `{base_version_num, proposed_content}` (full replacement, no diff format) | 403 if you are the author | | PATCH | /suggestions/:id/approve | suggestions:write + If-Match | none | 403 not entry author; non-pending currently 500s | | PATCH | /suggestions/:id/reject | suggestions:write | `{reason?}` max 2000 | 403 not entry author; non-pending currently 500s | | PATCH | /suggestions/:id/withdraw | suggestions:write | none | proposer + pending only; all failures are 403 | | GET | /inbox | auth only | none | pending suggestions on entries you author | | POST | /entries/:date/comments | comments:write | `{content, parent_comment_id?}` 1..10k | one reply level renders | | PATCH | /comments/:id | comments:write | `{content}` | author only | | DELETE | /comments/:id | comments:write | none | comment author or entry author; 204 | | POST | /entries/:date/reactions | reactions:write | `{emoji}` | toggle; shortcodes only | | POST | /comments/:id/reactions | reactions:write | `{emoji}` | toggle | Reaction shortcode allowlist: `+1 -1 laugh heart hooray rocket eyes confused` (raw unicode is 400 Invalid emoji). ## Content rules - Markdown is CommonMark + GFM (tables, task lists, strikethrough, autolinks, footnotes, fenced code with language tag but no highlighting). - Raw HTML is silently stripped. No math rendering. - Fenced ```mermaid blocks render as diagrams in entry bodies and appends (client-side; SSR, feeds, and comments show the raw code; invalid mermaid falls back to the code block with an error note). For other diagram tools see [Images API](/docs/images-api). - Bodies containing an in-flight editor upload placeholder (`uploading-...` image token) are 400. ## Freshness The API reflects writes instantly. Site pages are ISR-cached: a brand-new date page appears immediately, the /journal index and already-cached pages lag up to ~5 minutes, feeds and OG images up to 1 hour. There is no revalidation hook. --- # Images API Journal image storage, used to embed images (and rendered diagrams) in any markdown surface on the site. ## Upload `POST /api/journal/images` with any authenticated token (JWT or any PAT, no scope needed). ```bash curl -X POST https://shanebackend-production.up.railway.app/api/journal/images \ -H "Authorization: Bearer $PAT" \ -F "file=@diagram.png;type=image/png" # 201 {"id":"","url":"/api/journal/images/"} ``` Rules: - Multipart field name must be `file`. - The server sniffs magic bytes and ignores your Content-Type. Accepted: png, jpeg, gif, webp. SVG is deliberately rejected (XSS vector): 415. - Max 5MB: 413. Empty or missing file: 400. - Quota: 100 uploads per user per rolling 24h: 429 with an honest `Retry-After` (seconds until the oldest upload ages out). ## Serving `GET /api/journal/images/:id` is fully public, streams the stored bytes with the sniffed Content-Type and `Cache-Control: public, max-age=31536000, immutable`. There is no delete endpoint. ## Embedding Embed the absolute backend URL in markdown, which is what the site's own editor does: ```markdown ![architecture](https://shanebackend-production.up.railway.app/api/journal/images/) ``` The relative form `/api/journal/images/` also renders on shanejli.com pages (rewrite), but breaks in feeds and external readers; prefer absolute. ## Diagrams Journal entry bodies and appends render fenced ```mermaid code blocks natively, so prefer a mermaid block there. Upload rendered images instead when the diagram must show in comments or RSS/JSON feeds, or when it comes from a non-mermaid tool (graphviz, matplotlib): export PNG under 5MB, upload here, embed the URL. --- # Courses API The course catalog at /courses. Mounted at `/api/courses`. A course is an external interactive-lecture URL plus AI-extracted metadata, ratings, and comments. Shipped 2026-08-31 (SHAN-437). ## Model - Categories: `math physics computer-science engineering biology chemistry history economics philosophy language art music other` - Difficulties: `intro intermediate advanced` - `coverUrl` is null until a cover is uploaded; the site renders a generated cover in that case. ## Reads (public) | Method | Path | Notes | |---|---|---| | GET | / | `{courses}` newest first with `rating: {average, count}`, `commentCount`, `myStars` when authed | | GET | /:slug | one course, same shape | | GET | /covers/:courseId | cover bytes, public, immutable cache | | GET | /:id/comments | comments with author objects | ## Writes All writes share PAT bucket `courses-write` (60/min). Course mutations are owner-scoped (404 for non-owners); ratings and comments are open to any signed-in user. | Method | Path | Scope | Body | Notes | |---|---|---|---|---| | POST | / | entries:write | `{url, title?}` | Fetches the page server-side and classifies it with an LLM. 502 if the URL is unreachable; classification failure falls back to safe defaults (never blocks). 409 duplicate url. Slug generated from title, stable forever | | PATCH | /:id | entries:write | any of title, description, category, difficulty, durationMinutes, tags (max 8), url | 409 on url clash | | POST | /:id/reclassify | entries:write | none | Re-runs the AI; overwrites description/category/difficulty/duration/tags but PRESERVES title and slug | | DELETE | /:id | entries:write | none | 204, cascades ratings and comments | | PUT | /:id/cover | entries:write | multipart `file` | Same image ladder as journal: sniffed png/jpeg/gif/webp, 5MB 413, 415 otherwise | | DELETE | /:id/cover | entries:write | none | back to the generated cover | | PUT | /:id/rating | reactions:write | `{stars}` int 1..5 | Upsert per (user, course); returns `{rating:{average, count, mine}}` | | DELETE | /:id/rating | reactions:write | none | clears yours, returns fresh aggregate | | POST | /:id/comments | comments:write | `{content, parent_comment_id?}` 1..10k | markdown, one reply level | | PATCH | /comments/:id | comments:write | `{content}` | author only | | DELETE | /comments/:id | comments:write | none | comment author or course owner; 204 | ## Example ```bash curl -X POST https://shanebackend-production.up.railway.app/api/courses \ -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \ -d '{"url":"https://supermassive-courses-production.up.railway.app/courses/pi2-heist/"}' # 201 with AI-filled category/difficulty/duration/tags (allow ~10s) ``` --- # Trips API A public HTML pastebin for trip itineraries at /trips. Mounted at `/api/trips`. Wire fields are camelCase. ## Trips | Method | Path | Auth | Body | Notes | |---|---|---|---|---| | POST | / | optional (anonymous OK) | JSON `{html (1..10MB), title?, filename?}` OR multipart `file` (.html, 10MB) + `title?` | 201 `{trip:{id, slug, title, ...}}`; authed upload stamps ownerId, anonymous is ownerless | | GET | / | public | `?limit=1..100&cursor=` | metadata only, no html | | GET | /:slug | public | none | full row including html | | PATCH | /:slug | optional + trips:write (PATs) | any of html/title/filename, or multipart | 403 if owned by someone else; anonymous trips are editable by anyone; slug never changes | | DELETE | /:slug | optional + trips:write (PATs) | none | 204; same ownership rule | Gotchas: - Title precedence on create: body title, then the html `` tag, then first h1, then cleaned filename. - `trips:write` only gates PATs; browser JWTs and anonymous callers pass the scope check. The real protection is per-trip ownership. - Slug is kebab-cased from the title (max 60 chars) with a random suffix on collision. - No rate limiting on this module. Cursor is a createdAt timestamp, unlike the journal's date cursor. ## Trip Groups (`/api/trip-groups`) Group trip planning. Everything requires a browser session (requireAuth; no PAT scope exists for this module), except raw photo bytes which are public. Membership is join-by-slug: `POST /:slug/join`. Highlights (all under `/api/trip-groups/:slug` unless noted): - `POST /api/trip-groups` `{title}`: create, caller becomes owner. `GET /api/trip-groups`: your groups. - Ideas: `POST /ideas` `{body}` (1..4000), delete is author-only. - `POST /itinerary/consolidate`: LLM call turning ideas into a structured itinerary. Owner writes directly (200); a non-owner member creates a pending suggestion (201). 502 when LLM providers are down. - `PUT /itinerary`: manual structured edit (same owner/member fork). `DELETE /itinerary`: owner-only reset. - Suggestions: `GET /itinerary/suggestions` (with conflict detection), owner-only approve/reject, race-safe 409 when already resolved. - Photos: multipart upload per day (5MB, sniffed types), public raw bytes at `.../photos/:photoId/raw`, owner-only `unsplash-fill`. - Notes and todo sections: member CRUD with creator-or-owner deletes. - `POST /itinerary/export-calendar`: exports to the CALLER's Google Calendar; 409 `calendar_not_connected` until they connect. Agents without a browser session cannot use trip-groups; use the plain trips API instead. --- <!-- source: https://shanejli.com/docs/knowledge-api --> # Knowledge API Free-text note ingest with AI classification, plus structured entries, connections, and comments. Mounted at `/api/knowledge`. The vocabulary module (`/api/vocabulary`) writes the same underlying table with a narrower surface. ## Note ingest (the interesting endpoint) `POST /api/knowledge/notes` with scope `knowledge:write`. Three accepted body shapes (strict: unknown keys are 400): ```json {"text": "today I learned gracias means thank you in Spanish"} {"text": "...", "source": "Nibbler"} {"notes": [{"text": "...", "source": {"book": "War and Peace", "location": "ch. 3"}}]} ``` - `text` trimmed 1..5000. `source` is a string (becomes `{app}`) or an object with app/book/author/location/rawContext. - Single: 201 `{entries:[entry]}`. Batch (1..50): 201 all-ok, **207** `{entries, failures:[{index, text, error}]}` on partial failure, **502** when every note failed or the LLM chain is exhausted (retryable). - Caller-supplied source fields beat classifier-extracted ones. No cross-request dedup by design; within one batch, duplicate (word, language) pairs are silently dropped. - Rate: 30/min single, 5/min batch (separate buckets). ## Entries | Method | Path | Auth | Notes | |---|---|---|---| | GET | /entries | public | `?language=&label=&search=&category=&app=&limit=1..500&offset=`; returns `{entries, total, limit, offset}` (offset pagination, unlike most modules) | | GET | /entries/:id | public | `{entry, connections, connectedEntries}` | | POST | /entries | knowledge:write | word + language required; 409 `{error, existingEntry}` on (word, language, category) duplicate; auto-enriches vocabulary entries via LLM unless `autoEnrich:false` (enrich failure never blocks) | | PUT | /entries/:id | auth only | owner-only (legacy ownerless rows editable by anyone authed); `memorizationLocations` feeds the long-term-memorized derivation | | DELETE | /entries/:id | auth only | same ownership rule | | POST | /entries/bulk-delete | auth only | `{ids: [1..100]}`; always 200 with per-id `{deleted, denied, notFound}` | | POST | /entries/:id/enrich | knowledge:write | re-run AI enrichment; 502 on LLM exhaustion | Connections (synonym, antonym, related, translation, root): `GET/POST /connections`, `DELETE /connections/:id`. The knowledge-module versions do NOT check word ownership; the vocabulary-module twins DO (403 unless you own both words). Pick the path matching the permission behavior you want. Comments: same shape as journal comments (`GET/POST /entries/:id/comments`, `PATCH/DELETE /comments/:id`, scope `comments:write`, snake_case `parent_comment_id`, one reply level). ## Vocabulary module differences - `GET /api/vocabulary/words` search matches the word column only; list key is `{words}` not `{entries}`. - Duplicate check is (word, language) with no category dimension. - All writes use scope `knowledge:write` (there is no vocabulary:write), bucket `vocabulary-writes` 30/min, enrich 10/min. --- <!-- source: https://shanejli.com/docs/elements-directory --> # Elements Directory Every element on the periodic table, its route, backend mount, and auth model, in one table. "Public reads" means unauthenticated GETs; writes always need a token unless noted. | Element | Route | Backend mount | Auth model | What it is | |---|---|---|---|---| | Journal | /journal | /api/journal | public reads; scoped writes | collaborative wiki-journal, see [Journal API](/docs/journal-api) | | Documentation | /docs | (frontend only) | fully public | these docs; /llms.txt, /llms-full.txt | | Courses | /courses | /api/courses | public reads; scoped writes | AI-classified course catalog, see [Courses API](/docs/courses-api) | | Trips | /trips | /api/trips | public reads AND anonymous create | trip itinerary pastebin, see [Trips API](/docs/trips-api) | | Knowledge | /knowledge | /api/knowledge | public reads; scoped writes | AI-classified knowledge base, see [Knowledge API](/docs/knowledge-api) | | Vocabulary | /vocabulary | /api/vocabulary | public reads; knowledge:write writes | narrower view of the same data | | Scoreboard | /scoreboard | /api/scoreboard | public reads; entries:write writes | IRL game scoreboard (games, players, live matches) | | Tea | /journal/tea | /api/tea-entries | authed author; PIN-gated shares | private entries unlocked per-entry via the X-Tea-Pin header | | Skincare | /skincare | /api/skincare | authed only, owner-scoped | AM/PM routine tracker | | Practice | /practice | /api/practice | authed, practice:write writes | training sessions + vocab SRS | | RNG Capitalist | /rng-capitalist | /api/rng | authed (any PAT), no scope | AI purchase-decision roulette; evaluate costs an LLM call, 10/min | | Who Owes Me | /who-owes-me | /api/loans | authed, no scope | personal loan ledger; non-owner mutations 403 | | Slot assignments | (homepage) | /api/slot-assignments | authed | periodic-table layout persistence; PUT replaces the whole map | | Activities | (journal sidebar) | /api/activities/:date | public read only | daily activity feed; ingest is cron/admin only | ## Cross-element gotchas - Tea PIN flow: GET /api/tea-entries/:id returns 401 `PIN required` without a valid 4-digit `X-Tea-Pin` header, 403 on a wrong PIN, 429 after 10 failures per entry per minute. Distinguish the three. - Skincare reorder is `POST /api/skincare/reorder` (declared before /:id) and requires the COMPLETE ordered id list for that routine. - Scoreboard `GET /icons/search` requires auth because it spends GitHub API quota; deleting a player with match history is a 409. - RNG evaluate auto-bans a denied category for 30 days; there is no unban endpoint. - Admin-gated or machine-only surfaces (practice settings PATCH, activity ingest, wechat ingest, calendar connect) are not usable with PATs; skip them.