Machine-oriented guidance for OpenClaw and other AI agents operating Tithe.
Tithe is a single-user, local-first expense tracker. Agents interact primarily through the CLI (tithe) and can use API endpoints for UI-aware workflows.
AGENTS.md- Technical details and implementation notesREADME.md- User-facing overview and quick start
This ensures all documentation stays in sync.
- Preferred interface: CLI with
--json. - API is private and intended for PWA + controlled automation.
- Do not write directly to SQLite unless explicitly requested by a human operator.
- Destructive operations (
delete) require explicit approval token flow. - Never bypass approval checks.
- Never assume internet-exposed access; operate within Tailnet/private network assumptions.
Success:
{
"ok": true,
"data": {},
"meta": {}
}Failure:
{
"ok": false,
"error": {
"code": "ERROR_CODE",
"message": "Human readable",
"details": {}
}
}tithe --json category listtithe --json category add --name "Groceries" --kind expense [--reimbursement-mode none|optional|always] [--default-counterparty-type self|partner|team|other] [--default-recovery-window-days <days>]tithe --json category update --id <id> [--name "Food"] [--reimbursement-mode none|optional|always] [--default-counterparty-type <type|null>] [--default-recovery-window-days <days|null>]tithe --json category delete --id <id> --dry-runtithe --json category delete --id <id> --approve <operationId> [--reassign <id>]
tithe --json expense list [--from <iso>] [--to <iso>] [--category-id <id>] [--limit <n>]tithe --json expense add --occurred-at <iso> --amount-minor <int> --currency GBP --category-id <id> [--kind expense|income|transfer_internal|transfer_external] [--transfer-direction in|out] [--reimbursable|--not-reimbursable] [--my-share-minor <int>]tithe --json expense update --id <id> [fields...] [--kind ...] [--reimbursable|--not-reimbursable] [--my-share-minor <int>] [--counterparty-type <type>] [--reimbursement-group-id <id>]tithe --json expense delete --id <id> --dry-runtithe --json expense delete --id <id> --approve <operationId>
tithe --json reimbursement rule listtithe --json reimbursement rule add --expense-category-id <id> --inbound-category-id <id>tithe --json reimbursement rule delete --id <id> --dry-runtithe --json reimbursement rule delete --id <id> --approve <operationId>tithe --json reimbursement link --expense-out-id <id> --expense-in-id <id> --amount-minor <int> [--idempotency-key <uuid>]tithe --json reimbursement unlink --id <id> --dry-runtithe --json reimbursement unlink --id <id> --approve <operationId>tithe --json reimbursement close --expense-out-id <id> [--close-outstanding-minor <int>] [--reason <text>]tithe --json reimbursement reopen --expense-out-id <id>tithe --json reimbursement auto-match [--from <iso>] [--to <iso>]
tithe --json commitment listtithe --json commitment add --name "Mortgage" --rrule "FREQ=MONTHLY;INTERVAL=1" --start-date <iso> --default-amount-minor 150000 --currency GBP --category-id <id>tithe --json commitment update --id <id> [fields...]tithe --json commitment run-due [--up-to <iso>]tithe --json commitment instances [--status pending|paid|overdue|skipped]tithe --json commitment delete --id <id> --dry-runtithe --json commitment delete --id <id> --approve <operationId>
tithe --json report trends [--months <n>]tithe --json report monthly-ledger [--month <YYYY-MM>] [--from <iso>] [--to <iso>]tithe --json report category-breakdown [--from <iso>] [--to <iso>]tithe --json report commitment-forecast [--days <n>]tithe --json query --entity expenses --filter '{"field":"amount_minor","op":"gt","value":1000}'
tithe web [--mode dev|preview] [--api-port <port>] [--pwa-port <port>] [--daemon|--status|--stop]tithe --json web [--mode dev|preview] [--api-port <port>] [--pwa-port <port>] [--daemon|--status|--stop]
tithe --json monzo connecttithe --json monzo sync [--month <YYYY-MM> | --from <iso> --to <iso>] [--override]tithe --json monzo status- PWA Home Monzo card exposes connect/status controls (
Connect) and shows last sync/error state. - PWA
Connectopens Monzo OAuth in a separate browser window/tab (popup opened synchronously on click to reduce popup blocking). - PWA Transactions list merchant avatar fallback is
logo -> emoji -> initialsfor Monzo-imported expenses when display metadata is available.
- Invoking
tithewithout a subcommand should print help and exit successfully. - DB migrations are expected to run lazily on command execution, not on help-only invocations.
- API and CLI entrypoints auto-load workspace-root
.envviadotenvif present (existing exported env vars still take precedence). - Default
DB_PATHis~/.tithe/tithe.db; leading~is expanded to the current user's home directory. tithe weblaunches API + PWA in foreground mode (--mode previewby default).tithe web --daemonstarts a detached supervisor process that auto-restarts API/PWA if either child process crashes or is killed.tithe web --statusreports daemon state and access metadata from~/.tithe/web-daemon.state.json(fallback path when~/.titheis not writable:<workspace>/.tithe/web-daemon.state.json).tithe web --stopsendsSIGTERMto the daemon supervisor and waits for shutdown.tithe web --mode previewbuilds@tithe/apiand@tithe/pwabefore launch (foreground and daemon-supervisor startup).--api-portoverrides APIPORT; fortithe web, PWAVITE_API_BASEis preserved by default and has its port rewritten when--api-portis provided (fallback:http://<api-host>:<api-port>/v1).--pwa-portsetsPWA_PORTindevmode orPWA_PREVIEW_PORTinpreviewmode.- foreground
tithe --json webemits one startup envelope first, then streams prefixed service logs. - daemon startup/status/stop (
--daemon|--status|--stop) emit a single JSON envelope and exit (no live log stream). - web startup payloads include local PWA/API URLs and best-effort Tailnet URLs; if Tailscale is unavailable, startup continues with a warning and local URLs.
- PWA API requests use a 10-second timeout and transition to error state if backend is unreachable.
tithe --json monzo connectstores short-lived OAuthstateand returnsauthUrl.GET /v1/integrations/monzo/connect/callbackrequires querycode+stateorerror.- Monzo OAuth callback stores/refreshes tokens only and does not auto-run sync; first import happens on manual
monzo sync/ PWA Monthly LedgerSync month. tithe --json monzo syncimports Monzo debits and credits whereamount != 0, including pending rows (posted_at = nulluntil settlement); optional--month/--from --toscopes the sync window and--overrideoverwrites existing imported Monzo rows in that window.- Monzo import dedupe key is
expenses.source='monzo' + expenses.provider_transaction_id=transaction.id. - Monzo sync performs strict pending reconciliation within the active sync window: pending imported rows missing from the fetched Monzo transaction IDs are deleted, even if local note/reimbursement metadata exists on those rows.
- Monzo month sync overwrite updates existing imported rows in place (same
id/provider transaction id) and refreshes Monzo-derived fields including category, amount/date, kind, and merchant metadata while preserving local notes and local reimbursement fields. - Expense API responses include optional Monzo merchant display metadata (
merchantLogoUrl,merchantEmoji) for UI avatar rendering. - Expense API responses include semantic
kind(expense|income|transfer_internal|transfer_external) and reimbursement fields (reimbursementStatus,myShareMinor,recoverableMinor,recoveredMinor,outstandingMinor). - Expense API responses include
transferDirection(in|out|null); it is required for semantic transfer kinds andnullforexpense|income. - Monzo sync best-effort resolves pot-transfer descriptions that contain a Monzo pot ID (
pot_...) to a display labelPot: <Pot Name>for new imports; if pot lookup fails or no pot matches, the raw description is kept. - Monzo merchant logo/emoji metadata is persisted for new imports only; historical imports are not backfilled automatically.
- Initial Monzo sync backfills 90 days; subsequent syncs use a 3-day overlap from
lastCursor. - Monzo sync classifies pot transfers as
transfer_internal; non-pot debits asexpense; non-pot credits asincome. - Monzo category mappings are flow-aware (
in|out) and auto-create categories namedMonzo: <Category>with category kind inferred from flow (expensefor debits,incomefor credits). Pot transfers use a dedicated transfer category (Monzo Pot Transfers). - Optional
MONZO_SCOPEcan be set when building Monzo auth URL; if unset, no explicit scope is requested. GET /v1/reports/monthly-ledgerreturns a month-range ledger with legacyincome/expense/transfersections plus additive v2cashFlow,spending, andreimbursementsblocks and splittransferInternal/transferExternalsections.- Reports (
trends,category-breakdown,monthly-ledger) exclude pending Monzo rows (source='monzo'andposted_at IS NULL) from totals by default. - Reimbursement auto-match in v2 uses explicit category-link rules (
expense category -> income/transfer category), not hidden grouping keys. reimbursement_group_idmay still exist on expense rows as a reserved/deferred field, but v2 auto-match does not use it.- PWA Home embeds a full monthly cashflow ledger (month navigation, income/expense/transfer totals, category breakdown lists) and replaces the previous spend-only snapshot card.
- PWA Home Monthly Ledger widget includes a month-scoped Monzo
Sync monthaction that syncs the selected month window and overwrites existing imported Monzo expenses for that month. - Monthly Ledger Monzo sync success/error feedback is scoped to the selected month and clears when navigating to a different month.
- PWA Home Monthly Ledger widget also surfaces v2 summary metrics (
Cash In,Cash Out,Net Flow,True Spend,Reimbursement Outstanding) withGross/NetandExclude internal transferstoggles. - PWA Home Monthly Ledger category breakdown rows use category icon/color accents (matching Categories list styling) when category metadata is available.
- PWA Home Monthly Ledger
Income,Expenses, andTransferssection rows are tappable and drill into a Transactions detail route scoped to the selected category and month (/transactions/category/:categoryId?month=YYYY-MM); transfer drill-ins may also includedirection=in|out. - PWA Home
Add Transactionis a single manual entry flow forincome|expense|transfer; transfer entries require direction and support transfer subtype (internal|external) via semantickind, and reimbursable expense categories can captureTrack reimbursement+My share. - PWA Home pending commitments support a quick
Mark paidaction that creates a linked actual transaction (source='commitment') and updates the ledger. - PWA Transactions page now surfaces semantic/reimbursement chips (
Internal transfer,External transfer,Pending,Reimbursable,Partial,Settled,Written off) and basic reimbursement actions (Link repayment,Mark written off,Reopen). - PWA Transactions includes a ledger-origin drill-in detail flow (category + month scoped list) that shows the category name in the top bar, keeps the bottom tab bar active on
Home, and exposes top-bar back navigation to the source month. - PWA Categories page uses a floating
+action to openAdd Category, category add/edit dialogs can capture expense-category reimbursement settings/defaults, reimbursement auto-match rule management runs in a dialog, and the category list is grouped intoIncome/Expensesections with each row accented by category color (no per-row kind subtitle). - PWA Categories edit saves update the cached
categoriesquery immediately so the list reflects changes without a manual page refresh. - PWA Categories edit save reads the latest in-dialog draft state (including icon changes) to avoid stale writes when saving immediately after selecting a value.
- PWA Categories edit mutation marks
categoriesas stale without immediate refetch after cache write to avoid stale-response overwrites of freshly edited rows. - PWA short-form list-page dialogs (for example Transactions/Categories add/edit flows) should follow the Transactions pattern: MUI
DialogwithfullWidthand no mobilefullScreen. - Ledger v2 development rollout requires a fresh local DB reset (no backfill); reset
DB_PATH(default~/.tithe/tithe.db) before running v2 migrations/commands. - PWA large pages should use thin route entrypoints in
apps/pwa/src/pagesand feature-scoped UI/data modules underapps/pwa/src/features/<feature>; shared domain-neutral helpers belong inapps/pwa/src/lib. - PWA Home dashboard widgets (ledger, Monzo, commitments) should manage loading/error states independently to avoid page-wide blocking when one widget fails.
- Treat TanStack Query as the source of truth for server state; avoid copying query results into component state except for transient UI drafts.
- Keep route/screen components as composition layers and extract workflow-specific state into focused hooks (for example edit dialog state vs auto-match rules state).
- Keep hooks single-responsibility: avoid page hooks that mix route parsing, data fetching, shell chrome, and copy derivation. When a screen grows, prefer small focused hooks such as
use...Route,use...Data, anduse...Shell, with the page component reading top-down as parse inputs -> fetch data -> configure shell -> render. - Prefer one focused state object per active dialog/workflow instead of parallel id-indexed maps when only one entity can be edited at a time.
- Keep query cache writes/invalidation policy inside mutation hooks so UI components consume a stable API (
mutateAsync,isPending) without cache plumbing details. - For optimistic cache writes, avoid immediate active refetches that can overwrite fresh UI with stale responses; mark stale and refetch intentionally.
- Keep derived view data (
Mapindexes, filtered lists, linked id sets) pure and memoized from query data. - When save handlers can race with rapid input events, read from a ref-synced latest draft snapshot (or a form library with synchronous submit state) to avoid stale payloads.
@tithe/apidev script runs vianode --import tsx src/index.ts(no file watch) to avoid tsx IPC socket failures in restricted environments.- Swagger/OpenAPI operations at
/docsare generated from Fastify routeschemadefinitions inapps/api/src/features/*/routes.ts; when adding or changing endpoints, update route schemas in the same change. - API route composition is centralized in
apps/api/src/http/register-feature-routes.ts; keep feature registration order stable to preserve Swagger tag grouping order. - Use prefix-based feature route registration and define collection roots with an empty route path (
'') to keep canonical OpenAPI paths without trailing slashes. - API runtime config is validated once at startup in
apps/api/src/config.ts(HOST,PORT,LOG_LEVEL,CORS_ALLOWED_ORIGINS). - In API feature routes, Fastify JSON Schema is the request validation source of truth; avoid per-handler
zod.parse(request.body|query|params)duplication. - API must return envelope-form errors for Fastify validation failures (
VALIDATION_ERROR), unknown routes (NOT_FOUND), domain errors, and unexpected internal errors.
- Domain business logic is feature-split and created via
createDomainServices()inpackages/domain/src/services/create-domain-services.ts. - Service registry shape is
DomainServices:categories,expenses,reimbursements,commitments,reports,query,monzo.
createDomainServices()returns a closable service registry (DomainServices+close()), backed by a single long-lived SQLite connection for that runtime instance.- Shared infrastructure lives under
packages/domain/src/services/shared:domain-db.ts: long-lived DB runtime (db,sqlite,close) +DomainServiceOptions.approval-service.ts: approval token creation/consumption.audit-service.ts: audit log writes.common.ts: date/currency/hash helpers + default actor.
- Feature services instantiate repository classes directly against the runtime DB/transaction handle;
RepositoryFactoriesandDomainRuntimeDepsare no longer used. - Keep feature boundaries pragmatic: cross-feature flows are allowed inside feature services when transactional consistency is required.
- Example: expense create/delete may update commitment instance status.
- Example: category delete may reassign both expense and commitment references.
ExpenseTrackerServiceis removed; do not reintroduce a monolithic domain service facade.- Public domain exports are registry-based (
createDomainServices,DomainServices, feature service types).
- Fastify app context is decorator-based:
apps/api/src/http/tithe-plugin.tsdecoratesFastifyInstancewithapp.tithe(services, docs helpers, actor parsing helpers).BuildServerOptionsacceptsservices?: DomainServicesfor external injection/stubs.- The plugin owns lifecycle cleanup only for internally created services (
createDomainServices()), and callsclose()duringapp.close().
- Each feature route module owns only its feature service reference:
categories/routes.ts->services.categoriesexpenses/routes.ts->services.expensesreimbursements/routes.ts->services.reimbursementscommitments/routes.ts->services.commitmentsreports/routes.ts->services.reportsquery/routes.ts->services.querymonzo/routes.ts->services.monzo
- Feature route registrars read dependencies from
app.tithe(no explicitctxparameter plumbing). - Handler style:
- Parse/validate with Fastify schemas.
- Delegate to one feature service call.
- Wrap success with
ok(...). - Let
AppErrorand validation failures flow to central Fastify error handler.
- For destructive endpoints, keep approval flow in route handlers:
dryRunreturns approval token metadata.- non-
dryRunrequires approval token and executes delete.
- Root dev scripts:
pnpm dev:api,pnpm dev:pwa,pnpm dev:cli. - Root start scripts (for built artifacts):
pnpm start:api,pnpm start:pwa,pnpm start:cli. - Root native SQLite smoke check:
pnpm check:sqlite(runs@tithe/dbbetter-sqlite3:memory:open/close). - Root native SQLite repair:
pnpm repair:sqlite(runsbetter-sqlite3package install script, thenpnpm check:sqlite). - Root first-time bootstrap:
pnpm setup:first-time(runs install, creates.envfrom.env.exampleif missing, checks sqlite binding, and attempts repair on failure). - Root SQLite-dependent scripts (
pnpm dev,pnpm dev:api,pnpm start:api,pnpm db:migrate) runpnpm check:sqlitefirst and fail fast when the native binding is missing. - Root
package.jsonallowlistsbetter-sqlite3inpnpm.onlyBuiltDependenciesso pnpm can run its native build script during install/rebuild. - Root
pnpm devdefaultsVITE_API_BASEtohttp://127.0.0.1:8787/v1; override it explicitly for Tailnet/mobile runs. - PWA ports are configurable through root env vars:
PWA_PORT(dev) andPWA_PREVIEW_PORT(preview/start). - Global CLI link workflow:
pnpm --filter @tithe/cli build, thenpnpm link --global ./apps/cli. - After linking globally in zsh, refresh the shell command cache (
exec zshorhash -r) before invokingtithe. - After CLI code changes, rebuild with
pnpm --filter @tithe/cli build(relink is not required for an existing global link). - Force-refresh global link if needed:
pnpm remove --global tithe,pnpm link --global ./apps/cli, thenhash -r/exec zsh. - If
titheis not found after linking, runpnpm setup, restart zsh, and verifyPNPM_HOMEis onPATH. - Remove global CLI link with
pnpm remove --global tithe. - Team runtime pin for native SQLite stability: use Node
22.x(.nvmrc). - If
better-sqlite3fails with "Could not locate the bindings file", the native addon was not built for the active Node ABI. First checkpnpm check:sqlite; if broken, runpnpm repair:sqliteunder Node22.x. - Fallback manual repair remains
pnpm rebuild better-sqlite3(orpnpm rebuild --pending better-sqlite3) pluspnpm check:sqlite. - If reinstalling still does not fix native bindings, inspect
pnpm ignored-builds; pnpm may have auto-ignoredbetter-sqlite3script execution.
For destructive actions:
- Run command with
--dry-run. - Read
operationIdfrom response. - Re-run command with
--approve <operationId>. - Handle failures:
APPROVAL_EXPIRED: obtain a fresh token.APPROVAL_PAYLOAD_MISMATCH: retry with unchanged payload.APPROVAL_ALREADY_USED: tokens are single-use.
- Prefer
providerTransactionIdon imported expenses. - Treat
source + providerTransactionIduniqueness as immutable dedupe key. - For recurring, uniqueness is
(commitment_id, due_at). - Reimbursement link creation supports optional
idempotency_key; same key + same payload must return the existing link, while same key + different payload must fail withREIMBURSEMENT_IDEMPOTENCY_KEY_CONFLICT. - Reimbursement auto-match category rules are explicit links between category IDs (
expense_category_id,inbound_category_id) and are stable across category renames.
- Store timestamps in UTC ISO-8601.
- Render local times in UI if needed.
- Store amounts in integer minor units.
- Ledger v2 invariant:
amountMinoris absolute; direction is derived from semanticexpenses.kindandtransferDirection(transfer kinds only). Do not infer sign fromamountMinor. - Preserve original currency and optional normalized base amount.
VALIDATION_ERRORNOT_FOUNDCATEGORY_NOT_FOUNDCATEGORY_IN_USEEXPENSE_NOT_FOUNDCOMMITMENT_NOT_FOUNDINVALID_RRULEAPPROVAL_REQUIREDAPPROVAL_NOT_FOUNDAPPROVAL_EXPIREDAPPROVAL_PAYLOAD_MISMATCHAPPROVAL_ALREADY_USEDMONZO_NOT_CONFIGUREDMONZO_CONNECTION_REQUIREDMONZO_REAUTH_REQUIREDMONZO_OAUTH_DENIEDMONZO_OAUTH_STATE_MISSINGMONZO_OAUTH_STATE_INVALIDMONZO_OAUTH_STATE_EXPIREDMONZO_ACCOUNT_NOT_FOUNDMONZO_API_ERRORMONZO_RESPONSE_INVALIDMONZO_CATEGORY_CREATE_FAILEDREIMBURSEMENT_LINK_NOT_FOUNDREIMBURSEMENT_NOT_REIMBURSABLEREIMBURSEMENT_INVALID_LINK_TARGETREIMBURSEMENT_CURRENCY_MISMATCHREIMBURSEMENT_ALLOCATION_EXCEEDS_OUTSTANDINGREIMBURSEMENT_ALLOCATION_EXCEEDS_INBOUND_AVAILABLEREIMBURSEMENT_IDEMPOTENCY_KEY_CONFLICTREIMBURSEMENT_CATEGORY_RULE_NOT_FOUNDREIMBURSEMENT_CATEGORY_RULE_INVALID_EXPENSE_CATEGORYREIMBURSEMENT_CATEGORY_RULE_INVALID_INBOUND_CATEGORYREIMBURSEMENT_CLOSE_INVALIDREIMBURSEMENT_REOPEN_INVALIDINTERNAL_ERROR
report trendsreport category-breakdown- Summarize top category shifts and potential commitment pressure.
commitment run-duecommitment instances --status overdue- For any paid item missing linkage, prompt for expense creation or auto-create if policy permits.
queryfor uncategorized/low-quality entries.- Suggest category merges/reassignments.
- Use delete approval workflow only when human-approved.
- Capture endpoint error envelope.
- Check env/token setup.
- For
MONZO_REAUTH_REQUIREDwith Monzoforbidden.insufficient_permissions, verify the Monzo developer client has account/transaction read permissions, then reconnect. - Retry with exponential backoff.
- Do not create synthetic transactions when sync fails.
- Mark integration degraded.
- Surface remediation task for human re-auth.
- Avoid partial writebacks.
- Stop API/CLI writers.
- Restore SQLite file.
- Run smoke queries (
category list,expense list). - Validate row counts and recent timestamps.
When implementing or reviewing features, follow these rules:
-
Write tests for humans first.
- Keep tests readable and intentional (clear setup, action, assertion flow).
- Prefer explicit expected values over opaque helper magic.
-
Test requirements and business rules, not implementation trivia.
- Feature tests should validate behavior users/business care about.
- Keep lower-level/unit tests for mechanics, but feature coverage must stay requirement-led.
-
Do not “game” tests.
- Making tests green is not the only goal.
- When tests fail, first verify whether requirements are correct and still desired.
- Never patch tests with hacks that hide real regressions.
-
Default to red → green → refactor.
- Add/adjust a failing test first, then implement behavior.
- If infra is missing, add an explicit pending/placeholder test tied to requirement text and finish it in the same feature stream.
- Pending/placeholder tests are acceptable on feature branches, but must be resolved before merge unless a human explicitly approves carrying them forward.
-
Treat flaky tests as defects.
- Report flakiness and investigate root cause (time, async races, shared state, environment coupling).
- Prefer refactoring for determinism over retries/timeouts as a workaround.
- Do not ship retry-based or timeout-inflation hacks as the final fix; if root-cause work cannot be completed immediately, open a tracked follow-up with repro notes.
-
Integration tests must be hermetic locally and in CI.
- Never rely on developer machine state (real user DB, real env tokens, existing external integrations).
- Use isolated temp DB/env per test or test file, and clean up after each run.
-
If behavior changes, update tests and requirement docs together.
- If a previous expectation is no longer valid, document why and update tests intentionally.
- Prefer read operations before writes.
- For writes, explain intent in logs/summary.
- Never perform destructive operation without explicit approval token.
- Preserve deterministic JSON output in all automated pipelines.
- Every time you learn something new, or how to do something in the codebase, if you make a mistake that the user corrects, if you find yourself running commands that are often wrong and have to tweak them: write all of this down in
.agents/notes.md. This is a file just for you that your user won't read. - If you're about to write to it, first check if what you're writing (the idea, not 1:1) is already present. If so, increment the counter in the prefix (eg from
[0]to[1]). If it's completely new, prefix it with[0]. Once a comment hits the count of3, codify it into this AGENTS.md file in the## Miscsection.