The env file first landed with ESPERANTO_TTS_TIMEOUT equal to SHIM_TIMEOUT=600.
Equality is a race: both budgets expire at the same instant, so the case the
number existed to prevent — the worker abandoning a synthesis frank-tts is still
finishing — is exactly what happens on the tail of a long line. Nested budgets
go inside-out, which is the rule scripts/frank-tts.env states for its own
LLAMA_TIMEOUT; the client waits one round-trip longer than the server, so a
frank-tts give-up arrives labelled as frank-tts's error. 660.
The seam's assertion moves from equality to `>` for the same reason, and now
also holds ESPERANTO_LLM_TIMEOUT under upstream's 600 s web-UI patience — above
it the failure surfaces as a UI timeout with no name on it. The scriptwriting
number itself stays at the app's 180 s default: frank-zealot answers an outline
or a transcript well inside it, which corrects the earlier commit message's
claim that both timeouts were raised past upstream's 300 s.
Page registered in the vault index (the docs-completeness seam fails an
unregistered page) and logged in the bundle change log.
What the page owes the reader: the provider contract (frank-zealot for scripts
with a /v1 base URL, frank-tts for voices with 1-4 speakers mapped to the
narrator1/2/3 voice packs typed as free text), why Test Connection and Discover
Models fail cosmetically against frank-tts rather than being a fault, the
encryption key as the precondition for saving any credential, and the honest
blocker — frank-tts is not deployed and nothing listens on 7772, so a generation
run writes its script and fails on the first voice.
Episode destination is stated as interim and cites Gitea issue #7 (human-only,
agents must not work it): the app keeps its named volume, one bind mount puts
finished MP3s under /home/dank/media/podcasts, and deleting that one line is the
whole rollback. Rollback section also covers the full undo, including that the
podman network only disappears once the units are gone.
Structure only: what the units declare and how the scripts route them, never how
the app behaves inside a container. 19 tests.
The ones carrying real weight: the app unit has no publishable 8000 anywhere and
the database unit publishes nothing at all; the API remap off host 5055 is only
correct if API_URL names the same host port, so the seam parses the PublishPort
and compares; each unit reads the repo tunable file before the host secret file,
because podman's last-match-wins makes the order load-bearing; both health
probes are argument-only (a shell string there is a unit that starts dead); the
database password appears only as an env reference, never in Exec=; and the
episode host bind appears exactly once, since a second mount would silently
shadow the first.
The deployer assertions read the functions themselves: the gate URL path is
/health on the remapped port and must not name 5055, and the seed function
contains the blank-secret refusal plus the no-clobber test. No test asserts a
made-up endpoint or a cloud URL, and nothing touches the network.
The case has to sit where it does: the dankclaw/*.container glob's `*` crosses
`/`, so placed below it, dankclaw/open-notebook/quadlet/open-notebook.container
matched that generic case and ran the open-webui/pihole/searxng seams instead of
its own. Same reason the env path is grouped here — scripts/*.env would otherwise
send it to the generic env seams.
Internal-only: port 8502 direct, no vhost field, no Caddy change — exposure
stays behind an explicit decision, and the app carries its own password.
urlStatus=hidden is deliberate: the homepage probe runs on the gateway and the
pair is not deployed, so a live card would render dead on day one. Flipping it
to live is the only homepage change the deploy needs; the seam asserts the
hidden state while the service is repo-only and requires the required card
fields either way.
mono-on.svg follows the mono-* icon set the sibling cards use, so the card does
not fall back to the generic dot.
Registration is load-bearing, not cosmetic: deploy-plan.py refuses any service
it does not know, so SERVICE_ORDER + DANKCLAW_SERVICES are what let `--only` and
`--auto` address the pair at all; the dispatch case in deploy-remote.sh runs it;
the requirement in check_prerequisites gates a manual deploy; and the dispatch
regex in deploy.sh's host-mode branch decides whether a changed path dispatches
a service or falls through to "no service mapped" — an omission there is silent,
which is exactly how the AGENTS.md deployer-registration law was earned.
The deploy seeds /home/dank/Containers/open-notebook/env (0600) from the host
env and refuses to start the pair while OPEN_NOTEBOOK_ENCRYPTION_KEY or
OPEN_NOTEBOOK_PASSWORD is blank: without the key no provider credential saves
and without the password the API — including the episode-audio route — is open
to the LAN with no auth. A seeded file is never rewritten, so re-deploying
cannot clobber a rotated key, which would silently make every stored credential
undecryptable. SURREAL_PASS is derived from SURREAL_PASSWORD in the same write.
The gate names paths that answer, not invented ones: the database is judged by
its own container health status (/surreal is-ready, verified against the pinned
digest) and the app by wait_for_health on /health at the published 5056 mapping —
the route is upstream's and is on the auth middleware's exclude list, so it
stays 200 after the password is set. 180 s covers Next.js build + startup and a
degraded-but-listening first boot, matching the sibling LLM deploys.
Both digest pins are checked against local storage before restart, because
Pull=never means a missing image would otherwise surface as a unit stuck in
"Start request queued" (the 203/EXEC trap's sibling). The interim episode
directory is created before the app starts: a missing bind source makes podman
create it as root-owned and the app's mkdir would then fail.
The two knobs that keep the box alive: TTS_BATCH_SIZE=1 and
OPEN_NOTEBOOK_WORKER_MAX_TASKS=1. Upstream ships 5 x 5; frank-tts spawns one
~8 GiB, 8-thread engine process per synthesis request on a machine that also
serves two LLMs, so a five-way fan-out is a self-inflicted shutdown. The worker
knob is real, not decorative: the image's supervisord expands
${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5} into the worker command
(supervisord.conf:25).
Both ESPERANTO_* timeouts go above upstream's 300 s defaults, sized for CPU
synthesis (SHIM_TIMEOUT=600, LLAMA_TTS_THREADS=8) — one episode is dozens of
per-line /audio/speech calls and a timeout kills the episode, not the line.
API_URL is forced for the 5055 -> 5056 remap; SURREAL_URL addresses the database
by container name; CORS_ORIGINS is origin-form (scheme://host:port), the form
the app compares against. OPEN_NOTEBOOK_PASSWORD stays absent here on purpose:
the app treats a set-but-empty value as auth-on.
Secrets stay placeholders in the tracked file and in dankclaw/.env.example, which
lists SURREAL_PASSWORD rather than SURREAL_PASS — the deployer derives the
surreal CLI's spelling from it when it seeds, so the app and the database cannot
be handed different values.
Two containers, because that is what upstream runs (docker-compose.yml:6,13):
the app image under supervisord, plus SurrealDB. The `v1-latest-single` tag is
not in play — it bundles its own database, is deprecated upstream, and would
give the host a second hidden DB.
Both images are digest-pinned with Pull=never and a documented one-time pull,
the frank-dragoon posture: a Quadlet on :latest re-resolves on the first restart
after an unrelated daemon-reload, and the failure is silent.
Port map paid for by what is already on the host: seerr holds 127.0.0.1:5055
(since 2026-10-14, live `ss -ltn` agrees), so the API leaves on 5056 and the env
file has to set API_URL to match, because the frontend derives the API URL from
the window origin when it is unset. 8502 publishes on 0.0.0.0 — the service
carries its own password and the episode-audio route is unauthenticated. 8000 is
published nowhere, where upstream exposes it on loopback with root credentials.
The app joins dankclaw_default first and the private systemd-open-notebook
second: it reaches the published fleet endpoints through the former
(host.containers.internal, the arrangement Prometheus is proven against) and
finds its database by container name through the latter. Generated --network
flags keep file order, so the tunable file is listed before the host secret file
in both units — podman applies last-match-wins.
No UserMapping: the app runs as root in its namespace (no USER in the Dockerfile)
and writes only paths owned by that root. Exec= carries no database credential —
`exec start --user --pass` puts it in world-readable argv; the image's own
entrypoint passes it on stdin, so only the container env holds it.