DECISIONS — Aquila POC
Living log. Every locked decision, branch, and reversal is appended here with date + rationale. Never delete a decision; supersede it with a new entry that references the old one.
Convention: D### — Title · Status: locked | pending | superseded by D### · Date: YYYY-MM-DD
D001 — Project name is Aquila
Status: locked · Date: 2026-09-16
Named after the Aquila constellation (the eagle; carries Zeus's thunderbolt in myth). Chosen for: NASA-style celestial naming, two syllables, easy to pronounce across India, new-age without being generic. Earlier candidate "TARA" (Task & Reminder Agent / तारा = star) was retired in favour of Aquila.
- Repo:
aquila(this repository) - Product site:
aquila.exaflair.com(static homepage +/docs) - User microsites:
<phone>.aquila.exaflair.com(the app; wildcard) - End-state (post-POC):
<phone>.aquila.com— everything domain-agnostic viaBASE_DOMAINenv exaflair.comDNS stays on Hostinger — no nameserver migration
Rationale: Bhavya owns exaflair.com; we get a subdomain delegation without touching existing Vercel CNAMEs, MX records, or other services.
D002 — POC runtime environment
Status: locked · Date: 2026-09-16
- One laptop already in the deklo network (Bhavya's, 24×7, SSH + sudo + Docker available).
- ~8GB resource budget carved out for Aquila, plain Docker Compose — no k3s, no Knative.
- The POC does not touch the deklo production cluster (node-b2 is an EOL/eMMC SPOF; the cluster cannot run non-HTTP workloads under Knative — a gap this POC routes around, not fixes).
- Every workload stays K8s-mappable (single container, env-driven config, volume-backed state)
so Rohan can absorb the shapes later; mapping notes live in
deploy/k8s/.
D003 — Bot runtime: build our own minimal runtime (core/)
Status: locked · Date: 2026-09-16
Options compared (full comparison at docs/comparisons/runtime.html):
| Option | Verdict |
|---|---|
| Build minimal own runtime | CHOSEN. ~600-line FastAPI service: persona, chat loop, SQLite memory, APScheduler routines, LiteLLM client, tiny tool layer. ~100–150MB idle RAM. Full control, zero licensing surprises, clean metering hooks. |
| Port OpenMausBot | Rejected: Mac-first days-old app, rides desktop CLI logins (can't ship to customers), Linux build "coming". Kept as UX reference only. |
| Adopt nanobot (HKUDS) | Kept as reference codebase + fallback if our build slips. |
| LimeBot-OS / Daemora / OpenClaw | Rejected: RAM footprint (Chromium/LanceDB/Electron-class), unofficial WhatsApp bridges, or AGPL constraints kill the ₹250 economics. |
The user (Bhavya) reviewed the comparison HTML and chose the build path.
D004 — Channels: Telegram + web microsites now; WhatsApp later
Status: locked · Date: 2026-09-16
- Telegram (official Bot API) is the primary chat + notification + routine-delivery channel.
- Web microsite per phone number is the owned surface (workspace, bot management, chat fallback).
- WhatsApp official Cloud API: Phase 2 only, notification-only (structured daily briefing) — because (a) Meta banned open-ended AI assistants on the WhatsApp Business Platform in Jan 2026, (b) from 1 Oct 2026 even in-window service messages become billable per message.
- Unofficial bridge (Baileys/whatsapp-web.js): kept as a later TODO with explicit ban-risk acknowledgment (Bhavya accepted it as a future demo lever, not in the POC critical path).
One Aquila Telegram bot multiplexes all personas (persona-attributed messages). Per-bot Telegram identities need manual BotFather work per bot — deferred; Vestra does the same with one "Bash" bot.
D005 — Inference gateway: LiteLLM proxy, per-bot virtual keys
Status: locked · Date: 2026-09-16
gateway/runs LiteLLM proxy as the single LLM entry point. OpenAI-compatible.- POC upstream chain: OpenCode Go (auto-detect API access from local opencode config at build time) → OpenRouter free / high-limit models as fallback or primary.
- Per-bot virtual keys with monthly ₹ budgets + spend logs — this is the "packaged inference" metering that the economics report depends on.
- Production chain documented (not wired for POC): DeepSeek V4 Flash (primary) → GLM-5.3-Flash → OpenRouter free; cron/batch on GLM-4.7-Flash (free); optional on-node Ollama tier.
- Full comparison at
docs/comparisons/llm.html. Note: on-node Ollama costs ~3GB RAM ≈ 2 bots of density; off by default, measurable later.
D006 — Identity: phone number = account; Telegram OTP
Status: locked · Date: 2026-09-16
- Every unique phone number gets a pre-provisioned microsite at
<phone>.aquila.exaflair.com— "your assistant's home is already waiting". - Login: Telegram deep link / QR → native contact-share proves number ownership → OTP/session issued → workspace unlocked. No passwords, no email signup, no SMS cost.
- Bots reach the user in Telegram; QR + deep links bridge web → Telegram at any point.
- Phone format in subdomain: digits only, no
+(e.g.919876543210.aquila.exaflair.com).
D007 — Static site + CI: mirrored from ~/exa/design-context
Status: locked · Date: 2026-09-16
- CI copied from design-context: GitHub Actions → Vercel CLI, single
VERCEL_TOKENrepo secret (team-scoped, same Exaflair teamteam_iYoLtkqKNe5vg7z3GO98HcPF), prod onmain, PR previews with sticky comment, project nameaquila. - Static site = homepage (
site/) + docs (docs/*.mdrendered to/docs) + brand; built byscripts/build.mjsinto generatedpublic/(never hand-edited, gitignored). One dep added:marked(MD→HTML); everything else zero-dep. - Deployment quirks preserved from design-context: no committed
.vercel/,vercel link --yes --project aquila --team ...first,outputDirectory: public. - Wildcard
*.aquila.exaflair.comdoes not go through Vercel: Hostinger A record → relay VPS (Caddy on-demand TLS) → persistent tunnel → laptop app. - Relay decision: bengaluru-svc if Rohan fixes ufw #66, else a fresh small VPS (₹~500/mo).
Provisioning checklist in
PLAN.md§7. Until then, laptop is reachable over Tailscale/LAN for development; public demo gated on the relay. - Site is
noindexfor the POC (unlisted, like design-context). Revisit at launch.
D008 — POC personas (and the wider catalog)
Status: locked · Date: 2026-09-16
Ship 3 personas + baseline companion:
- Inbox Munshi — IMAP sweep → Hinglish digest on Telegram → drafted replies to copy. (Founding call: "the killer app is email".) Tool: IMAP read.
- Udaan — daily 7:30am one-idea briefing for the owner's trade/city. Tool: web search (optional).
- DaamDekho — watches named product pages 2×/day, alerts on price drops. Tool: web fetch.
Full 18-bot catalog with ranking, packaging and trap list: docs/killer-apps.md (from the
killer-apps research subagent, 2026-09-16).
Safety posture (locked): detect → summarize in Hinglish → deliver on Telegram → draft the reply
for the human to send. The bot never sends email, never touches money, never files anything.
Traps explicitly out: WhatsApp auto-reply, GST filing, auto-payments, government-portal automation,
bank scraping. See docs/killer-apps.md §4.
D009 — Success metrics for the POC
Status: locked · Date: 2026-09-16
- Density: ≥10 live bots on the 8GB slice; idle ≤250MB/bot; platform ≤1.5GB.
- Latency: p95 first-token < 4s on chat (gateway hop included).
- Reliability: 12h soak with all routines firing on schedule, zero intervention.
- Economics: measured ₹/bot/month at a defined usage profile (30 msgs/day + 1 routine/day), verdict modeled at ₹250 vs ₹299 pricing, at 10/15/20 bots per 16GB mini PC.
- Demo: 5-minute scripted flow, end-to-end, on
aquila.exaflair.com+ Telegram.
Bhavya: "use your recommended baseline, adjust as we go."
D010 — Build process: docs-first, orchestrator, reviewed deliveries
Status: locked · Date: 2026-09-16
- Docs-first: repo, contracts (
AGENTS.md), plan, CI and scaffolds land before any workstream agent runs. - Orchestrator: the main agent (this session) owns integration, review, merges, and the decision log. Workstream agents own directories, not the repo.
- Review gates: every delivery = branch → orchestrator review + smoke tests → merge to
main. Twice-daily integration checkpoints. - Workstream agents run on DeepSeek V4.x Flash at max reasoning effort (Bhavya's instruction);
configured in
opencode.json+.opencode/agent/. - 6 workstreams: WS1 core · WS2 gateway · WS3 manager+microsites · WS4 telegram · WS5 homepage · WS6 docs/e2e/economics (orchestrator-owned).
D011 — Repo layout (monorepo)
Status: locked · Date: 2026-09-16
core/ # WS1 — per-bot runtime container
gateway/ # WS2 — LiteLLM proxy config + metering API
manager/ # WS3 — FastAPI control plane + microsites (Jinja, no JS build)
channels/telegram/ # WS4 — Aquila Telegram bot: OTP, chat multiplex, delivery
deploy/ # orchestrator — compose stack, relay/Caddy, k8s mapping notes
site/ brand/ docs/ scripts/ # WS5 — static homepage + docs (build → public/)
e2e/ # orchestrator — smoke tests + density harness
opencode.json, .opencode/agent/ # agent config (DeepSeek Flash, max effort)
D012 — Open decisions (pending, tracked)
Status: pending · Date: 2026-09-16 · Updated: 2026-09-16 evening
- p1 — RESOLVED: relay = bengaluru-svc (
ssh aquila@139.84.146.236). It is a shared k3s-agent node; additive-only changes, never touch ufw rules below new ones. Host has no nginx/caddy, 80/443 free, ~2.5GB RAM available. - p2 — RESOLVED: GitHub
git@github.com:deklo-in/aquila.gitcreated;VERCEL_TOKENsecret added;mainpushed (CI run pending verification on the repo's Actions tab — repo is private). - p3 — Partially resolved: OpenRouter key ✅ (in local
.env, never committed). Telegram bot token and test Gmail app password still pending from Bhavya. - p4 — RESOLVED: no OpenCode Go API base/key exposed → gateway renders with OpenRouter free
promoted to
aquila-default(D019). OpenCode Go slots in via env later. - p5 — WhatsApp unofficial bridge demo — later TODO (post-POC), ban-risk acknowledged.
- p6 — Per-bot Telegram identities (BotFather scale problem) — post-POC.
- p7 — Domain-agnostic cutover
<phone>.aquila.com— post-POC.
Day-1 workstream deliveries (renumbered from agent proposals — numbering is orchestrator-owned)
D013 — Bot memory: SQLite WAL + FTS5 recall + rule-based facts
Status: locked · Date: 2026-09-16 · Source: WS1
No embeddings, no extra deps. Facts extracted by deterministic heuristics ("yaad rakho:" / name /
business / city patterns). Recall + facts injected into the system prompt char-budgeted at 8k chars
(≈2k tokens). imap_last_uid watermark lives in a state kv table so read-only IMAP behaves as
"since last run". Rationale: zero marginal cost, works offline, and the POC's economics depend on
prompt size discipline.
D014 — Tool layer: exactly four tools via OpenAI function-calling loop
Status: locked · Date: 2026-09-16 · Source: WS1
web_search (SearXNG), web_fetch (stdlib HTML strip + SSRF guard: http/https, ports 80/443, no
private/loopback), imap_read (imapclient, read-only, BODY.PEEK[], UID watermark — never sends,
never marks read), calculator (AST sandbox, no eval). Max 4 tool rounds; results truncated to 4k
chars. Tool calls are logged and folded into text (not emitted as SSE events — stream shape stays
frozen {delta}/{done}).
D015 — Bot admin surface requires the shared internal token
Status: locked · Date: 2026-09-16 · Source: WS1
PUT /persona and POST /routine/{name}/run require X-Internal-Token (or bearer) when
AQUILA_INTERNAL_TOKEN is set. /chat stays open only on the isolated compose network. Manager and
telegram send the header.
D016 — Image-size metric convention
Status: locked · Date: 2026-09-16 · Source: WS1
Report both on-disk and registry-compressed sizes plus idle RAM. python:3.11-slim baseline
(~200MB disk / ~49MB compressed) is the floor for all workstreams; the earlier ≤180MB-disk target is
dropped as unreachable. Idle RAM targets stand (bot ≤250MB; achieved ~39–55MB).
D017 — Routine delivery contract
Status: locked · Date: 2026-09-16 · Source: WS1
Core POSTs {"bot_id","routine","text"} to $DELIVERY_WEBHOOK with optional X-Internal-Token;
unset webhook = log-only (dev mode). Manager exposes the receiver and forwards to telegram
/internal/send.
D018 — Gateway metering: LiteLLM custom_auth + CustomLogger, not native virtual keys
Status: locked · Date: 2026-09-16 · Source: WS2
LiteLLM's native virtual keys/budgets require Postgres (absent in the POC). LiteLLM stays the
routing engine; general_settings.custom_auth validates BOT_LLM_KEY against gateway.db, and a
custom logger parses standard_logging_object into gateway.db + spend.jsonl. Native keys can be
adopted when Postgres arrives (k8s phase).
D019 — Upstream selection is rendered at container start
Status: locked · Date: 2026-09-16 · Source: WS2
config.yaml carries env refs; deployments whose aquila_requires env is absent are pruned and
OpenRouter free models are promoted into aquila-default / aquila-cheap — absence of credentials
means zero failed attempts, no wasted latency. Effective config inspectable at
gateway/data/effective_config.yaml.
D020 — Budgets are calendar-month IST with community FX (USD_INR, default 88)
Status: locked · Date: 2026-09-16 · Source: WS2
Exhaustion → 429 + Retry-After (until next IST month start) + X-Aquila-Budget-* headers; core
surfaces the Hinglish message. DB failure = fail-closed 503. Spend rows carry precomputed IST month
(no counter races).
D021 — Manager owns bot LLM key lifecycle
Status: locked · Date: 2026-09-16 · Source: WS3
POST /api/bots mints the gateway virtual key (BOT_BUDGET_INR), stores it on the bot row; delete
revokes it. Gateway admin endpoints are internal-token-only, never exposed to users.
D022 — Infra failure semantics for bot CRUD
Status: locked · Date: 2026-09-16 · Source: WS3
Never 5xx for infra hiccups: persist status:"error" + Hinglish message, retryable via Start.
Delete = soft-delete row (audit) + container/volume/key teardown.
D023 — Persona seeding contract
Status: locked · Date: 2026-09-16 · Source: WS3
Manager passes PERSONA_KEY + PERSONA_FILE=/data/persona.md; core seeds from its bundled catalog
on first boot; persona read/write proxied through manager (PUT admin-only per D015). PERSONA_KEY
is an additive env beyond the frozen §2.2 list.
D024 — Manager ships a mirrored brand token file
Status: locked · Date: 2026-09-16 · Source: WS3
manager/app/static/tokens.css mirrors brand/tokens.css because the image build context is
manager/. brand/tokens.css remains source of truth; drift risk accepted for POC (revisit with a
build step).
D025 — Session cookie: per-subdomain, HMAC-hashed at rest, DB-revocable
Status: locked · Date: 2026-09-16 · Source: WS3
Random token, HMAC(session_secret) stored; HttpOnly SameSite=Lax; COOKIE_SECURE flips Secure
behind the relay. Sessions revocable server-side.
D026 — Telegram via raw Bot API over httpx (no bot framework)
Status: locked · Date: 2026-09-16 · Source: WS4
~10 API calls needed; smaller image (225MB), uvicorn-native, offline-testable with fakes.
D027 — Signed login nonces minted by telegram /internal/deeplink
Status: locked · Date: 2026-09-16 · Source: WS4
v1-<phone>-<exp>-<hmac16> (fits Telegram's 64-char start payload); contact verification offline,
opaque nonces resolved via manager GET /internal/otp/challenge/{nonce} as fallback.
D028 — Chat reply protocol: SSE stream → single-message edit with throttle
Status: locked · Date: 2026-09-16 · Source: WS4
Manager streams SSE {delta}→{done}; telegram edits one placeholder message (1.5s throttle),
splits finals at 4000 chars; Hinglish fallbacks for 429/404/errors.
D029 — Telegram webhook secret derivation
Status: locked · Date: 2026-09-16 · Source: WS4
TELEGRAM_WEBHOOK_SECRET defaults to sha256(AQUILA_INTERNAL_TOKEN)[:32]; set_webhook.sh derives
it identically. Webhook endpoint always 200s (background dispatch) to avoid Telegram retries storms.
D030 — Homepage v2
Status: locked · Date: 2026-09-16 · Source: WS5
Single-file, mobile-first, honesty-first landing: hero / how / personas / day / pricing (Shuruaat 250, Dhanda 299, Ustaad 499) / safety never-always / FAQ / CTA; one CTA intent "Find my star"; chat preview labelled as sample; no raster assets; reduced-motion + no-JS fallbacks; verified in headless Chrome at 1440 and 390.
D031 — Brand token v2
Status: locked · Date: 2026-09-16 · Source: WS5
Gold = action accent only, constellation blue = links only; em-dash ban in copy; new semantic, focus,
elevation and motion tokens; light-theme AA contrast fixes (#8A6008 gold, #5B6580 faint).
D032 — Relay runs as an additive guest on bengaluru-svc
Status: locked · Date: 2026-09-16 · Source: orchestrator recon
The relay VPS is a live k3s-agent node of the deklo cluster. Rules: static Caddy binary + systemd unit
(no apt repo changes), only additive ufw rules if required (allow 80,443 — never touching existing
SSH/mesh rules), no k3s workloads touched, resource ceiling ~200MB. Any change that could affect the
cluster stops and goes back to the team first.
D033 — OpenRouter free-tier reality + the tool-support constraint
Status: locked · Date: 2026-09-16 · Source: orchestrator live probe
Discovered while bringing the stack up (all verified against the live catalog, 2026-09-16):
deepseek-v4.1-flash:free,glm-4.7-flash:freeno longer exist;glm-5.2:freeexists but does not support tool calls — and bots always sendtools(web_search etc.), so non-tool models 404 with "No endpoints found that support tool use".- Tool-capable free picks (probed):
inclusionai/ling-3.0-flash-vl:free(primary),cohere/north-mini-code:free(secondary) foraquila-default;nex-agi/nex-n2.5-pro:freeforaquila-cheap. Wired ingateway/config.yaml. - Current OpenRouter key balance: $0. Paid routes fail until topped up. Recommendation (Bhavya): add ~$5 credit — at the usage profile (30 msgs/day, ~4k in / 400 out per msg) paid DeepSeek V4.1 Flash costs ≈ ₹66/bot/month ($0.15/$0.60 per 1M), which lands inside the ₹60–80 inference budget for a ₹250 bot. Keep the free chain as fallback either way.
- Free-tier attrition is a standing risk for the POC: the gateway is config-swappable, so rotation is a 2-line edit + restart — no code changes.
Proof (live, 2026-09-16): bot container aquila-bot-day1check (persona=nova) → gateway
(auth + ₹ budget + spend attribution) → OpenRouter → streamed Hinglish reply over SSE;
spend rows attributed per model (cohere/north-mini-code:free, nemotron-3-super... transient
502 from Nvidia during probing), tokens metered correctly.
D034 — Persona naming v2: celestial backronyms (supersedes D008 display names)
Status: locked · Date: 2026-09-16 · Source: Bhavya
The founding-call register (munshi/udaad/saathi) reads dated to Bhavya; product personas are now celestial + backronyms, matching the Aquila brand (stars that work while you sleep):
| Key | Name | Backronym | Job |
|---|---|---|---|
vega |
VEGA | Virtual Email Guardian Assistant | Inbox digest + reply drafts (was Inbox Munshi) |
nova |
NOVA | Notifications Of Valuable Advice | Daily 7:30am business idea (was Udaan) |
pulsar |
PULSAR | Price Updates & Low-price Signals, Alert Radar | Price watch 2×/day (was DaamDekho) |
sirius |
SIRIUS | Smart Intelligent Reminder & Insights Utility System | Baseline companion (was Saathi) |
Applied across core/personas/* (files renamed), manager/app/personas.py, site cards (each card
shows the backronym), tests, briefs and docs. The wider killer-apps catalog keeps its Hindi
archetype names until promoted to products; POC personas are the four above. Follow-up naming
pattern for new bots: constellation/star + backronym (e.g., ORION, LYRA, ATLAS).
D035 — Voice: English only, classy. No Hinglish anywhere user-visible
Status: locked · Date: 2026-09-16 · Source: Bhavya
"Indian doesn't mean Hinglish and clumsy." All user-facing copy — personas, Telegram messages,
manager microcopy and errors, site, docs — is English, plain and warm. No Hinglish, no em/en
dashes (brand rule), no decorative emoji beyond the brand star. Persona bodies carry
language: english. The "yaad rakho" pattern is tolerated as input recognition only, never as
output. Tests no longer pin copy strings verbatim where avoidable (L04).
Scope applied: 4 personas rewritten, Telegram copy module rewritten, 30+ manager strings and templates translated, core budget/error messages translated; all suites re-run green (the six suites that pinned old strings were updated as part of the pass).
D036 — Agentic setup v2: expert skills, reviewer/manager agents, learning loop
Status: locked · Date: 2026-09-16 · Source: Bhavya ("install reputed skills… keep improving autonomously")
- Skills installed (21): all 14
superpowersskills (systematic-debugging, TDD, verification-before-completion, requesting/receiving-code-review, dispatching-parallel-agents, subagent-driven-development, writing-plans/skills, etc.) + curatedanthropics/skills(webapp-testing, frontend-design, skill-creator, canvas-design, web-artifacts-builder, mcp-builder, doc-coauthoring), plus the existingtaste-skill. Location:~/.claude/skills/(auto-loaded by opencode for every project). - Reviewer agent (read-only): contract/test/copy/brand/security/resource/honesty checklist with a fixed report format; blocks merges. Manager agent (edits): resolves findings, keeps the stack verified, maintains the improvement log; never commits.
- Commands:
/review <subject>and/retroformalise the loop. - Learning log:
docs/agent-improvements.md(L01–L08 seeded from Day-1 lessons); briefs and ground rules evolve from its entries, not from memory. - Orchestrator persists as the only actor that commits/pushes and renumbers decisions.
D037 — Per-user credentials live in an encrypted connections table, never in .env
Status: locked · Date: 2026-09-16 · Source: Bhavya's scale-alignment question
.env holds platform secrets (Telegram token, LLM provider keys) and, for the POC, ONE demo
tenant's mailbox so the VEGA flow is demonstrable immediately. The product pattern is per-user:
connections(user_id, provider, secret_encrypted, meta) with AES-GCM at rest under
AQUILA_ENCRYPTION_KEY (KMS envelope encryption in production). Production migrates Gmail to
OAuth (gmail.readonly, refresh tokens encrypted the same way); app passwords remain the fallback
for non-Gmail IMAP. Full architecture: docs/multiuser.md.
D038 — Bot credential injection: env at spawn (POC) → short-lived fetch at boot (target)
Status: locked (POC shortcut recorded) · Date: 2026-09-16
POC: manager decrypts the owner's connection and passes IMAP_* into the bot container env at
spawn (visible to docker inspect; acceptable inside the isolated compose network for the POC).
Target: bot authenticates to the manager with its own token at boot and fetches a short-lived
credential; secrets never land in container config. Recorded so the reviewer can flag regressions.
D039 — Control-plane DB path: SQLite now, Postgres (CNPG) next — same schema
Status: locked · Date: 2026-09-16
POC runs SQLite WAL (adequate to ~100 bots/box, zero ops). Scale target is the platform's existing CNPG Postgres, with Redis for ephemeral state (OTP/rate limits/session cache) and Garage S3 for artifacts. Schema is written SQL-first with a versioned migration runner so the driver swap is not a redesign.
D040 — Onboarding is phone-first, then connect-services, then pack selection
Status: locked · Date: 2026-09-16
<phone>microsite → 2) Telegram OTP sign-in → 3) connect inbox (OAuth, skippable) →- pick a pack (Dukaan/Vyapari/Service/Firm) → 5) bots provisioned with the owner's connections →
- billing via UPI (post-POC; gateway budgets already enforced). Two minutes, no app, no password.
D041 — Manager UI v2 + favicon; verification harness rules
Status: locked · Date: 2026-09-16 · Source: ui-polish v2 delivery
Manager microsite UI v2: fluid type scale, locked card/input/button shapes, budget bars, empty and
error states, tabular numerics — DOM contracts with app.js unchanged, all copy English, tests
green. Favicon: night-sky star mark at site/favicon.svg, linked from the homepage, docs template,
and both comparison pages. Verification harness rules (L10): restart servers before screenshots,
pin themes, kill by PID never by pkill -f pattern.
D042 — Live launch path: tunnel from dev machine first, node-b2 next
Status: locked · Date: 2026-09-16 · Source: orchestrator
Wildcard DNS is live (*.aquila.exaflair.com → relay VPS). The relay's Caddy reaches the manager
through a reverse SSH tunnel opened by the host running the stack. Sequence: (1) live today via a
tunnel from the dev machine using Bhavya's exaflair key (no new keys); (2) moves to node-b2
(spare laptop) when its SSH key is authorized there; the tunnel becomes an autossh systemd unit
on the laptop with restricted permitlisten. Public TLS is on-demand per hostname via Caddy →
manager /internal/tls-ask (digits-only subdomain allowlist).