The three deploys
The agent runs as three containers off the same image, each
with a different CMD and a different lifecycle. This page is the
ops-side counterpart to the architecture overview.
The three CMDs
prod-accounting-bot python -m accounting_agent.discord_bot
prod-accounting-webhooks python -m accounting_agent.webhooks
prod-accounting-booker python -m accounting_agent.booker_daemon
All three:
- Run on the
prod_networkDocker network (talks toprod-postgres,prod-redis,prod-falkordb, the shared LGTM stack, etc.). - Run on the
cf_networkso Traefik can route to them where ingress is needed (only the webhooks container has an HTTP surface). - Pull secrets at startup via
infisical runwrapping the actual command — Infisical project + path + env are injected as three vars; everything else comes through the wrapper.
Bot container
Surface: outbound Discord gateway (WebSocket, kept open 24/7).
What it does:
- Receives Discord messages + interactions
- Runs the ingest pipeline when a PDF is forwarded
- Runs the chat agent for
/ask - Runs the
BookerHitlPoller— every 300s scans forbookedrows needing prompt + pendingclient_create_approvalcheckpoints needing posting - Hosts every slash command (
/pending,/book,/rules,/setkind, etc.)
Restart cost: ~5 sec to reconnect to Discord gateway.
Webhooks container
Surface: inbound HTTPS (FastAPI, port 8000) behind Traefik.
What it does:
- Stripe webhook handler (signature-verified)
- Enable Banking OAuth callback handler
- A background asyncio task running the EB poll loop every ~6 hours (free-tier AIS quota math: 4 polls/day × 30 days = 120 calls/month, comfortably under 100/account limit)
Restart cost: ~2 sec to start FastAPI; ~6 sec until the first
EB poll fires (if run_immediately=True).
Booker daemon container
Surface: none. Pure outbound (talks to Turso + R2 + FalkorDB + e-Fin).
What it does:
- Wakes every 900s (15 min)
- Calls
book_all_pending— scansbookedrows whereefin_entry_id IS NULLand processes up to 50 per tick - Each row goes through:
find_by_name(client lookup) →vat_decision(rate) →_build_create_payload→ create → update_totals → attach PDF → register - Stamps
efin_entry_idon the recon row when done
Restart cost: ~3 sec. Daemon catches SIGTERM and finishes the in-flight row before exiting (no half-booked entries).
Why split this way
The original design had everything in the bot container. Two problems surfaced:
- Discord gateway latency. A
book_onecall blocks for 3-5 seconds on the e-Fin round-trip; doing that on the bot's event loop made the bot stop responding to other Discord events during books. - Cron coupling. Putting the booker on a cron in the bot container meant cron failures (image restart mid-tick) corrupted the Discord connection state.
The booker as its own container fixes both. Separation also lets us scale the booker independently (more frequent ticks, higher batch size) without touching the bot.
Shared state
All three containers share Turso (the same libsql://... URL +
auth token in Infisical). The reconciliation table is the lingua
franca:
- Bot writes
proposedrows (when matching) andbookedrows (when operator approves via HITL). - Webhooks writes new
unmatchedrows (when EB poller sees a new transaction). - Booker reads
booked AND efin_entry_id IS NULLand writesbooked AND efin_entry_id=<id>.
The state machine prevents collisions: each transition has a single owner. See data-model for the full machine.
Resilience contract
Each container is restartable at any time:
- Mid-OCR? The Turso doc row stays at
parsedstatus; next bot start picks it up. - Mid-EB-poll? The high-water mark only advances on success;
next webhooks start re-polls the same window. UNIQUE constraint
on
(source_kind, source_reference)handles duplicates. - Mid-book? The notes-marker dedup
(
source_kind:source_referencein the e-Fin entry'snotesfield) lets the next booker start find the half-created draft and resume fromupdate_totals.
No state lives in container memory that isn't recoverable from Turso + R2.