Containers + data flow
The picture
The diagram source lives at
src/assets/diagrams/architecture-overview.d2; rebuild via
d2 --layout=elk --theme=0 src/assets/diagrams/architecture-overview.d2
src/content/docs/diagrams/architecture-overview.svg.
The chain in one paragraph
The operator forwards a receipt to Discord. The bot OCRs it
via LlamaParse, stores the bytes in R2 and the structured
fields in Turso, embeds the text via OpenAI, then runs the
matcher to look for an existing unmatched bank transaction.
Meanwhile the webhooks container polls Enable Banking
every 6 hours and writes new bank transactions into Turso,
running the matcher in the other direction. When a match crosses
HITL_THRESHOLD, the bot opens a checkpoint and posts a Discord
prompt. On approve, the row goes to booked status. The booker
daemon ticks every 15 min, finds booked rows without an
efin_entry_id, calls vat_decision (which queries Turso's
tax_rules + the FalkorDB knowledge graph and reasons via an
LLM), and writes the entry to e-Financials. Done.
What's where
| Container | What it does | What it owns |
|---|---|---|
prod-accounting-bot |
Discord bot, HITL prompts, chat agent (/ask), all slash commands, ingest pipeline |
Discord token, the chat agent's lifecycle, the doc_match + booker_approve + client_create_approval prompt loops |
prod-accounting-webhooks |
FastAPI for Stripe webhooks, OAuth callbacks; runs the Enable Banking poll loop in-process | EB session lifecycle, Stripe webhook signature verification |
prod-accounting-booker |
Booker daemon — picks up approved recons, writes to e-Fin | The e-Fin client connection, idempotent re-book logic |
All three containers run the same image with different CMDs.
That keeps deploys symmetric: one image push → all three update.
Why three containers
- Bot needs the Discord WebSocket open 24/7. It can't be a cron because Discord's gateway is persistent.
- Webhooks needs an HTTP port open for Stripe + OAuth callbacks. It also runs the EB poll loop (cheap enough to share the process).
- Booker runs writes that take ~1-2 seconds each and are rate-limited by e-Fin. Putting it in the bot would block the Discord event loop; putting it in webhooks would couple cron to HTTP traffic. A dedicated daemon is the right surface.
What runs where else
- Production secrets: Infisical (path:
/companybot/agent, env:prod). All three containers wrap their CMD withinfisical runto inject secrets at startup. - CD: GitHub Actions builds + pushes to Google Artifact
Registry;
dockhand(also running on this VPS) watches the registry and redeploys when a new tag lands. - Observability: Logfire (OTLP → LGTM stack on the VPS).
- Backups: Turso is hosted; R2 has versioning enabled; FalkorDB is rebuildable from the source PDFs in R2.