This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is the jchat Fly.io app — a single deployment that hosts multiple independent applications under one container:
- JimmyQrg Chat (
server/index.js,public/,index.html) — the main group chat + DMs + admin tools app. Express on port 8080. - Schoology Dashboard (
schoology/) — a Flask app on port 8081 that proxies upstreamschoology-mcpto fetch grades/courses/assignments, plus a full AI Assistant (math, Wikipedia, files, code, integrations, web). Fronted by the Node proxy at/schoology/api/*→http://127.0.0.1:8081/.... - Schoology MCP (
schoology-mcp/) — vendored copy of dajun666/schoology-mcp, used by the schoology dashboard to drive a headless Playwright browser against Schoology.
Production: https://jchat.fly.dev. Live logs: fly logs -a jchat.
"this repo also powers other applications, when you edit schoology, you cannot affect any other applications" — user, 2026-05.
schoology/is the only safe place to make schoology-related changes. The rest of the repo (chat, public/, server/) is the main app and must not be touched for schoology bugs.schoology-mcp/is shared with other consumers — do not edit it for dashboard bugs. To update it: copy from upstream (rsync -a --delete --exclude='.git' --exclude='.venv' --exclude='__pycache__' /tmp/schoology-mcp-new/ ./schoology-mcp/), then audit for breaking changes (single-tenant switch, tool renames) and adjustschoology/server.py+schoology/run_tool.py+schoology/index.htmlaccordingly.- The Dockerfile,
fly.toml, Node proxy, andpublic/files serve both the main chat and the schoology dashboard. A change there must be checked against both.
If a schoology bug tempts you to edit anything outside schoology/, stop and ask first.
Local dev (main chat only — schoology dashboard needs the full Docker image):
npm install
npm run init-db
npm run dev # http://localhost:3000Deploy to Fly:
fly deploy -a jchat # one-step; fly builds + pushes + releases
# OR the two-step pattern documented in agent.md (used when build-only was needed):
flyctl deploy --build-only --push -a jchat --config fly.toml
flyctl deploy -a jchat --image registry.fly.io/jchat:deployment-<TAG>Inspect / debug:
fly logs -a jchat --tail 100
fly ssh console -a jchat # in-container shell
fly status -a jchat
fly volumes list -a jchatLocal Python for schoology changes:
cd schoology
../.schoology-venv/bin/gunicorn -b 0.0.0.0:8081 server:app # uses the venv created by the Dockerfilecd /app/schoology; \
/app/.schoology-venv/bin/gunicorn -b 0.0.0.0:8081 --workers 1 --threads 8 -c /app/schoology/gunicorn.conf.py server:app \
& node /app/server/index.js- One container, two processes. The Node app on 8080 is public; the Flask app on 8081 is internal-only and reached only through the Node proxy.
- Persistent volume
/datais mounted for SQLite (chat.db), schoology session cookies, and AI assistant file uploads. 1 GB volume insjcregion. - 512 MB RAM, shared CPU. Memory is tight: Whisper
tiny+ CLIP + Playwright chromium + a per-user daemon pool all fight for it. Watch RSS.
- Single worker (
--workers 1,--threads 8) — previously 2 workers, but each gunicorn worker is its own daemon pool, so 2 workers × 4 parallel dashboard sections = up to 8 Chromium instances on 512MB Fly → OOM. One worker is enough for the current user base (~8 people, 2 concurrent typical). The per-user spawn lock inside_get_daemon()coalesces concurrent requests for the same user so we never have more than one in-flight Chromium spawn per (worker, user). - Long-lived
run_tool.pyper authenticated user. Each daemon holds a warm Playwright browser for that student. The pool is keyed by username, LRU-evicted atDAEMON_POOL_MAX=100.GUNICORN_WORKER_INDEXis set in the master env byschoology/gunicorn.conf.py:pre_fork(still useful for telemetry/logging). - Why per-user daemons: upstream
schoology-mcpis single-tenant — it readsSCHOOLOGY_USERNAME/SCHOOLOGY_PASSWORDfrom its own process env at spawn and keeps one headless browser per process. The pool is how we serve multiple students from one Flask worker. - Why
/data/schoology_storage.json: image redeploys lose/root/.cache/..., so the storage state env-var points to the persistent volume.
- Per-user spawn lock (
_spawn_locks+spawn_lockin_get_daemon): without it, 4 parallel dashboard sections for the same user would each spawn their own daemon → 4 Chromium instances during the spawn window → OOM. The first caller holds the lock, the rest wait, then find the daemon in the pool. - TOCTOU race in
_kill_daemon(username): the pool is keyed by username, not byDaemonClientobject. If caller A spawns a fresh daemon and caller B's pre-flight fails on the old daemon, B's_kill_daemon(username)would pop A's brand-new daemon from the pool. Always prefer_kill_daemon_object(target)which only kills a specific instance and verifies it's still in the pool before popping. - Daemon stdin "closed file" check in
DaemonClient.call(): when Chromium dies, Python'sBufferedWriterflags itsstdinclosed and subsequent writes raiseValueError(notBrokenPipeError). The pre-flight checksproc.poll() is NoneANDstdin.closedAND catchesValueErroron write — without all three, dead-daemon cycles surface as 500s. - Drain-on-timeout in
DaemonClient.call(): when the per-call timeout (200s) fires on a slow cold-start, the daemon is NOT killed. Instead the orphan response line is drained inside the lock so the next caller can use the warm daemon. Killing on timeout was the cause of cascading 500s across the 4 dashboard sections. - Tombstoned daemons:
close()setsproc = None. The next caller holding a tombstonedDaemonClientwould crash onproc.poll()._get_daemonre-spawns if it findsproc is None;call()raises EOFError as a safety net.
Non-obvious bugs already fixed and worth not re-introducing:
- Don't re-serialize
req.body={}for GET/HEAD and send it as a JSON body — gunicorn keep-alive reads{}as the next request line and returns 400. - Don't try to JSON-stringify a multipart body and forward it — you have to
req.pipe(proxyReq)raw, andreturnbeforeproxyReq.end(). TheproxyRequesthelper handles both. New/schoology/api/*endpoints are safer to add; the proxy just forwards.
A real-time multiplayer FPS built on Socket.IO's /game namespace, unrelated to schoology. Lives entirely in server/index.js (~line 1749+).
- Room registry:
globalRoomRegistry(aMap<roomKey, {mode, code, hostId, map, playerCount, createdAt, updatedAt}>) plus the per-socketgameRoomsMap. The registry expires rooms after 5 min of inactivity (ROOM_EXPIRY_MS). - Room key format encodes the room origin and mode:
qp:<mode>:<CODE>— quickplay roomcr:<mode>:<CODE>— user-created (private) room- The mode prefix matters for matching:
quickplay(mode)matches anyqp:<mode>:room with the same mode, regardless of code.
- Per-mode player caps (
ROOM_MAX_PLAYERS):crossfire: 2,arena-coop: 6,boss-coop: 4,training-coop: 4. Rooms at cap are skipped by quickplay and rejected with{error: 'Room is full'}byjoinByCode. - Map field: rooms carry an optional
mapstring (capped at 64 chars bynormalizeCreateArgs). Map is not used for matching (a quickplay player joins whatever map the host chose); the host'smapis returned in the callback so the client loads the right map. normalizeCreateArgs(arg)accepts both old (bare string mode) and new ({mode, map}) client shapes — preserves backward compat for older Zone No Light clients.- Socket handlers (
socket.on(...)in the/gamenamespace):quickplay(arg, cb)→ tries to join an open room in the mode; if none, creates one. Returns{room, roomCode, roomKey, count, map}.createRoom(arg, cb)→ always creates a newcr:<mode>:<CODE>room. Returns{code, roomCode, roomKey, map}.joinByCode({code, mode}, cb)→ mode-agnostic lookup by code across all rooms. Returns{ok, code, roomCode, roomKey, map}.getRooms(cb)→ lists open rooms (for the lobby browser).move(data),chat(...),shoot(...), etc. — gameplay events scoped to the current room.- Server broadcasts
roomListUpdate(viaio.emit) on registry changes.
- Teleport command (
/tp): disabled in quickplay rooms (!roomKey.startsWith('qp:')). In private rooms, supports source selectors (@s,@a,@e,@p,@r, or player name) and destination selectors — seeparseTeleportCommandandexecuteTeleportinserver/index.js.
- Tool protocol is bracket commands emitted by the model:
[CALC:expr],[WIKI:topic],[FILE:id|name|type|size], etc. As of 2026-08 the tool DESCRIPTIONS live SERVER-SIDE inschoology/ai/system_prompt.py::build_tools_prompt()(injected into Layer 3/4 prompts); the client-sideTOOL_REGISTRYinschoology/index.htmlkeeps only the executors (regex + handler + user-facing blurbs), no prompt text. Adding a new tool = one entry in BOTH the server tool list and the client executor registry, plus the backend route inschoology/ai/<family>.py. - All AI prompts are server-side only (2026-08 change). The client no longer builds or sends system prompts: the old
buildContextMessages/buildToolsPrompt/buildCalendarPromptSection/buildContextExtrasSystemMessageand the legacy worker-proxy streaming path were removed. Chat generation is fully server-side and asynchronous:sendChatMessage()POSTs{message, prior_messages, grades, courses, assignments, posts, extras, grade_level}toPOST /api/chats/<id>/messages; the server appends the user message and runs the 5-layer pipeline in a background job (_run_chat_jobinschoology/server.py, bounded byAI_JOB_MAX_CONCURRENT, default 4). The client pollsGET /api/chats/<id>/statusevery 1.5s until the reply lands — so generation continues when the user switches chats/menus or closes the tab, and multiple chats generate concurrently. The legacy synchronousPOST /api/chat/layeredendpoint still exists for backward compatibility. Chat auto-titling (POST /api/chats/auto-title), soft-fail grade re-detection (POST /api/grade/redetect), and the agent password check (POST /api/agent-verify) all run server-side now. - Developer status injection: when
dev_auth.is_developer(username)is true (developer key proven),build_developer_block()(insystem_prompt.py) injects a forceful "this user is a VERIFIED DEVELOPER" directive into the system prompt of Layers 2-5 ONLY (never Layer 1). Without it the model rationalizes the "key accepted" history message as a glitch and refuses to recognize the developer. The block ALSO states explicitly that developer status does NOT exempt the user from any content/safety policy (2026-08-19: without this, the model solved the developer's own homework test question — the dev block made it think "not a regular student" = graded-work policy doesn't apply). - Latency knobs (2026-08-19, thinking was too slow):
MAX_REJECTION_ROUNDS = 3(was 5); per-layer token budgets cut roughly in half (L2 400, L3 700, L4 short/medium/long 250/600/1000, L5 600); prior-message windows trimmed to last 6 messages at 400 chars (Layer 1: last 3 at 300). Keep these in mind before "making the model think harder". - Tool results go into
state.currentMessageswithrole: 'tool'so the model sees them on the next turn. Cap each at 2,000 chars in history; the full result stays in the chat UI. - File upload is real:
handleFileUploadPOSTs to/api/file/ingest, gets afile_id, and on send appends[FILE:id|name|type|size]markers./api/file/contextexpands markers into extracted text (PDF, OCR, Whisper transcription, etc.) before the model sees the message. Files are stored in/data/ai_uploads/<uuid>and auto-purged after 1 hour. - New AI routes live in
schoology/ai/*.py(math, geometry, knowledge, science, files, code, integrations, basics, web) and self-register viaregister_routes(app)fromschoology/ai/__init__.py.schoology/server.pycallsregister_ai_routes(app)once near the bottom of its route block. - Auth for AI routes is the same
decode_auth_header()Basic-auth pattern as the rest of the API. - No rate limiting in v1; most routes hit free public APIs with their own quotas (Wikipedia, Open-Meteo, MyMemory, arXiv). GitHub is 60/hr unauth.
- Adding a new tool = one entry in
build_tools_prompt()inschoology/ai/system_prompt.py(server prompt) + one entry in the clientTOOL_REGISTRYexecutor table (regex + executor + userBlurb; NO prompt field -- prompts never ship to the client). Then add the implementation inschoology/ai/<family>.pyand register it viaregister_routes(app). Don't sprinkle the tool name across files. - AI tone: the assistant is the student's FRIEND (warm, casual, supportive). Tone rules live in
schoology/ai/system_prompt.py(SYSTEM_PROMPT + POLICY_BLOCK_FOR_LAYER_5) and in the Layer 3/4/5 templates inschoology/ai/layers.py; Layer 5 may EDIT a compliant-but-cold draft to warm it up. - Developer/admin key: proving the developer key sends it as a chat message;
schoology/ai/dev_auth.pyverifies it via Argon2id (hash stored, key never stored). The hash was rotated 2026-08-18. - Multi-chat state is server-persisted per user. See "Multi-chat AI assistant" below for storage layout, endpoints, and the heuristic remember/cross-chat context system.
- Dashboard section cache (
localStorage): each section's last successful response is persisted underschoology_section_cache_<username>_<section>.hydrateFromCache()reads it on dashboard open and renders instantly whileloadSection()runs in the background — without this, the user stares at skeletons for 60-90s on every cold start. The cache is per-user; don't surface another user's data.
A user can have many chats; each chat has its own history. A "global memory" of user-asked-to-remember facts is shared across all chats, and a short summary of every other chat is injected into context for cross-chat awareness.
-
Storage layout (on the
/datavolume, seeAI_CHATS_DIRinschoology/server.py):<DATA_DIR>/ai_chats/<username>/<chatId>.json— one file per chat<DATA_DIR>/ai_chats/<username>/_memory.json— global memory items<DATA_DIR>/ai_chats/<username>/_last_chat.json— last opened chatId (restore-on-load)- Writes go through
_atomic_write_json()(tmpfile +os.replace) so a partial write can't corrupt the persistent volume.
-
Server endpoints (all in
schoology/server.py, all usedecode_auth_header()):GET /api/chats— list metadata (id, title, summary, messageCount) sorted byupdatedAtdescPOST /api/chats— create new chat (cap 50 per user; oldest dropped)GET /api/chats/<id>— full chat or 404PUT /api/chats/<id>— update title and/or messages; re-summarizes via heuristic whenlen(messages) >= 4POST /api/chats/<id>/messages— append user message + queue a background AI job (202; 409 if a job is already active for this chat)GET /api/chats/<id>/status— poll endpoint:{messages, pendingJob: {status: queued|running|failed|cancelled, error} | null, updatedAt}.pendingJobis null once the turn is donePOST /api/chats/<id>/cancel— cooperative cancel (worker discards its result);POST /api/chats/<id>/retry— re-run a failed/cancelled job from its stored payloadPOST /api/chats/<id>/edit— edit & resend: truncate at{index}, append{content}, queue a fresh jobPOST /api/chats/<id>/append— append a synthetic message (tool history / soft notes) so client-generated messages persistDELETE /api/chats/<id>— 204POST /api/chats/contextbody{chatId}— returns{otherSummaries, globalMemory}for context injectionGET/POST/DELETE /api/memory[/<id>]— global memory CRUD (cap 50 items; oldest dropped)GET/POST /api/chats/last— persist and retrieve last-opened chat for restore-on-load
-
Frontend state (in
schoology/index.html):state.chats: []— list of chat metadatastate.currentChatId: null— open chatstate.currentMessages: []— messages of the open chat (loaded lazily vialoadCurrentChat)state.globalMemory: []— remembered factsloadChats()always ensures one default chat exists (creates one if list is empty) so the sidebar is never blank.sendChatMessage()POSTs/api/chatson first send to mint an id, then posts the message to/api/chats/<id>/messagesand polls for the reply. The client no longer PUTs the messages array — the server owns message persistence (job replies, tool history via/append, and soft notes all land server-side). After the first assistant reply,aiNameChat()auto-titles the chat if the user hasn't renamed it -- viaPOST /api/chats/auto-title(DeepSeek call + title prompt are SERVER-SIDE).- Server-side replies store both
content(raw, brackets intact — used for tool dispatch viahandleAICommands) anddisplay(bracket-stripped — used for rendering).renderChatMessages()prefersdisplaywhen present. - Each active chat gets a poller (
startChatPolling/_chatPollTick); pollers run in the background even when the chat isn't open, andensureChatPolling()re-attaches one when a chat with a live job is (re)opened. Busy state is per-chat (state._genBusy);state._aiBusymirrors the current chat's flag. - On process start,
_fail_stale_chat_jobs()marks anypendingJobleft in queued/running state as failed (their worker died with the old process); the client renders a Retry button that POSTs/retry. - Saves are debounced 500ms via
scheduleSaveCurrentChat()in demo mode only (real mode is a no-op now). - Demo mode (
state.demoMode === true) bypasses all/api/chatsand/api/memorycalls and uses one localStorage chat underschoology_chat_history_<username>. The sidebar shows a "(demo mode)" notice in that case.
-
Heuristic remember detection (
detectRemember()inschoology/index.html):- Trigger keywords (case-insensitive, matched as substrings):
remember,don't forget,do not forget,note that,keep in mind,from now on,always remember. - If any keyword matches, the full user message is POSTed to
/api/memorywithsourceChatId, and the assistant reply gets a subtle "📌 Noted." inline. - This is local, no LLM call — keeps latency/cost low for what runs on every send.
- Trigger keywords (case-insensitive, matched as substrings):
-
Cross-chat context injection (
buildContextMessages()+fetchChatContextExtras()):- On every send, the frontend fetches
POST /api/chats/context {chatId}and prepends a second system message after the main prompt, capped at 20 memory items and 8 other-chat summaries (most recent first byupdatedAt). - The wording tells the model these summaries are "for awareness only — do NOT proactively reference unless the user asks", so the AI doesn't accidentally mention "yesterday's homework help" unprompted.
- On every send, the frontend fetches
-
Summarization is intentionally a simple non-LLM heuristic (
_summarize_chatinserver.py): join user messages, truncate to ~400 chars. The same shape as the previous client-sidegenerateSummary(). If the user later wants LLM-generated summaries, this is the place to add it.
/api/basic-info in schoology/server.py is the "first-paint identity" call. Upstream schoology-mcp removed the get_profile tool, so the route returns HTTP 200 with {"removed": true, "message": "..."} — NOT 410 Gone.
- 410 logs as a "Failed to load resource" browser console error (no functional impact, but noisy and bad UX).
- The frontend (
loadBasicInfo()inschoology/index.html) checksdata.removedand treats it as a soft skip: dashboard still loads using whateverget_courses/get_gradesreturn. The identity strip is skipped; student name is unknown on first paint. - Student header UI is intentionally minimal: just "Welcome! {name}" — student ID and grade are NOT shown in the UI even when the data is present, but stay in
state.studentfor the AI to use. The header element is<div class="student-info" id="studentInfo" style="display: none;">containing only<span id="studentName">—</span>; the surroundingrenderStudentHeader()produces the "Welcome! {name}" line.
getAuthHeader() in schoology/index.html returns the object {Authorization: 'Basic ...'}, not the string value:
return { 'Authorization': 'Basic ' + base64Encode(...) };The correct call is:
const auth = getAuthHeader();
if (auth && auth.Authorization) headers['Authorization'] = auth.Authorization;The wrong pattern (which has shipped before and produced silent 401s across /api/chats, /api/memory, AND pre-existing /api/file/ingest and the tool-executor fetch (aiFetch)):
const auth = getAuthHeader();
headers['Authorization'] = auth; // coerces object to "[object Object]"Flask's decode_auth_header() then sees no Basic prefix and returns 401; the symptom also cascades to 500s on /api/grades etc. because the MCP daemon gets None credentials. Three call sites in schoology/index.html already use the correct pattern: _apiFetch (multi-chat), aiFetch (tool executors), and the file upload fetch — keep new code aligned with these.
CSS variables on :root / [data-theme="light"] / [data-theme="dark"]. --surface is used for chat message backgrounds. Toggle: #darkModeToggle checkbox; init: initDarkMode() reads localStorage or prefers-color-scheme.
"Don't write fallbacks, write show error messages and fix the thing properly" — user. Error messages should NOT say "contact administrator" (personal project). Errors render as a red box with the exact message — no generic "something went wrong" replacements.
| Var | Required | Notes |
|---|---|---|
SESSION_SECRET |
yes | signs chat session cookies |
DATA_DIR |
no | default /data; SQLite + schoology storage + AI uploads |
SCHOOLOGY_STORAGE_STATE |
no | default /data/schoology_storage.json |
SCHOOLOGY_HEADLESS |
no | default true |
SCHOOLOGY_KEEPALIVE |
no | default false |
DAEMON_POOL_MAX |
no | default 100; per-user daemon pool cap (set high; current user base is ~8 people, 2 concurrent is typical) |
JUDGE0_KEY |
no | enables [RUN:lang code] for C/C++/Rust/Go/Java |
JUDGE0_URL |
no | default https://judge0-ce.rapidapi.com |
- 39 languages (en, zh, zh-Hans, zh-Hant, ja, ko, es, fr, de, hi, ar, bn, pt, ru, ur, id, sw, tr, vi, it, th, pl, uk, nl, ro, sv, hu, el, he, fa, am, ta, te, mr, pa, gu, kn, ml, jv).
- Source of truth at runtime:
data.json. The inlineSTRINGSinmain.jsonly hasen+zh(and is whatscripts/build-translations.cjsextracts).t(key)falls back toen, then the key itself. build-translations.cjsoverwrites the inline en+zh and the zh-Hant fallback table only — it does NOT preserve the other 37 languages in data.json. If you re-run it, all non-{en, zh, zh-Hant} translations in data.json get lost. To keep them, either edit data.json directly (don't re-run the build) or modify the build script to merge.- Translation keys are contextually chosen, not literally translated: e.g. "key" in the security/recovery sense maps to 凭证/証拠/증거/Key, not the literal 键/キー/키.
|
JUDGE0_KEY| no | enables[RUN:lang code]for C/C++/Rust/Go/Java | |JUDGE0_URL| no | defaulthttps://judge0-ce.p.rapidapi.com|
- Chat app entry points:
server/index.js+public/assets/js/main.js+server/db.js(see README.md). - Game (Zone No Light) multiplayer:
server/index.js/gamenamespace, ~line 1749+ (globalRoomRegistry,quickplay/createRoom/joinByCode, teleport, etc.). - Schoology dashboard UI:
schoology/index.html(single large file;TOOL_REGISTRYis at the top; multi-chat sidebar + memory + cross-chat context live here). - Schoology Flask:
schoology/server.py(daemon pool, all/api/*routes, AI registration, multi-chat + memory endpoints). - Schoology AI routes:
schoology/ai/__init__.py(aggregator) + one file per tool family. - gunicorn config:
schoology/gunicorn.conf.py(pre_forksetsGUNICORN_WORKER_INDEX). - Project memory (curated, persists across sessions):
~/.claude/projects/-Users-Benran-Documents-GitHub-chat/memory/—schoology-architecture.mdfor the per-user daemon pool + upstream tool surface;server-proxy-pitfalls.mdfor the two non-obvious Node proxy bugs;dm-voice-architecture.mdfor the 1:1 WebRTC voice implementation. - Seeded accounts (auto-created on every server boot, idempotent — see
server/db.jsandschoology/index.html): thejimmyqrgadmin (placeholder password, claimed on first signup), thehelperbot, and thesezitoushangyibadaoprivate account (display name色字头上一把刀, passwordxyz12345, emailsezitoushangyibadao@chat.local,is_private=1). Private users are hidden from everyone exceptjimmyqrgon profile / DM / mention-search / friend paths; groups still display their messages. - Private-user enforcement (
server/db.js+server/index.js):is_private=1rows are filtered from/api/userslist,/api/users/mention-search, and from the DM-open gate (/api/conversations/with/:userId). The frontend shows aprivate-user-noticeplaceholder when the server returns{ private_user: true, error: 'This user is private' }.canSeePrivateUser(viewer, targetId)is the single helper; add it to any new endpoint that exposes a user. - Theme-aware logos (
schoology/index.html): two stacked<img>tags per logo, dark variant +logo-lightvariant, swapped via CSS[data-theme="light"] .header-logo-dark { display: none }. The favicon is intentionally NOT theme-swapped. - Working notes (free-form, session-scratch):
agent.mdat repo root.
- Do not edit
schoology-mcp/for dashboard bugs. Update only when upstream changes. - Do not add "fallback" error handlers that swallow the real message. Show the actual error and fix the cause.
- Do not add
execto the DockerfileCMD—&is the right pattern for running gunicorn and node in one container. - Do not assume the chat app can be suspended —
agent.mdsays "this app cannot suspend - it powers other applications". - Do not use demo/placeholder data in production paths (e.g. schoology grades). The
generateDemoResponseAI fallback is for the AI tab only, and even there, "DO NOT USE DEMO INFORMATION UNLESS IS IN THE DEMO MODE". - Do not write
headers['Authorization'] = authfromgetAuthHeader()— it returns an object, not a string. Useauth.Authorization. See "Auth-header gotcha" above; this bug shipped before and produced silent 401s across/api/chats,/api/memory,/api/file/ingest, and the tool-executor fetch (aiFetch). - Do not return HTTP 410 for removed-upstream endpoints. 410 logs as a browser console error; return 200 with a
{"removed": true}body and let the frontend soft-skip. See "Schoology basic-info pattern" above.