Three drifts between this repo and reality, found while moving the per-member key seed into the app: - The 'verify after every image deploy' block asserted the CREAM palette (rgb(250,248,245)). The deployed palette has been the green wash (rgb(234,243,234) with white text areas) for some time, so a correct deploy would have read as a failure — and invited someone to 'fix' the CSS back. - The block still verified against librechat-test.inference.coop; the swap to chat.inference.coop happened 2026-10-21 and that alias is not provisioned. - Nothing checked that the scheduler task's script exists in the running image, which is exactly the failure mode a manifest repoint can introduce. release.sh encodes the documented release flow (dirty-tree guard, always --no-cache, pin-before-deploy) and its verify subcommand runs checks 1-5 plus the seed task's log lines. sync-keys.js now prints its own md5 prefix on every run, so a stale volume copy can never hide again — the repo copy's md5 is the authority. Verified: ./release.sh verify passes all six checks against the live app; ./release.sh volume sync-keys.js confirms md5 parity.
LibreChat sandbox — Inference Co-op customization
Source of truth for the co-op's chat instance at
chat.inference.coop (Cloudron app 4023ccbf-d57a-4500-97df-14de9cf3516e,
appstore package ai.librechat.cloudronapp@1.4.1, which bundles LibreChat
v0.8.8 on image cloudron/ai.librechat.cloudronapp:202610150224320000).
librechat-test.inference.coop was the pre-swap hostname (the swap to
chat. happened 2026-10-21; the test alias is still not provisioned).
The deployment has two independent layers — know which one you are changing:
- Persistent volume (
/app/data/…) — behavior:librechat.yaml,env,mcp/*.py. Survives image updates. Edited viacloudron exec, then mirrored into this repo (see "Volume changes" below). - Overlay image (this repo's Dockerfile) — appearance only: logo,
favicons,
custom.css,custom2.js,<title>. Rebuilt and redeployed to change branding.
The md5s of librechat.yaml, mcp/server.py, and mcp/image_server.py in
this repo are kept byte-identical to the live volume files. Verify with:
APP=4023ccbf-d57a-4500-97df-14de9cf3516e
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec --app $APP \
-- md5sum /app/data/librechat.yaml /app/data/mcp/server.py /app/data/mcp/image_server.py
md5sum librechat.yaml mcp/server.py mcp/image_server.py
Architecture: what lives where
| Layer | Location | Survives image update? | Repo mirror |
|---|---|---|---|
| App config | /app/data/librechat.yaml (volume) |
yes | librechat.yaml |
| Env vars (env-file wins!) | /app/data/env (volume, sourced LAST by start.sh) |
yes | documented below |
| Env vars (Cloudron-managed) | cloudron env (process env) |
yes | documented below |
| MCP servers | /app/data/mcp/server.py + mcp/image_server.py (volume) |
yes | mcp/ |
| Per-member key seed | /app/data/sync-keys.js (volume; run by the manifest's scheduler task) |
yes | sync-keys.js |
| Brand assets + branding CSS/JS | baked into image overlay | no — rebuilt per image | assets/ |
CloudronManifest.json |
repo only (controls what cloudron update deploys) |
n/a | official appstore manifest except dockerImage |
Precedence gotcha: start.sh sources /run/librechat/env AFTER the
process env, and /app/data/env is appended into it — so a value defined in
/app/data/env overrides the same cloudron env variable. That is why
ALLOW_EMAIL_LOGIN=false must be set in /app/data/env, not (only) via
cloudron env.
Volume changes (librechat.yaml, mcp/*.py, env)
Volume warning: edits you make to the files in this repo do not deploy with the image. The image never touches
/app/data. Apply changes to the live files first, then mirror them here.
Deploy a config change (no image rebuild needed):
B64=$(base64 -w0 librechat.yaml)
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \
--app 4023ccbf-d57a-4500-97df-14de9cf3516e \
-- bash -c "echo '$B64' | base64 -d > /app/data/librechat.yaml"
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" restart \
--app 4023ccbf-d57a-4500-97df-14de9cf3516e --timeout 900
-
Always deploy via base64 — heredocs and
sedinserts have corrupted this file twice (literal$(...)/ bad indentation → LibreChat silently falls back to defaults with only one log line). -
Always
diffthe repo file against the live one after deploying. -
Reading large live files back over
cloudron execcannot be done with a plaincat(a single large stdout stream resets the connection,ECONNRESET). Pipe it small instead:cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \ --app 4023ccbf-d57a-4500-97df-14de9cf3516e \ -- bash -c "gzip -c /app/data/librechat.yaml | base64 -w0 | fold -w 100" > /tmp/live.b64 tr -d '\n' < /tmp/live.b64 | base64 -d | gunzip > /tmp/live-librechat.yaml
Verify after every config change:
cloudron logs --app 4023ccbf-d57a-4500-97df-14de9cf3516e --lines 60 | grep -iE 'custom config|error|invalid|MCP'
curl -s https://librechat-test.inference.coop/api/config # appTitle, emailLoginEnabled
Build + deploy the image (branding only)
Two traps have each silently eaten deploys of this overlay. Both look like success. Run the verification block below after every deploy.
cloudron update --image <tag>is IGNORED when this directory containsCloudronManifest.json. The CLI deploysmanifest.dockerImageinstead — while still printing your--imagevalue in its output, so it looks right. The tag you build MUST be written intoCloudronManifest.json.dockerImagebefore updating.- The remote builder's cache can serve stale COPY layers even when file
content changed (observed 2026-10-19: six consecutive "Build successful"
runs produced images with old assets). Build with
--no-cachewhenever anything underassets/changed.
Full deploy sequence:
cd /path/to/librechat-custom # the dir containing CloudronManifest.json
# 1. edit assets/ … then:
git add -A && git commit -m "…" && git push
SHA=$(git rev-parse --short HEAD)
# 2. build fresh layers (no-cache after asset edits)
cloudron builder build --no-cache \
--repository registry.inference.coop/co-op/librechat-custom \
--tag 20261019-$SHA
# 3. pin the manifest to the new tag — the ONLY field you may change
# (edit "dockerImage" in CloudronManifest.json to :20261019-$SHA)
git add CloudronManifest.json && git commit -m "Pin manifest to $SHA" && git push
# 4. deploy — the manifest drives it
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" update \
--app 4023ccbf-d57a-4500-97df-14de9cf3516e --force
./release.sh release runs this same sequence with both traps handled — it
refuses to build from a dirty tree, always passes --no-cache, writes the tag
into the manifest before deploying, then runs the verification block. build
needs docker, so it runs on whoever's machine has it (deliberately not the agent
VM); verify, volume and pin/deploy need only CLOUDRON_TOKEN.
./release.sh verify is safe to run any time.
Rollback: repoint CloudronManifest.json.dockerImage to the previous tag
(every build is tagged 20261019-<git-sha>; the base image
cloudron/ai.librechat.cloudronapp:<tag> is the pre-overlay fallback) and
rerun step 4.
Verify after every image deploy (all four, or it didn't happen)
APP=4023ccbf-d57a-4500-97df-14de9cf3516e
# 1. What is ACTUALLY running? (CLI update output is not evidence)
curl -s -H "Authorization: Bearer $CLOUDRON_TOKEN" \
https://my.inference.coop/api/v1/apps/$APP \
| python3 -c "import json,sys; print(json.load(sys.stdin)['manifest']['dockerImage'])"
# 2. Container files match the repo (catches stale builder cache)
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec --app $APP \
-- md5sum /app/code/librechat/client/dist/assets/custom.css
md5sum assets/custom.css
# 3. Served HTML carries the injection
curl -s https://chat.inference.coop/login | grep -Eo 'assets/custom2?\.js|assets/custom\.css'
# 4. Rendered DOM shows it (browser devtools or a browser tool). The DEPLOYED
# palette is the GREEN WASH with white text areas — an earlier version of this
# block asserted cream (rgb(250,248,245)), which would have led you to "fix"
# a correct deploy:
# computed background of div.bg-surface-primary == rgb(234, 243, 234);
# chat text areas / composer == rgb(255, 255, 255);
# dark scheme == rgb(34, 39, 31);
# the "Don't have an account?" <p> computes display: none.
# 5. The scheduler task's script exists in THIS image — a manifest repoint
# deployed before its image lands leaves the key seed broken on schedule:
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec --app $APP \
-- test -f /app/data/sync-keys.js && echo "seed path present"
./release.sh verify runs checks 1-5 plus the seed task's own log lines and the
running script's revision hash, and exits non-zero on any failure.
CloudronManifest.json rules
- The only field you may change is
dockerImage— it must point at the overlay tag you want deployed. - Everything else stays a byte-identical copy of the OFFICIAL appstore
manifest. A hand-edited manifest that omits the addons (
postgresql,mongodb,oidc,sendmail,localstorage) strips them from the running app and crash-loops it (mkdir: Read-only file system). That broke the sandbox on 2026-10-18 — the manifest, not the overlay, was the bug. - When the appstore package updates, refresh the file with a new copy from
the Cloudron API (
GET /api/v1/apps/<id>→.manifest), repointdockerImageto your rebuilt overlay, and bump theFROMtag in the Dockerfile to the new appstoredockerImage(pinned — never a floating tag).
Env vars (live state)
Set in /app/data/env (volume — these WIN over cloudron env):
APP_TITLE="Inference Co-op"(was"LibreChat")ALLOW_EMAIL_LOGIN=false(wastrue) — password login removed; Cloudron SSO (OIDC) is the only login pathSCHEDULES_SINGLE_PROCESS=true— sandbox runs a single replica; silences the scheduler's Redis guard error
Set via cloudron env set (process env):
TTS_API_KEY/STT_API_KEY— dedicated LiteLLM virtual key (aliaslibrechat-speech, $5 / 30d budget, restricted totinfoil/voxtral-tts+tinfoil/whisper-large-v3-turbo). Regenerate with the gateway master key:POST /key/generate {"key_alias":"librechat-speech","models":[...],"max_budget":5,"budget_duration":"30d"}— never put the master key here.IMAGE_GEN_OAI_BASEURL=https://gateway.inference.coop/v1andIMAGE_GEN_OAI_MODEL=regolo/qwen-image— the built-in agent "OpenAI Image Tools" points at the co-op gateway. No admin key is configured; members using the AGENT path paste their own key in the tool UI (v0.8.8 limitation). The MCP path below needs no key at all.SEARXNG_INSTANCE_URL=https://search.inference.coop— referenced from the YAML as${SEARXNG_INSTANCE_URL}(self-hosted search; keyless JSON API).
Branding overlay (custom.css / custom2.js)
Injected into the built SPA by the Dockerfile (<link> + <script> after
the <title> sed). What it does:
Palette (final): light --surface-primary is 250 248 245 (#faf8f5
cream) — the dashboard's true page background (the green wash #eaf3ea was
tried as the full background and rejected as too dark; on the dashboard the
green is accent-only). Sidebar/composer box = white cards, tertiary = #f4f2ec
(dashboard chips), hover = #eaf3ea green wash, border = #e4e0d8, chat text
areas (--surface-chat, --presentation) = white. Green (#5b8c5a family)
stays on accents: buttons, links, focus rings. Loading flash = cream.
Dark mode: deep pine + green-light accents. Homepage variables remain the
reference for other surfaces.
Fonts: Figtree (the homepage's OFL font) is self-hosted from the design
kit (assets/fonts/figtree-*.woff2 + OFL.txt, served at /assets/fonts/)
via @font-face in custom.css with a font-family override — LibreChat's
built-in default is Inter. Never load fonts from a CDN.
Footer: build-time text (no-JS fallback) + runtime link upgrade —
custom2.js rewrites the footer to
"Inference Cooperative · Our AI, governed together"
with a dataset guard so the MutationObserver doesn't loop.
UI strings: patch_ui_strings.py (build-time, idempotent, non-fatal)
renames the "MCP Servers" button to "Tools" and replaces LibreChat's
footer tagline ("Every AI for Everyone.") with the co-op footer
("Inference Cooperative · Our AI, governed together · inference.coop"),
deleting stale .gz/.br siblings of edited files. Add new string
replacements to the REPLACEMENTS list.
Palette source of truth: the co-op homepage's own CSS variables —
--cream #faf8f5 (surfaces), --wash #eef4ea (panels/hover),
--green-deep #5b8c5a (primary/submit), --green-mid #6ba86b (hover),
--green-light #7ab87a (dark-mode accent), --ink #2d3327 (text),
--muted #6b7a62 (secondary text), --border #e8e2d8. The html.light /
html.dark blocks in custom.css map these onto LibreChat's Tailwind
variables; keep them in sync when the homepage palette changes.
Logo & icons: assets/logo.svg is the GREEN pine-dots vector (extracted
from the homepage's inline SVG — keep the xmlns attribute, a standalone
.svg is invalid without it). The earlier white-plate PNG variant was
invisible on the cream background and only "flashed" against the dark
loading screen. Favicons/PWA icons are the cream-plate + green-dots design
rasterized from design-assets/favicon.svg. The Dockerfile also cache-busts
the logo URL in the client bundle (assets/logo.svg?v=3) and deletes the
stale precompressed .gz/.br siblings — bump v= whenever the logo
changes, or browsers/service workers may keep serving an old cached copy.
- Colors — LibreChat's Tailwind theme resolves every color through CSS
variables set on
html.light/html.dark(space-separated RGB):--surface-primary,--surface-secondary/-tertiary/-hover/-submit/-submit-hover,--accent-primary/-hover,--ring-primary,--link,--text-primary/-secondary,--border-light.custom.cssoverrides them (light: cream250 248 245surfaces + co-op green91 140 90accents; dark: matching co-op dark palette). To rebrand, edit thehtml.light/html.darkblocks — do not invent class names;.login-page,.primaryetc. do not exist in the DOM. Read current values in devtools viagetComputedStyle(document.documentElement).getPropertyValue('--surface-primary'). - No sign-up prompt — the "Don't have an account? Sign up" line is
<p><a href="/register">Sign up</a></p>; hidden withp:has(> a[href*="register"]) { display: none !important; }(hiding only the<a>leaves the text). Any residual register/signup links are hidden too. custom2.js— the auth form is client-rendered AFTERload, so a plain load-time query matches nothing; a MutationObserver re-checks on every DOM change and hides the sign-up paragraph whenever React renders it.
There is also an interface.theme block in librechat.yaml — it is
forward-compatible only: the theme engine ships in LibreChat releases
AFTER 0.8.8 and the block is silently ignored today. When the appstore
package bundles a newer LibreChat, revisit whether the CSS overlay is still
needed.
Feature notes (LibreChat v0.8.8)
- Chat models: custom endpoint "Inference Co-op" → gateway, 8 chat
modelSpecs (emoji convention shared with models.md).
regolo/qwen-imageis deliberately NOT in the chat picker (image models are not chat models; chat-calling it was the source of the old 500s). - Image generation — two paths:
- MCP tool (default, no key ever):
inference-imageserver (toolgenerate_image) — members select it in the composer's + palette and just ask. Per-member billing viacustomUserVars.IC_API_KEY(seeded from the portal) +env: IC_API_KEY: "{{IC_API_KEY}}"; a system-key fallback (system-image-fallback, $5/30d) covers the rare empty-value case. ThecustomUserVarsgate blocks a not-yet-seeded member until the next sync (≤10 min) — accepted as a short delay over billing everything to the system account. - Agent tool: LibreChat's built-in "OpenAI Image Tools" (env
IMAGE_GEN_OAI_BASEURL/IMAGE_GEN_OAI_MODEL→ gateway; no admin key, members paste their own). Note LibreChat v0.8.8 renders the generated image only inside the agent conversation.
- MCP tool (default, no key ever):
- MCP:
inference-docs(toolsearch_docs, starts at boot) andinference-image(toolgenerate_image,startup: false). Members may add their own via the MCP Settings panel ("bring your own tools"). Permissions seeded viainterface.mcpServers(use/create true, share false). - Speech: TTS
tinfoil/voxtral-tts(voicecasual_female), STTtinfoil/whisper-large-v3-turbo, both via the gateway with the capped key. - Web search: SearXNG (self-hosted) + keyless Keenable scraper, no reranker.
Per-member key auto-load (no API-key prompts)
Members never enter an API key. Chat and image generation resolve each member's own portal-provisioned key automatically:
- The portal provisions a per-member LiteLLM key at onboarding and stores it
(
members.key_token). GET /admin/librechat/keyson the portal returns those keys for ACTIVE members — authenticated by the dedicatedX-Sync-Tokenheader (LIBRECHAT_SYNC_TOKEN, set on BOTH apps; not the admin token).sync-keys.js(this repo, deployed to/app/data/sync-keys.js) fetches that feed and upserts, per member with a sandbox account:keyscollection:{userId, name: "Inference Co-op", value: encrypt(JSON.stringify({apiKey}))}— consumed by the chat endpoint (apiKey: "user_provided"in the yaml).pluginauthscollection:{userId, pluginKey: "mcp_inference-image", authField: "IC_API_KEY", value: encrypt(key)}— consumed by the MCPcustomUserVarsconfig so image generation bills to the member's own team. Encryption uses LibreChat's own scheme (CREDS_KEY/CREDS_IV, AES-256-CBC via@librechat/data-schemasencrypt()).
- Members who haven't logged in to the sandbox yet are skipped; the next run seeds them.
Scheduling (in this app, since 2026-10-25)
CloudronManifest.json declares the task in addons.scheduler:
"scheduler": {
"librechat_key_sync": {
"schedule": "*/10 * * * *",
"command": "cd /app/code/librechat/api && node /app/data/sync-keys.js"
}
}
Seeding therefore depends on nothing outside this app — it previously ran from a steward cron on another machine, which made a member-facing function depend on that machine's uptime and paths.
Two consequences of how the scheduler addon works, both learned the hard way:
-
Each task runs in its own container. The app's process env is absent there, and
/proc/1/environbelongs to the task launcher, not to LibreChat — soCREDS_KEY/CREDS_IVcome from/app/data/.librechat-creds(root:cloudron0640), written from the live app env:cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \ --app 4023ccbf-d57a-4500-97df-14de9cf3516e \ -- sh -c 'umask 077; tr "\0" "\n" < /proc/1/environ | grep -E "^CREDS_(KEY|IV)=" > /app/data/.librechat-creds; chown root:cloudron /app/data/.librechat-creds; chmod 640 /app/data/.librechat-creds'If the app's key is ever rotated, re-run that. A stale key does not corrupt anything — the guard below refuses to write.
-
It fails closed.
sync-keys.jswrites nothing unlessCREDS_KEYfirst decrypts an existing key row (proof it is the app's real key) and unless each fresh ciphertext round-trips back to the plaintext. A credential problem exits non-zero, logsFATAL: ..., and writes/app/data/key-sync-diag.txt(uid, cwd, which env sources were readable) instead of overwriting working member keys.
Manual run (unchanged, useful after an onboarding wave):
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \
--app 4023ccbf-d57a-4500-97df-14de9cf3516e \
-- bash -c "cd /app/code/librechat/api && node /app/data/sync-keys.js"
Idempotent. Verify: the app log shows the task's output
(creds check: decrypt-ok … / portal feed: N active member key(s) /
done: N seeded, M skipped), and keys / pluginauths counts should equal the
number of members who have logged in to the sandbox.
Output-redaction gotcha: terminal output renders values after
apiKey:as***even when the file containsuser_providedor${ENV_REF}. Never conclude a file contains a literal placeholder from redacted output — probe lengths/counts instead (tr -cd '*' < file | wc -c,awk -F'apiKey: ' '{print length($2)}').
Known state / limitations
- The
[schedules]Redis guard error is resolved bySCHEDULES_SINGLE_PROCESS=true(above). - The
RAG API not reachablewarning is expected (no rag_api addon); it only affects LibreChat's own file-RAG, which we do not use. - The
inference-imageMCP server logsTools: undefinedat boot — expected: withstartup: falseit registers at boot and spawns per-user on first use in a conversation. - Members' chat usage bills per-member via their endpoint key; the MCP image path is currently keyless and bills to the gateway (not enforced per-member). Wiring per-member key injection into LibreChat chat is an open task.
- Repo ↔ live parity last verified 2026-10-19:
librechat.yaml,mcp/server.py,mcp/image_server.pymd5s match the live volume; overlay imageregistry.inference.coop/co-op/librechat-custom:20261019-fc9b11eis deployed (container asset md5s match this repo).
PWA lessons (2026-10-22 — icon/band debugging)
- Builder COPY layers can be stale: after editing any asset, build with
--no-cacheand VERIFY the served file's md5 against the repo before reporting success. (The 20261022-df55d7f build shipped a stale maskable icon this way — the repo was right, the image wasn't.) - PWA icon URLs must carry a per-build
?v=: they are served withmax-age=172800, and remove/re-add of the PWA does not clear the phone's HTTP cache — launchers keep rendering the previous build's icon bytes. patch_index.py injects the query into every manifest icon src per build. - Maskable icons: logo must sit inside the safe zone — ~66% of the canvas on a solid brand background. Full-bleed logos get cropped ("zoomed in").
- Firefox Android:
crossorigin="use-credentials"on the manifest link breaks icon resolution (blank launcher icon) — patch_index.py strips it. - Theme bars: the manifest
theme_coloris one static value; Chromium's nav bar and all Firefox bars use it even when the meta says otherwise. The durable fix isviewport-fit=cover+ standalone-mode safe-area padding (custom.css) so the bands show the theme-correct page background; custom2.js additionally syncs one metatheme-colorto the app's resolved theme.