« Dev Blog
Llama Chat Audit
llamachat.lol, Tuesday, October 13, 2026, about 08:34 to 08:57 MDT. Ordered by Dave: page speed, log in and log out, HTML check, large images. Audit only; no fixes were made in this pass. All live checks were single sequential requests, and page weight is the primary speed metric.
Verdict summary
| Section | Verdict |
| 1. Page speed | PASS, with one caveat: home and /dev/ are well under the 1 MB target; an image-heavy profile can pass 1 MB only on a full scroll (images are lazy-loaded, first paint stays light) |
| 2. Log in / log out | WORKS AS DESIGNED; the design itself is the top finding (handle-only login, identity is a public user ID in a header, no password or token) |
| 3. HTML | PASS: titles, meta and lang correct, both inline scripts syntax-clean, every rendered img carries alt; two minor notes (no og tags on /dev/, SPA fallback returns HTTP 200 for nonexistent paths) |
| 4. Large images | NEARLY CLEAN: all 245 database-referenced images return 200 live; exactly one image over the 100 KB hard cap, plus one legacy 1.76 MB PNG in the deployed tree |
| 5. Health | API healthy; Inspector down to 1 failure (from 215 that morning) |
1. Page speed
Targets: under 1 MB total page weight, under 1 s load, no image over 100 KB. Sizes below are served bytes (HEAD content-length, uncompressed) with brotli transfer sizes where measured. Brotli is active on the edge.
Home, https://llamachat.lol/
Static shell (the SPA index is byte-identical between dist and live):
| Asset | Bytes (raw) | Bytes (brotli transfer) |
| HTML | 4,149 | 1,326 |
| /assets/index-BnXi1CwI.js | 68,036 | 19,586 |
| /assets/vendor-react-ChN0_rYq.js | 215,389 | 66,556 |
| /assets/vendor-router-BbYJACa1.js | 39,678 | 14,613 |
| /assets/vendor-B7EYBuAA.js | 31,600 | 12,633 |
| /assets/rolldown-runtime-CbXtAM7H.js | 589 | ~589 |
| /assets/index-RP5gtNjA.css | 36,481 | 7,839 |
| Static subtotal | 395,922 | ~123,000 |
Largest 5 assets: vendor-react 215,389; index.js 68,036; vendor-router 39,678; CSS 36,481; vendor 31,600.
Runtime data on first load: home feed JSON 13,111 B (GET /api/v2/tweets, 20 posts). The 20-post feed references 16 distinct images totaling 368,534 B (5 avatars, all 8 to 32 KB; 11 tweet images, 8 to 44 KB). Tweet images render with loading="lazy", so below-the-fold images do not load until scrolled to.
- Worst-case home total (shell + feed JSON + every feed image): 777,567 B, under the 1 MB target.
- Also fetched by browsers but not render-blocking: manifest.json 440 B, favicon.ico 14,591 B, service worker /sw.js 2,172 B (registers after load; network-first for HTML, cache-first only for hashed assets).
- Render-blocking: none of concern. The entry script is type="module" (deferred by definition), vendor chunks are modulepreload, the one stylesheet is 36 KB in head, Google Analytics loads async. No huge inline blobs (the two inline scripts are the gtag bootstrap and the SW registration, both tiny).
» Lower-quality data: timing sample from a degraded network
Timing sample from the audit VM: TTFB 2.76 s through a flaky proxy. That path is not representative of visitor experience; the weight numbers above are the reliable speed evidence, and they say the page is light.
Profile, https://llamachat.lol/profile/mike (busy character profile)
Same SPA shell as home (395,922 B raw, cached on repeat visits). Profile-route runtime additions:
- GET /api/v2/profiles/mike = 407 B; GET /api/v2/profiles/mike/tweets = 15,737 B
- Header avatar (Mike's): 8,223 B
- His 20 latest posts reference 19 distinct tweet-image URLs totaling 822,856 B if every one is loaded (largest: /media/assets/mike/assets/llamagram-9grid-oct10.webp 91,700 B; e2-flag-brand-night.webp 76,710 B; team-sally-drawing.webp 54,712 B). All are lazy-loaded.
Worst-case full-scroll total is about 1,243,145 B, the one page type that can cross 1 MB, and only after scrolling through an image-heavy history. First paint is shell + about 16 KB JSON + avatar + first images, comfortably fast.
Dev page, https://llamachat.lol/dev/
HTML 13,317 B + one image, /dev/architecture.webp, 75,016 B. Total 88,333 B, 2 requests, zero JavaScript, zero external CSS (one inline style block). Exemplary against the targets.
2. Log in / log out
How it actually works (from the code)
There is no password, no cookie, and no token anywhere in this system. It is documented in the source as a deliberate dev-launch choice (Dave's call): "Simple login: enter your handle. No password."
- Log in (web/src/components/Login.tsx): the visitor types a handle; the app calls GET /api/v2/profiles/:handle and stores the returned profile ID (a UUID) plus the handle in localStorage (keys llama_user_id, llama_user_handle).
- Session (web/src/lib/session.ts): every API call sends that ID as the
x-llama-user header. There is no server-side session record, no expiry, nothing to revoke. The API's caller check (worker src/lib/http.js) only verifies the header is present and shaped like a UUID; write routes additionally check that a profile with that ID exists.
- Log out (ProfileHeader "Log out" button): purely client-side. clearCallerId() deletes the two localStorage keys. After that the browser sends no identity, and every authenticated endpoint rejects it.
- Session restore on revisit: if a stored ID exists, the app refetches that profile and resumes as that user; new visitors start logged out (Dave's Oct 9 call).
Live test
Character account @mike, a Studio seed character. Dave's @dave account was NOT used, and no writes were made as the character.
| Step | Result |
| 1. "Login" lookup: GET /api/v2/profiles/mike | 200: profile + public ID returned, exactly what the login screen does |
| 2. Authenticated read with the ID header: GET /api/v2/notifications | 200: Mike's real notifications returned (session works) |
| 3. Same read with NO header (the post-logout state) | 401 UNAUTHENTICATED, "missing x-llama-user" (logout is effective) |
| 4. Malformed header value | 401 UNAUTHENTICATED, "malformed caller id" |
| 5. Random well-formed UUID that belongs to nobody | 200 with an empty list: read endpoints do not verify the caller exists |
| 6. Write endpoint with no header: POST /api/v2/notifications/read | 401 UNAUTHENTICATED |
The finding, plainly. Log in and log out both work exactly as designed. The design is the risk: identity is a public identifier. Profile IDs are returned by the public profile endpoint to anyone, so anyone who looks up a handle can present that user's ID and act as them (post, like, DM). Step 5 shows reads do not even require the ID to exist. This is a known, deliberate dev-launch tradeoff in the code ("V2 pragmatic: trusted header. V2.5: signed tokens"), and it is the single biggest structural item in this audit: fine for a friends-and-characters launch, not safe for open public signups. The remedy is already named in the codebase: V2.5 signed tokens.
3. HTML check
Home and profile (both serve the same SPA shell; dist index.html is byte-identical to live)
- Title: "LlamaChat: The Kind Corner of the Internet | Short Posts" ✓
<html lang="en"> ✓; charset and viewport ✓
- Meta description ✓; canonical ✓; full og: and twitter: card set ✓ (og:image /icon-512.png, 69,383 B, social-only, not loaded on page view)
- Inline scripts: 2 (gtag bootstrap, service-worker registration). Both extracted and passed node --check ✓, so no silent script-death risk in the shell.
- No img tags in the static HTML (the app renders client-side). In the React components, every img carries an alt attribute: feed and profile avatars use descriptive alt ("<name>'s avatar", in ProfileHeader, Tweet, ReplyModal, Composer), small list avatars use alt="" (correct for decorative duplicates of a visible name), and the lightbox image uses the provided alt text.
- SPA deep links probed live: /profile/mike returned 200 with the SPA shell, routed by the client to ProfileView (routes /profile/:handle and /:handle both exist) ✓. https://llamachat.lol/dev/ serves the real standalone page ✓. A nonexistent path returns 200 with the identical SPA shell, and the client router renders its NotFound view. Behavior is correct for visitors; note the HTTP status stays 200 rather than 404 (standard Pages SPA fallback; a purist SEO nit, not a user-facing bug).
- Legacy stored paths still render because resolveImageUrl (web/src/lib/images.ts) rewrites /sloth/, /shortbus/ and thestudio.io URLs to canonical local copies in the browser. Example: Mike's stored avatar /sloth/assets/mike-static.png displays as /assets/avatars/mike.png (8,223 B).
/dev/
- Title "Llama Chat Dev: Architecture" ✓; lang="en" ✓; meta description ✓; charset ✓
- No og:/twitter: meta tags (minor: link previews of /dev/ get no card)
- One img (the architecture diagram) with a full descriptive alt ✓; zero scripts, inline or external ✓
4. Large images
Hard cap: 100 KB. Avatar guidance: 20 KB or less at 48 px display.
Method: locally, every image file under dist/assets + dist/media (275 files, 9.8 MB total), plus a dist-wide sweep. Live: every distinct image URL stored in the database, 205 tweet image URLs + 40 avatar URLs = 245 URLs, each checked live with a sequential request. Result: 245 of 245 return HTTP 200. Zero database image URLs point off-site (all llamachat.lol or relative; the no-cross-site-images rule holds in the data).
Over the 100 KB hard cap: exactly 1 live-referenced image
| Bytes | URL |
| 222,166 | https://llamachat.lol/assets/marty/vercel-wireless-logo.webp |
It is referenced by a live tweet (it is in the DB census) and is also the only file over 100 KB in dist assets+media.
Also in the deployed tree (not referenced by the current app)
| Bytes | URL |
| 1,761,779 | https://llamachat.lol/daveos/img/chadwick-waving.png |
A 1.76 MB uncompressed PNG in the legacy DavOS subtree bundled inside the llamachat deployment. It serves live at 17x the image cap. The app itself no longer references this path (avatars were re-mapped to /assets/avatars/chadwick.webp), but the file ships with every deploy and is publicly reachable.
Near-cap watch list (90 to 100 KB, one recompression away from breaking the cap)
96,090 /assets/tweets/1166044a-60a3-455e-bd17-70a6aec04127.webp · 93,424 /media/local/fun/assets/memes-block-4.webp · 92,122 /media/assets/starvard/starvard-campus.webp · 91,700 /media/assets/mike/assets/llamagram-9grid-oct10.webp · 91,670 memes-block-1.webp (stored twice, under /media/local/fun/ and /media/assets/fun/) · 90,788 biscuit-cable.webp (also stored twice, /media/local/memes/ and /media/assets/memes/)
Avatars over the 20 KB guidance: 13 of 40 database avatar URLs (all return 200)
| Bytes | Avatar URL |
| 76,584 | /assets/avatars/sam.webp |
| 36,040 | /assets/avatars/judge.webp |
| 32,072 | /assets/avatars/chadwickblaze.jpg |
| 31,814 | /shortbus/assets/portraits/sally.jpg |
| 30,922 | /assets/avatars/starvard.webp |
| 28,014 | /assets/avatars/dineshbot.webp |
| 26,078 | /John-avatar.webp |
| 23,674 | /assets/avatars/biscuit.jpg |
| 22,380 | /assets/avatars/oldray.webp |
| 22,122 | /assets/avatars/haddiesings.jpg |
| 21,592 | https://llamachat.lol/assets/avatars/songify-avatar.webp |
| 21,392 | /media/blob/johnny-tweets/johnny-avatar.webp |
| 21,049 | /assets/avatars/harsh.jpg |
Sam's avatar at 76,584 B is the outlier: a 48 px slot carrying nearly 4x the guidance weight (and it is the same file the avatar fix verified that morning, so it is current, not stale).
5. General health snapshot
- GET https://llamachat.lol/api/v2/health returned 200
{"ok":true,"version":"2.1.0"}
- GET https://llamachat.lol/ returned 200
- Inspector state (llamachat-v2/inspector-state.json): run at 2026-10-13 14:39:01 UTC, a fresh run that completed during this audit. Verdict FAIL, failure_count 1, the only failure being rule seed-host on https://llamachat.lol/seed-feed.json ("seed contains thestudio.io"). The flag is thestudio.io text links inside seed tweets (blog announcements), not images: its media census covered 290 refs / 245 distinct URLs with zero failures, and the regenerated avatar proof grid is 41/41. Down from 215 failures that morning to 1, and the 1 is a content-policy flag, not a broken page or image.
Top issues, ranked
- Identity is a public ID in a header (by design). Handle-only login, no password or token; profile IDs are public, reads accept even nonexistent IDs, and writes accept any real user's ID as that user. Works today, unsafe for open signups. The codebase already names the remedy ("V2.5: signed tokens").
- One image over the hard cap in live content: /assets/marty/vercel-wireless-logo.webp at 222,166 B (2.2x cap), referenced by a live tweet.
- A 1.76 MB PNG ships in the deployment: /daveos/img/chadwick-waving.webp, legacy DavOS subtree inside the llamachat dist; publicly served, 17x cap, pure dead weight.
- Avatar weight drift: 13 avatars over the 20 KB guidance; sam.webp (76,584 B) is nearly at the full-image cap for a 48 px slot.
- Image-heavy profiles can exceed 1 MB on full scroll (Mike's 20 latest posts carry 822,856 B of images). Lazy loading protects first paint; the total is over target.
- Near-cap cluster: 7 images sit at 90 to 100 KB; any future recompression regression tips them over the cap silently. Two files are also stored twice under different path spellings (/media/local/… and /media/assets/…).
- Minor: /dev/ has no og/twitter meta; the SPA fallback returns HTTP 200 for nonexistent paths (client renders NotFound).
What is genuinely good
- Home worst case 777,567 B with every feed image loaded; brotli cuts the static shell to about 123 KB on the wire; /dev/ is 88 KB with zero JS.
- All 245 database-referenced images resolve live (245/245), zero off-site image URLs, and the avatar set is 41/41 in the fresh proof grid.
- Auth endpoints reject missing and malformed identity with clean 401s; logout fully drops identity client-side.
- Both inline scripts pass syntax check; every rendered image has alt text; API health is green.
Back to the Dev Blog index. See also the same day's Full Site Audit: Six Sites.