33 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Source-of-truth path:
/etc/nixos/files/dev-CLAUDE.md.~/dev/CLAUDE.mdis a home-managermkOutOfStoreSymlinkto here (/etc/nixos/home.nix).~/dev/is not a git repo. Edits at either path take effect immediately (no rebuild needed), but commit from/etc/nixos.
What this directory is
~/dev/ is not a single repo — it's a workspace of sibling projects for
the aiolabs / atitlan.io stack (Lightning + Nostr). Most projects are
forgejo-hosted at git.atitlan.io. Per-project CLAUDE.md files are the
source of truth for each repo; this file holds workspace-level facts that
cross-cut multiple repos.
Layout convention
Bare repos live under repos/<name>.git/ and are checked out as
worktrees named after the branch (or purpose): lnbits/main/,
lnbits/dev/, lamassu-next/lightning-pub/, etc. Single-worktree projects
typically use <project>/main/ (host5-home/main, quartz-module/main,
webapp-module/main, fava-module/main).
Per-project CLAUDE.md (defer to these)
webapp/CLAUDE.md— Vue 3 + Vite + Electron client (Nostr + Lightning). Heavy guidance on modular DI architecture, Shadcn forms, mobile file-input defenses. Read before touching anything underwebapp/.bitspire/bitspire/CLAUDE.mdquartz-module/main/CLAUDE.mddocs/castle-docs/CLAUDE.md
Key cross-cutting locations
-
deploy/server-deploy/— Unified NixOS infrastructure flake. Consumes project repos here as flake inputs (url = "git+ssh://...aiolabs/<x>"). Seeflake.nixfor the canonical map of which repos feed which hosts. -
local/docker/regtest/— Local multi-node Lightning regtest stack (LND/CLN/Eclair + bitcoind/electrs/boltz/fava). Seelocal/README.md. Default lnbits dev path is FakeWallet — no docker needed. Only spin up regtest when testing real channels/payments.LNBITS_SRCbranch awareness: the dev compose builds lnbits from${LNBITS_SRC:-~/dev/lnbits/main}. IfLNBITS_SRCpoints elsewhere (e.g. a feature branch), commits tolnbits/maindon't reach the dev image even after--no-cacherebuild. Verify resolved context withdocker compose config | grep -A2 lnbitsbefore assuming a rebuild picked up your patch.Extension folder = LNbits install target: the compose mounts
~/dev/shared/extensions/at/sharedand setsLNBITS_EXTENSIONS_PATH= /shared, so the extension git checkout is the installed extension. Clicking "Upgrade" in the LNbits UI extracts the catalog archive directly over the checkout, wiping.git. Mitigation: aio semver MUST beat upstream tag (see catalog rules). -
refs/— Curated mirrors of upstream reference codebases (Beancount, Lemmy, LNbits, LND/CLN/Eclair, nostr-tools, khatru, …) plus weekly digests. Manifestrefs.toml; refresh viabin/refresh, digest viabin/digest. Layout:repos/<group>/<owner>/<name>. Use for grep/browse — don't import for builds; pin via flake input or submodule instead. -
shared/extensions,shared/lamassu-server,shared/nix-bitcoin— shared paths used across multiple worktrees. -
upstream-prs/— branches staged for upstream contribution. -
lnbits-extensions/— extension catalog (extensions.json).
Working in this workspace
- Treat each subdirectory as its own repo with its own conventions, branches, remotes. Don't assume changes in one project belong with changes in another.
- Many lnbits worktrees share the same bare repo — be mindful which branch you're on, especially when running migrations or touching shared state.
- The
webapprepo'sdevbranch is the staging channel fordemo.aiolabs.dev;mainfeeds production. Dev = staging, not "demo".
Webapp release flow
- Push to
aiolabs/webappdev(not main). - Bump
webapp-devinput indeploy/server-deploy/flake.lock(nix flake lock --update-input webapp-dev). Tracksdev. - Deploy to
host4(./deploy.sh host4). Smoke as staging vet. - Once vetted: fast-forward
aiolabs/webappmainto dev's commit- bump the
webapp(notwebapp-dev) input inflake.lock.
- bump the
Never push runtime webapp changes directly to main. If you find runtime commits on main that should have gone through dev, stop and notify the user — recovery is destructive and needs explicit auth.
Carve-out: non-runtime / tooling-only changes can go direct to main +
rebase dev on top. Root-level dotfiles and dev tooling that don't ship
in the Vite/Electron bundle (.mcp.json, editorconfig, CLAUDE.md, root
README) have nothing to vet on staging. Pattern: commit on main, push,
then cd ~/dev/webapp/dev && git fetch && git rebase origin/main && git push --force-with-lease. Still PR for anything touching src/,
electron/, package.json/pnpm-lock.yaml, build config.
- Production-bound changes need a corresponding
flake.lockbump indeploy/server-deploy/to actually reach a host. - Git staging discipline: never
git add -A/git add .— list filenames explicitly. Hard rule from a 2026-05-14 incident that leaked an LNbits auth key.
aiolabs Forgejo conventions
Fork versioning (lnbits + extensions)
Universal across all LNbits-derived aio forks:
- Tag scheme:
v<upstream-version>-aio.<patch-number>. Examples:v1.5.4-aio.1,v1.3.0-aio.2. Bump upstream segment on rebase, reset patch counter to1. - Hyphen pre-release suffix on tags + extension
config.json, NOT+aio.Nbuild-metadata. Confirmed across multiple bumps. - EXCEPTION: lnbits
pyproject.tomlrequires PEP 440 →+aio.Nthere only. Hyphen-suffix isn't PEP 440-valid. Same release, two spellings: git tagv1.5.4-aio.1,pyproject.toml1.5.4+aio.1. The latter is whatimportlib.metadatareturns and the UI footer displays. - Tag at deploy-ready boundaries, not every commit. Tags mark vetted states; direct-commit-to-main is fine, but don't tag broken intermediate commits.
- Lint pipeline for aio forks: black + mypy + prettier + ruff.
For aiolabs/lnbits: deploy/server-deploy pulls lnbits as a flake input
pinned by commit. The tag is a human-readable label, not a functional
dependency — but makes git log --decorate and archaeology vastly clearer.
aiolabs/lnbits-extensions catalog rules
extensions.json is consumed at runtime by LNbits via
LNBITS_EXTENSIONS_MANIFESTS; archives are sha256-pinned per entry.
The catalog repo is NOT tagged. It's a curated manifest, not a fork.
Deploy reads it live from …/raw/branch/main/extensions.json; audit trail
is git log on main, rollback is git revert.
- Production points ONLY at our manifest, never upstream. The deployed
LNBITS_EXTENSIONS_MANIFESTSpoints atgit.atitlan.io/aiolabs/lnbits-extensions/raw/branch/main/extensions.json(deploy/server-deploy/modules/services/lnbits.nix:73). Production users can only install / upgrade what we've curated. Cycle: upstream releases → vet in dev → rebase to aio → update our manifest. Never addgithub.com/lnbits/<ext>URLs for an extension with an active aio fork. - Don't overwrite a version entry in place. When bumping, add a new entry alongside the old — instances running the previous version need it to remain resolvable.
- Aio semver MUST beat the upstream tag we're forking (dev hygiene).
Regtest compose doesn't override
LNBITS_EXTENSIONS_MANIFESTS, so dev sees LNbits's built-in default catalog AND our manifest. LNbits's "upgrade" picks highest semver across all visible entries; if upstream outranks our-aio.N, clicking Upgrade wipes our.git. Always bump aio fork tov<upstream>-aio.1immediately on rebase. - Catalog bumps don't require a
flake.lockbump. LNbits fetches the manifest live; nix doesn't bake archives in. Explicit exception to the "production-bound changes need a flake.lock bump" rule.
Extension version-bump procedure
When user says "bump and update lnbits-extensions":
- Sanity-check ext repo: clean tree, on
main,git log <last-tag>..mainmatches intended release. - Choose semver bump from the diff.
- Push the branch first (force-push after rebase OK for solo-maintainer forks). Origin/main is recoverable; the tag isn't.
- Test locally on dev LNbits BEFORE tagging. Restart regtest to load new on-disk code (or uninstall+reinstall via UI); smoke happy paths, watch logs for migrations + exceptions. Once installs pull the catalog, bad tags are hand-fix-every-install messy.
- Tag at HEAD and push:
git tag v… && git push origin v…. - Fetch archive, compute sha256:
curl -sL .../archive/v….zip | sha256sum. - Add a new entry to
aiolabs/lnbits-extensions/extensions.json(don't overwrite the old). - Commit + push catalog. Live immediately — no flake.lock bump.
Forgejo issue labels (aiolabs/webapp)
When creating issues on aiolabs/webapp, apply labels via
add_issue_labels immediately on creation. Two axes:
- App scope:
app:activities,app:webapp,app:marketplace,app:wallet, etc. - Issue type:
type:bug,type:feat,type:chore, etc.
PR flow on aiolabs-forked extensions (default since 2026-06-12)
For aio LNbits ext forks (aiolabs/lnurlp, aiolabs/withdraw,
aiolabs/events, aiolabs/libra, …), feature-branch + PR is the
default flow — these extensions move real money and feed production
instances, putting them in the same review category as aiolabs/lnbits
and aiolabs/webapp.
Why the flip (was direct-commit-to-main while single-maintainer): a libra refactor regression (typed entry links breaking the approval flow, libra-#42) shipped straight to a production-feeding main on 2026-06-12. The PR gate buys a review pause + a place for CI/tests before main moves.
Flow: branch → push → open PR (creation via MCP is fine) → hand off to the user to merge via the Forgejo web UI (see PR-merge rule in user-global CLAUDE.md — the MCP merge endpoint is unreliable and the user has a final-review ritual). Trivial typo/docs-only commits can still ride direct-to-main at the user's discretion — ask if unsure.
(L3 Forgejo branch protection on main for these repos is the parked enforcement follow-up, same as for lnbits/webapp.)
Issue closing — manual, not commit-keyword auto-close
Do NOT use Forgejo closing keywords (Closes #N, Fixes #N) in commit
messages on aiolabs repos. Reference issues as prose ((libra-#42) in
the subject, plain #42 in the body) and close the issue manually
after the fix lands.
Why: auto-close fires when the commit hits main, but on this stack "on main" ≠ "deployed" — issues should stay open (or at least be closed deliberately) until the fix actually reaches the affected instance. Confirmed by user 2026-06-12 (libra-#42).
aiolabs identity vs upstream
Forks live on Forgejo at git.atitlan.io/aiolabs/*. User's handle there
is padreug. Fork-internal references (contributors, repo URLs, PR/issue
links) use Forgejo + padreug. GitHub handle (your-github-username) is separate
— only for upstream PRs. Identity-rewrite details in user-global
~/.claude/CLAUDE.md.
LNbits extension development
Auth decorators
| Decorator | Auth scope | Returns |
|---|---|---|
require_invoice_key |
Wallet invoice key — read access | Wallet |
require_admin_key |
Wallet admin key — write to own wallet only | Wallet |
check_admin |
LNbits admin user (super_user + lnbits_admin_users), Bearer | Account |
check_super_user |
LNbits super user only, Bearer | Account |
require_admin_key is easily confused with check_admin and means
something very different. The first is a wallet-level write key (any user
can have one for their own wallet); the second is LNbits-instance admin
access. Use check_admin for cross-user / admin-only operations.
Testing
Use FakeWallet (LNBITS_BACKEND_WALLET_CLASS=FakeWallet) for extension
CRUD / API / UI tests. Spin up full regtest only when end-to-end Lightning
payment behavior is actually under test (regtest costs build + container
time and adds no fidelity for non-payment flows).
Fork-migrations pattern (migrations_fork.py)
Per aiolabs/lnbits#8 — fork-only schema deltas go in migrations_fork.py
alongside the upstream-tracked migrations.py, loaded by our patched
migrate_extension_database() under <ext.id>_fork in dbversions. Keeps
upstream rebases conflict-free for the migration file.
Architecture facts:
dbversionslives in the core LNbits DB (database.sqlite3), not per-extension. Schema:(db TEXT PRIMARY KEY, version INT);update_migration_versionis INSERT-OR-UPDATE so a new<ext>_forkrow appears on first run with no core-side migration.- Extension data tables live in
ext_<id>.sqlite3(SQLite) or a Postgres schema named after<id>. Created lazily on firstDatabase.connect(). - No cross-DB atomicity. Extension migration commits to
ext_<id>.sqlite3;dbversionsupsert commits todatabase.sqlite3. If extension write succeeds and dbversions fails, migration is orphaned and re-runs on next startup. Every migration MUST be idempotent (_alter_add_column_safe,CREATE TABLE IF NOT EXISTS, etc.).
Squash recipe for adopting the pattern on existing forks:
- Restore
migrations.pyto upstream-byte-identical (drop fork-only functions and any helpers added for them). - Create
migrations_fork.pywith a singlem001_aio_<ext>_schemathat idempotently applies every fork-only delta the old migrations did. - Use
_alter_add_column_safeper ALTER andCREATE TABLE IF NOT EXISTSper table — no-ops cleanly on installs that already ran old migrations.
One-time fix on installs adopting the pattern AFTER previously running
old fork migrations: their dbversions['<ext>'] row is ahead of upstream
(e.g. events|11). After moving to migrations_fork, the next upstream
rebase that adds e.g. m007 would compare 7 > 11 → false and silently
skip. Reset the row before the rebase lands:
-- Run against the core DB, not the extension DB.
UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>';
Containerized: docker compose exec lnbits python3 -c "..." since the
file is root-owned inside the container.
Upstream-overlap at rebase time: if upstream eventually adds a schema
change we already carry in migrations_fork.py, fresh installs work (our
guards swallow dups) but existing installs crash when upstream's
non-idempotent migration runs. Mitigation:
- Prune the redundant block from
migrations_fork.pyso future fresh installs get the column from upstream. - Pre-deploy
dbversionssurgery on affected installs:UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>'.
Don't patch upstream's migration with idempotency guards in our fork — breaks the "migrations.py == upstream byte-identical" property.
Upstream PR follow-up: the extension loader change in
migrate_extension_database() is upstreamable as a sibling to the
in-flight core_fork PR (your-github-username/lnbits @ add-fork-migrations-namespace).
Fork-internal patch for now.
Settings precedence — env seeds DB on first boot, then DB wins
LNbits has two sources of truth for settings depending on lifecycle, and
the switch happens automatically. Verified 2026-05-24 against ~/dev/lnbits/main.
On boot with lnbits_admin_ui=True (lnbits/core/services/users.py: 231-247, check_admin_settings):
- Read DB row via
get_super_settings(). - If DB empty → seed from
.envviainit_admin_settings()(first-boot only). update_cached_settings(settings_db.dict())overwrites in-memorySettingswith the DB row..envvalues loaded by Pydantic at startup are clobbered.
Practical consequence: once an instance has booted once, editing
.env and restarting changes nothing for editable fields. Change via
Admin UI (PUT /api/v1/settings, gated by check_admin) or by clearing
relevant system_settings table rows.
Exceptions where .env still wins every boot:
super_user— env overrides DB atusers.py:243-245.lnbits_admin_ui=False— DB-load block skipped entirely.- All
ReadOnlySettingsfields (settings.py:1132+ parents):host,port,lnbits_extensions_path,lnbits_path,lnbits_title(API title — NOTlnbits_site_titlewhich is editable),lnbits_data_folder,lnbits_database_url,auth_secret_key,first_install_token,lnbits_admin_ui,lnbits_allowed_funding_sources.update_cached_settingsskips any key inreadonly_variables. Editable settings (site title/tagline, theme, watchdog, fee defaults, rate limits, per-funding-source credentials, the whole Admin UI form) get DB-frozen.
LNBITS_FIRST_INSTALL_TOKEN rotation does NOT reset settings to env.
It creates a new super_user account with a fresh UUID and sets
settings.first_install = True, re-enabling /first_install for a
locked-out admin to re-claim the instance. No env values flow back through.
Deploy-side consequence for deploy/server-deploy/modules/services/ lnbits.nix: editable env vars only take effect on fresh install
(empty settings table). For existing deploys, "set it in nix, redeploy,
done" only works for ReadOnlySettings fields.
Nostr architecture
Patterns reference is the source of truth
Before writing or reviewing any Nostr-related code in ~/dev/webapp/,
read docs/nostr-patterns/ first. After implementing or refining a
pattern (or fixing a subtle Nostr bug), update the relevant topic file
in the same commit. If new, add to the index. Drift between reference
and code defeats the purpose.
Reference implementations (curated mirror)
~/dev/refs/repos/nostr/<owner>/<repo>/ holds upstream nostr codebases
worth grepping for patterns: nostr-protocol/nips, fiatjaf/khatru,
fiatjaf/nostr-tools, etc. Refresh weekly via ~/dev/refs/bin/refresh;
digest under ~/dev/refs/digests/. For new features: check
docs/nostr-patterns/ (internal) → then refs/repos/nostr/ (external).
Key in-flight initiatives
- LNbits nostr transport —
aiolabs/lnbitsPR #4 (nostr-native-transportbranch). NIP-44 v2 encrypted kind-21000 RPC over relays, modeled after Lightning.Pub. Lets HTTP-allergic clients (ATMs, kiosks behind NAT) reach LNbits through commodity relays. - Lightning.Pub reference —
~/dev/lamassu-next/lightning-pub/. Key files:extension-loader/proto/autogenerated/ts/nostr_transport.ts,extension-loader/src/services/nostr/nostrPool.ts. Patterns: dual NIP-44 v1+v2 support, content sharding for large messages.
Long-term direction
The eventual goal is webapp + LNbits extensions communicating exclusively over Nostr — eliminating HTTP. Prefer architectural decisions that move toward Nostr-native. Don't create HTTP-only patterns that will need to be ripped out. The nostr transport PR (#4) is the first concrete step.
Bunker for everything: no nsec at rest on LNbits
Once aiolabs/lnbits#18 (NIP-46 bunker integration) lands, every nsec
on the LNbits host gets retired — operator users AND the server identity.
There is no two-tier endgame where users go through bunker and the server
keeps NOSTR_TRANSPORT_PRIVATE_KEY on disk.
The plausible-looking carve-outs (server "conceptually different," boot bootstrap complexity, latency, failure modes) don't survive the threat model: the LNbits host runs extension code, payment plumbing, a public API — disk/root access there must NOT equal Nostr-identity compromise.
Concrete rules:
- Every signing call routes through the signer abstraction
(
lnbits.core.signers.resolve_signerfrom#17). Impls:LocalSigner(envelope-encrypted at rest, transitional),ClientSideOnlySigner(operator-driven, no server signing),RemoteBunkerSigner(NIP-46 via#18— endgame). - No
if server_is_special:branch anywhere. Server identity is one more account from the signer's perspective. NOSTR_TRANSPORT_PRIVATE_KEYis acceptable as a transitional env-pinned source for aLocalSigneradapter, but with an explicit sunset.- For extension code signing on behalf of operators: same abstraction. The
hybrid pattern at
aiolabs/spirekeepercommite13178dis the template (try-importresolve_signer, fall back to direct prvkey on pre-#17lnbits, both produce identical signed events).
If tempted to keep static nsec "because it's just the server" or "because bunker isn't ready yet," push back: the whole point of bunker is removing static nsec from the LNbits host.
Respect protocol semantics over friction reduction
When picking a transport / event-kind / message-format for a new feature, the protocol's intended use case wins over "an existing listener happens to fire." Quick-fix transport choices get locked in by callers and are harder to migrate later.
How to apply — before wiring a new feature onto an existing listener:
- Name the relationship the feature serves (ATM↔Customer, ATM↔LNbits, ATM↔Operator, Operator↔User, LNbits↔Bunker, …).
- Ask "what is this protocol / kind / RPC for?"
- If answers don't align, design or pick the right primitive even if a wrong-purpose listener already exists.
Exception: explicit, time-bounded stopgaps with a written migration path.
Worked example (2026-05-29 / aiolabs/bitspire#56). Cassette-config
publishing was nearly routed through clink.onManagement (kind-21003)
because the listener existed. CLINK is a payment-flow protocol; repurposing
would have conflated payment + operator-config concerns. The right
primitive was kind-30078 (NIP-78 replaceable, NIP-44 encrypted,
["p", atm_npub]-tagged).
Nostr kind allocations — avoid the CLINK band (21001–21003)
The Nostr ephemeral range (20000–29999) is technically unallocated but
several application-protocol families have squatted on specific kinds.
CLINK has claimed 21001 / 21002 / 21003 for Offers / Debits / Manage
(see refs/repos/shocknet/shocknet/CLINK/). We want CLINK-compat preserved
as we adopt it for ATM cash-in/cash-out flows.
Rule: kind:21001–kind:21099 is OFF-LIMITS for aiolabs-specific events.
CLINK may add adjacent kinds; on-the-wire collisions with
["clink_version"]-tagged events aren't worth the namespace-squatting
savings.
Suggested aiolabs band: 22000–22099 for non-replaceable application events specific to our stack. Pick incrementally; document each allocation here so future sessions don't reuse a number.
Settlement-receipt rotation (2026-06-02): kind:21001 was originally
locked in for bitSpire settlement receipts (aiolabs/lnbits#22 +
aiolabs/spirekeeper#11) before CLINK was in scope. Collision found
during a CLINK primer review. Settlement receipts will land on a non-21001
kind before either PR ships; rotation plan tracked on aiolabs/spirekeeper#20.
Replaceable events (kind:30000–39999) are a separate namespace. Kind:30078 (NIP-78 application-specific data) is fine for fee_config, cassette state, fleet roster — by-design replaceable state-of-the-world events.
Standalone app pattern (webapp)
Modules can be deployed as standalone PWAs alongside the main webapp via a
second Vite entry point. Used for activities (sortir); may replicate
for marketplace / wallet / others.
For a new standalone app from an existing module:
- HTML entry:
<app-name>.htmlat project root →src/<app-name>-app/main.ts. - App shell:
src/<app-name>-app/{main.ts, App.vue}. - Vite config:
vite.<app-name>.config.tswith its own build target and PWA scoping (see service worker rules inwebapp/CLAUDE.md). - npm script:
dev:<app-name>and (for the demo host) a roll-up.
The standalone Vue frontend lives in webapp/ alongside other AIO
standalone apps (chat, forum, market, tasks, wallet, activities, libra) —
the corresponding extension repo (~/dev/shared/extensions/<name>/ if any)
holds only the LNbits backend Python code.
Matrix homeserver: Continuwuity
Self-hosted Matrix homeserver in the Rust/conduwuit lineage. Lives in
deploy/server-deploy/modules/services/matrix.nix for castle hosts that
opt into services.matrix-stack.enable. Sibling stack: LiveKit SFU,
lk-jwt-service, Element Web, Element Call SPA, .well-known/matrix/
delegation on the apex.
Required config to avoid quiet breakage
-
well_known.client+well_known.server— without these, Continuwuity usesserver_name(the apex) when generating user-facing URLs like password reset links. Set tohttps://matrix.${domain}andmatrix.${domain}:443to mirror the JSON nginx serves at/.well-known/matrix/on the apex. -
allow_registration = trueis safe. It does NOT mean open registration. Withoutregistration_token/_fileAND without theyes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuseflag, Continuwuity rejects self-serve signups.
First-user bootstrap
When no admin exists yet, Continuwuity auto-generates a one-time
registration token at startup and prints it to journalctl. This
overrides any registration_token_file you might wire up. After the
first user registers, they're auto-promoted to admin.
Admin commands
All admin ops happen in the auto-created control room via !admin <group> <cmd>. Groups: token, users, appservice, rooms, federation,
media, server. Full reference:
https://continuwuity.org/reference/admin/index.html.
Token issuance flags are mutually exclusive — pick exactly one of
--once, --max-uses N, --max-age <30s|5m|7d>, --immortal.
Bridges + bots
Continuwuity registers appservices via admin commands (!admin appservice register with pasted YAML), not via app_service_config_files like
Synapse. The NixOS services.mautrix-*.registerToSynapse shortcut does
NOT apply — bridges go through a manual paste step. Bot-only flows
(maubot, custom matrix-nio scripts) don't need appservice registration.
Maubot plugin development
Maubot is the standard Python plugin framework for Matrix bots; one
running maubot daemon hosts many plugin-bots. Plugin source at
~/dev/maubot-plugins/<name>/.
maubot.yaml — database_type is API style, not storage backend
Valid values: asyncpg (modern, what mautrix.util.async_db provides) or
sqlalchemy (legacy). NOT sqlite or postgres — that's the storage
backend, chosen at the daemon level via plugin_databases.sqlite /
plugin_databases.postgres. Wrong value fails at instance start with
RuntimeError: Unrecognized database type sqlite.
Parent command + freeform text + subcommands
For !journal <text> (parent) plus !journal show [@user] / !journal today (subcommands), @command.new needs both flags:
@command.new("journal", require_subcommand=False, arg_fallthrough=False)
@command.argument("text", pass_raw=True, required=False)
async def journal(self, evt, text=""): ...
@journal.subcommand("show", help="...")
@command.argument("user", required=False)
async def show(self, evt, user=None): ...
Without require_subcommand=False, non-subcommand input shows auto-help
instead of recording. Without arg_fallthrough=False, pass_raw=True
greedily consumes the rest, so !journal show @alice gets recorded as an
entry. Same pattern as upstream maubot/reminder.
Multi-line freeform parent commands need @command.passive
The above only works when everything is on one line. For multi-line
content (!journal\n- item one\n- item two), @command.new silently
drops it — maubot's parser only treats space as the command/args
delimiter, so a newline immediately after the command name makes maubot
fail to recognise the command at all. No handler invoked, no error logged.
Fix: use @command.passive with a regex admitting any whitespace,
then dispatch subcommands manually:
_JOURNAL_RE = re.compile(r"^!journal(?:[ \t\r\n]+(.*))?$", re.DOTALL)
@command.passive(regex=_JOURNAL_RE)
async def journal(self, evt, match):
rest = (match[1] or "").strip()
if not rest: ...
first_token, _, after = rest.partition("\n")[0].partition(" ")
if first_token == "show": ...
else: ... # record `rest` verbatim — multi-line preserved
You lose @parent.subcommand("...") ergonomics but gain reliability for
prose-style inputs. Rule of thumb: for commands whose dominant use is
free-text that may span lines, default to passive.
Database access (asyncpg style)
from mautrix.util.async_db import UpgradeTable, Connection
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial schema")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute("CREATE TABLE ...")
class MyBot(Plugin):
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table
@command.new("foo")
async def foo(self, evt):
await self.database.execute("INSERT INTO entries VALUES ($1, $2)", a, b)
rows = await self.database.fetch("SELECT ... LIMIT 10")
Placeholders are $1, $2, ... regardless of backend; async_db normalizes
across asyncpg/aiosqlite.
Iteration loop
Edit + bump version in maubot.yaml, then:
cd ~/dev/maubot-plugins/<plugin>
zip -j ../<plugin>.mbp maubot.yaml <plugin>.py
Upload via Plugins → click existing plugin → upload new .mbp. Instance
reload requires hitting Save on the instance after upload — toggling
Enabled and walking away doesn't persist.
Wiping a plugin's data
Each plugin has its own SQLite DB under plugin_databases.sqlite. Cleanest
reset is the maubot UI's per-instance Database tab — DELETE FROM <table>
runs against the live DB without restart. Nuking the file works but loses
migration version tracking.
Pyright false positives
maubot / mautrix imports unresolved + .subcommand "unknown attribute"
warnings are expected — the SDK is dynamic and pyright can't introspect
the decorators. Ignore.
Upstream lnbits PR conventions
For PRs to github.com/lnbits/lnbits:
- Base branch is
dev, notmain. 100% of recent merged PRs targetdev.maingets release commits (Merge branch 'dev'+ version bumps). - Commit titles: lowercase conventional commits.
feat:,fix:,chore:,chore(deps):,docs:,ci:. Not capitalized. - Identity: push under GitHub identity (
your-github-username), not Forgejo. See user-global~/.claude/CLAUDE.mdfor re-author details.
Verified against last 25 merged PRs on 2026-04-28.
Documentation discipline
For any aiolabs repo with a structured docs/ tree:
Any commit that materially changes a database table, an API endpoint,
order/event flow, Nostr publishing convention, or CMS structure must
update the relevant note in docs/ in the same commit. Architecture
decisions get ADRs at docs/adr-NNNN-<slug>.md.
Drift between docs and code defeats the purpose. Don't park doc updates for "later" — they get forgotten and docs lose signal.
LNbits + Quasar UMD — frontend gotchas
Applies to anything rendering into LNbits' page shell: lnbits core
(~/dev/lnbits/*) and every extension (~/dev/shared/extensions/*,
~/dev/lnbits-extensions/). All use Vue 3 + Quasar 2 as UMD globals —
no build step, Jinja templates with per-page JS.
No self-closing tags. Per Quasar's UMD rules (https://quasar.dev/start/umd/#usage), components need explicit-close:
<!-- correct -->
<q-input v-model="foo" label="Foo"></q-input>
<!-- wrong — silently broken in UMD/no-build mode -->
<q-input v-model="foo" label="Foo" />
Self-closing is fine in .vue SFCs (build step rewrites them), but
UMD-loaded templates are parsed by the browser's HTML parser, which
doesn't honor self-close on non-void elements — close tag gets implied at
the wrong place, nesting breaks silently, subsequent siblings end up
inside the prior component.
CSS specificity trap. LNbits applies theme overrides on Quasar
typography utilities (.text-caption, .text-grey-*) with !important.
Class-based CSS in an extension — even with !important — loses unless
the selector is strictly more specific. Inline style attrs (static or
via Vue :style) win without an arms race.
Rule: for per-element typography/color overrides on LNbits pages, use
Vue :style bindings, not <style> blocks targeting utility classes.
Background/border tweaks at card-level are fine via classes.
Cache busting. Static assets served with ?v={server_startup_time}
(lnbits/helpers.py: static_url_for). Bumping JS requires server restart;
Jinja templates re-render every request. If browser keeps serving stale
JS after restart, hard-refresh (Ctrl+Shift+R) to bypass HTTP cache.
Dark-mode color discipline. Pale bg-{color}-1 utilities render
white-on-cream under dark theme — pair every pale background with an
explicit dark text class (bg-red-1 text-grey-9 etc).