Clawbolt is an AI assistant for the trades. FastAPI backend with a messaging-first interface (iMessage, RCS, SMS, Telegram, web chat) and a custom tool-calling agent loop built on any-llm. Built by Mozilla.ai using the open-core model.
# Install dependencies
uv sync
# Run server (requires PostgreSQL -- see docker-compose.yml)
uv run uvicorn backend.app.main:app --reload
# Run with Docker (starts Postgres + app, runs migrations automatically)
docker compose up
# Database migrations
uv run alembic upgrade head
uv run alembic revision --autogenerate -m "description"
# Tests
uv run pytest -v
# Lint & format
uv run ruff check backend/ tests/ alembic/
uv run ruff format --check backend/ tests/ alembic/
# Type checking
uv run ty check --python .venv backend/ tests/ alembic/- Python 3.11+, FastAPI, SQLAlchemy 2.0, Pydantic v2
- any-llm-sdk (LLM provider abstraction via
amessages) - Multi-channel messaging: BlueBubbles (iMessage/RCS/SMS), Twilio (SMS), Linq, Telegram (via python-telegram-bot), web chat
- Google Drive (per-user OAuth) for file storage
- PostgreSQL for all data persistence, Alembic for migrations
- uv + hatchling build system, ruff linting, ty type checking
All structured data is stored in PostgreSQL (configurable via DATABASE_URL). The core tables:
| Table | Purpose |
|---|---|
users |
User profiles, personality text, preferences |
channel_routes |
Channel -> user routing (iMessage, SMS, Telegram, web chat, etc.) |
sessions |
Chat session metadata |
messages |
Chat messages (FK to sessions) |
memory_documents |
Structured memory and compaction history |
heartbeat_logs |
Heartbeat send log |
idempotency_keys |
Webhook deduplication |
llm_usage_logs |
Token usage tracking, with the endpoint that served each call |
llm_endpoints |
Named LLM destinations: dialect, base URL, own credential, capabilities |
tool_configs |
Per-user tool configuration |
calendar_configs |
Per-user calendar integration settings |
oauth_tokens |
Encrypted OAuth tokens for integrations (Google Calendar, Google Drive, QuickBooks, etc.) |
Ten more tables exist for AUTH_MODE=multi_user and stay empty in a single-user deployment: subscriptions, usage_quotas, deleted_user_usage, allowed_emails, waitlist_entries, admin_api_keys, admin_audit_logs, llm_payload_captures, llm_eval_runs, llm_eval_turn_results. See "Multi-user mode" below.
Saved files are not tracked in Postgres. The Google Drive integration is the source of truth for filenames, locations, and descriptions. The agent quotes saved files by their storage path (e.g. /Astro Home Management - 123 Main Street/photos/foo.jpg).
Key store modules:
backend/app/agent/user_db.py--UserStore(singleton viaget_user_store())backend/app/agent/session_db.py--SessionStore(per-user viaget_session_store(id))backend/app/agent/memory_db.py--MemoryStore(per-user viaget_memory_store(id))backend/app/agent/stores.py--HeartbeatStore,IdempotencyStore,LLMUsageStore,ToolConfigStorebackend/app/agent/dto.py-- Pydantic DTOs:UserData,StoredMessage,SessionState, etc.backend/app/database.py--Base,AsyncSessionLocal,db_session_async(),get_async_db(),get_async_engine()backend/app/models/-- SQLAlchemy ORM models, one module per product area. Importing the package is what registers every mapper onBase.metadata, so Alembic autogenerate and the test-suite TRUNCATE both depend on it.
File storage is exposed through the Google Drive integration: each user grants the drive.file scope and uploads land in their own Drive under a top-level Clawbolt folder. The operator wires the OAuth client via GOOGLE_DRIVE_CLIENT_ID / GOOGLE_DRIVE_CLIENT_SECRET; without those, the file tools never load.
The singleton engine and session factory live in backend/app/database.py. The session factory ships with expire_on_commit=False so attribute access after commit does not trigger MissingGreenlet. Pool tuning (pool_recycle, pool_pre_ping, statement timeout) is on the engine.
- READ-only methods:
db = AsyncSessionLocal()+try / finally await db.close(). Lighter weight, no rollback wrapper. ReferenceIdempotencyStore.has_seeninbackend/app/agent/stores.py. - WRITE methods:
async with db_session_async() as db: .... Auto-rollback on exception, auto-close. ReferenceIdempotencyStore.try_mark_seeninbackend/app/agent/stores.py. - FastAPI dependency:
db: AsyncSession = Depends(get_async_db).
Pool sizing is currently SQLAlchemy default (pool_size=5, max_overflow=10). To re-evaluate, run scripts/benchmark_pool.py against a prod-like Postgres; it sweeps pool_size/max_overflow across realistic concurrency and emits a markdown report with p50/p95/p99 connection-acquisition latency. Methodology and the most recent run are in scripts/benchmark_pool_report.md (issue #1179).
- Read:
(await db.execute(select(X).where(...))).scalar_one_or_none(). For the shapes that repeat, use the helpers inbackend/app/query_helpers.py:get_or_404_async(fetch one or 404),count_rows(db, Model.id, *where)(scalar count),fetch_all(db, stmt)(entities as alist), andiso/iso_or_none(timestamp serialization,""orNonefallback). Hand-roll only where the query does not fit one, e.g. a count across a join. - DML rowcount: at runtime
(await db.execute(update/delete)).rowcountreturnsint, but the stubs sayResult. Cast to access cleanly:cast("CursorResult[object]", await db.execute(...)).rowcount. ReferenceSessionStore.delete_messageinbackend/app/agent/session_db.py. - Bulk DML
synchronize_session: the kwarg moved offupdate()/delete()constructors. Use.execution_options(synchronize_session="fetch")on the executable. Reference_append_history_updateinbackend/app/agent/memory_db.py. - Row-level lock:
(await db.execute(select(M).filter_by(id=x).with_for_update())).scalar_one_or_none(). .scalars().all()returnsSequence[T], notlist[T]. Wrap withlist(...)only when the consumer is typed forlist;fetch_allalready does.- Do not use the SQLAlchemy 1.x
db.query()API.
EncryptedString columns (e.g. MemoryDocument.history_text, OAuthToken.access_token) handle envelope encryption automatically on bind/unbind. SQL-side string concat (Model.col || new_text) operates on ciphertext and silently corrupts the row. Bug fixed in #1200.
Correct pattern: SELECT FOR UPDATE the row, decrypt-in-Python via attribute access, append in Python, UPDATE with the full new plaintext. Reference _doc_select_for_update and _append_history_update plus their callers in backend/app/agent/memory_db.py. The row-level lock serializes concurrent appenders so neither side loses its update.
Use pg_advisory_xact_lock whenever possible: the lock is bound to the surrounding transaction and released automatically on COMMIT or ROLLBACK. Just execute the lock SQL inside the session and let the existing commit drop it. Reference _advisory_lock_sql in backend/app/agent/session_db.py (SessionStore) and _lock_user_permissions in backend/app/agent/approval.py.
Session-scoped advisory locks (pg_advisory_lock / pg_try_advisory_lock) are different: the unlock MUST run on the same connection that took the lock. AsyncSession.commit() returns the underlying connection to the pool, so a follow-up pg_advisory_unlock call on a fresh AsyncSessionLocal() runs on a different connection and is a silent no-op (Postgres returns False, the helper logs nothing). Recovery code in backend/app/agent/inbound_recovery.py and OAuth refresh in backend/app/services/oauth.py rely on the same-connection coupling: they hold the lock on a dedicated AsyncConnection (not a session) for the duration of the critical section.
Concurrency tests for advisory locks: spin per-task AsyncConnection handles so the lock primitive is actually exercised, not the connection serialization. Coordinate via asyncio.Event. Do not assert on time.monotonic() deltas across tasks; sub-millisecond races make the comparisons flake. Reference TestInboundRecoveryLockSerialization in tests/test_inbound_recovery.py.
The session-scoped autouse _isolate_async_engine fixture rebinds the OSS engine to a NullPool async engine pointed at the test database. The default _isolate_stores autouse fixture TRUNCATEs every table in Base.metadata.sorted_tables after each test (RESTART IDENTITY + CASCADE) so the next test starts on a clean slate. The standard test_user fixture writes through db_session_async() against that engine.
The opt-in async_db fixture (in tests/conftest.py) gives a stricter SAVEPOINT-based rollback for tests that need it: each test runs inside a per-test AsyncConnection with a wrapping transaction, and the fixture rebinds _async_session_factory so store calls pick up the test connection. Two non-obvious choices documented in the design comment block above the fixture:
- Function-scoped engine. asyncpg connections bind to the event loop they were created on; pytest-asyncio rotates loops between tests by default. A session-scoped engine surfaces as
RuntimeError: Future attached to a different loopon the second test. join_transaction_mode="create_savepoint". Forces every session into its own SAVEPOINT under the outer transaction so anIntegrityErrorrolls back to the savepoint without detaching the outer transaction.
Pair async_db with the async_test_user fixture, which inserts the test user through the per-test connection so it is visible inside the same outer transaction. End every async_db-using test file with an iso-canary pair (_part_a writes a fixed-id row, _part_b asserts it is gone) to prove rollback isolation. Reference test_async_isolation_rolls_back_between_tests_part_a and _part_b in tests/test_idempotency_pruning_async.py.
tests/multi_user/ runs the AUTH_MODE=multi_user surface. Its conftest.py inherits the root database fixtures and adds three things: a package-level MULTI_USER_APP built by create_app() with the mode set (one instance, so a test's app.dependency_overrides and the client fixture agree on the object), an autouse fixture pinning settings.auth_mode, and a sync db_session for the setup and assertions that suite does through a plain Session.
Put a test there when it exercises sign-in, the admin console, quotas, or operator monitoring. Everything else belongs in tests/, where the app is single-user.
Two things that suite gets wrong easily, both of which surface as a foreign-key violation or a hung TRUNCATE rather than as anything that names the cause:
- Flush a
Userbefore its dependent rows. None of the multi-user models declares an ORM relationship toUser, so a single flush orders the INSERTs by mapper sort key, which putsSubscriptionfirst. See the note on the model. - Set
auth_modewhen you patchsettingswholesale. The lifespan andcreate_app()both read it, and a bareMagicMockcompares unequal to every string, so the multi-user branches silently do not run.
Until this project has its first production release, you do not need to be concerned about backwards compatible changes.
- All type annotations required
- Ruff rules:
E, F, I, UP, B, SIM, ANN, RUF(line length 100,E501andB008ignored) - SQLAlchemy 2.0
mapped_columnstyle for all ORM models - Pydantic v2 for all data classes and request/response schemas
- All routes
async def - All LLM calls via any-llm
amessages(async) - Never use
BaseHTTPMiddlewarefor streaming endpoints -- use pure ASGI middleware - Conventional commit prefixes:
feat:,fix:,docs:,refactor:,test:,ci:,chore: - Every data endpoint uses
Depends(get_current_user)withuser_idscoping - Config via Pydantic
BaseSettingswithextra="ignore" - Prefer
isinstancechecks and direct typed attribute access overgetattr,hasattr, or string-based type checks. Our objects are properly typed; usinggetattrwith defaults masks real problems when types change. Only usegetattr/hasattrwhen explicitly directed or at true dynamic boundaries (e.g. plugin APIs). - Never use em dashes in user-facing content, comments, or copy -- use periods, commas, colons, or pipes instead
- All imports at the top of the file. No inline or deferred imports inside functions. The only exception is
TYPE_CHECKINGguarded imports.
Never write down real names or real personal information anywhere that gets persisted or shared. This includes, but is not limited to:
- Source code (comments, docstrings, string literals, variable names)
- Tests and test fixtures (use obviously fake names like
Alice,Bob,Test User, or domain-appropriate placeholders) - Documentation (READMEs, AGENTS.md, CLAUDE.md, SKILL.md, user guides, design docs)
- Commit messages, branch names, and PR titles/descriptions/comments
- GitHub issues, discussions, and any other public artifact
- Migration files, seed data, and example payloads
- Logs, error messages, and debug output that may be checked in
Real PII to avoid: real customer/user names, real phone numbers, real email addresses, real addresses, real business names from customer data, real Telegram handles or chat IDs, real OAuth tokens or API keys.
Soft PII: use judgment for content that isn't on the hard list above but still ties back to a specific user. Generalize in your own writing.
When you need realistic-looking data, use clearly synthetic values: jane.doe@example.com, +15555550123, Acme Plumbing, UUIDs, or faker-style placeholders. If you encounter real PII in a debugging session or pasted content, scrub it before committing or pushing anything to GitHub.
- pytest with FastAPI
TestClient - PostgreSQL for all tests (requires a local
clawbolt_testdatabase; see conftest.py) reset_stores()clears cached store singletons between tests- Override
get_current_uservia FastAPI dependency injection - Mock ALL external services: messaging channels (BlueBubbles, Twilio, Linq, Telegram), LLM (any-llm), faster-whisper, Google Drive
- Bug fixes must include regression tests
- PostgreSQL storage: all structured data in PostgreSQL via SQLAlchemy 2.0 ORM. See
backend/app/database.pyandbackend/app/models/. Store modules inbackend/app/agent/provide CRUD APIs. - Auth plugin infrastructure: base.py (ABC), loader.py (dynamic import), dependencies.py (get_current_user), scoping.py (row-level auth).
AUTH_MODEselects the model:single_user(default) resolves every request to the one user in the database;multi_userdelegates to a resolver registered viaset_current_user_resolver()and never falls back to the single-user path, since that would serve one tenant's data to an unauthenticated caller. user_idscoping on every data class and endpoint from day one- Message bus: async inbound/outbound queues in
bus.py. Channels publish inbound messages; the agent publishes outbound replies. TheChannelManagerdispatches outbound messages to the correct channel. - Agent loop: channel webhook -> media pipeline -> tool-calling loop (any-llm
amessages) -> tool execution -> reply - Memory: Freeform per-user MEMORY.md managed via workspace tools, backed by
memory_documentstable with automatic compaction - Services: External services abstracted behind service classes in
backend/app/services/
AUTH_MODE=multi_user (see docs/self-host/configuration.md) turns one process into a hosted, multi-tenant deployment. The default, single_user, is unchanged self-hosted behavior and none of this is reachable there.
What the mode switches on, and where it lives:
| Surface | Modules |
|---|---|
| Google OAuth sign-in, JWT sessions, admin API keys | auth/google_oauth router, auth/oauth_flow.py, auth/jwt_auth.py, auth/session_auth.py, services/admin_api_keys.py |
| Admin console, audit log, consent-gated shared data | routers/admin/, routers/admin_shared_data/, routers/admin_reported_conversations.py, services/admin_audit.py, services/pii_redaction.py |
| Account page, data export, deletion | routers/account.py, services/data_export.py, services/user_deletion.py, services/inactive_cleanup.py |
| Per-tenant quotas and plans | billing/ |
| Operator monitoring and email | routers/monitoring.py, services/health_monitor.py, services/admin_alerts.py, services/email_service.py |
| Model-swap evaluation | routers/admin_llm_eval.py, services/llm_eval/ |
| Request middleware (security headers, SEO meta, admin config guard) | middleware/ |
| KMS envelope encryption | security/kms.py, security/dek_cache.py, security/validate.py |
On the frontend, the same surface lives under frontend/src/extensions/, plus pages/marketing/, pages/docs/, layouts/, and components/LoginPage.tsx. It ships in every build and gates itself at runtime, not at build time: isPremiumAuth() reads /api/auth/config, which only reports oauth_google when the backend is in multi_user mode. There is no separate frontend build for the two modes, and no overlay.
Terms and Privacy are the exception. They are a contract naming a specific operator, so a deployment supplies the prose at public/legal/*.html and pages/marketing/LegalPage.tsx renders it. A deployment that supplies nothing gets a placeholder. Do not check legal text into this repo.
Three rules when touching this:
create_app()is the only place that decides what mounts. Routers and middleware are conditional there. Do not gate a route by checking the mode inside the handler.- The agent-level hooks are process-global, so they are installed at
main.pyimport underif MULTI_USER: the quota pipeline, theChannelRouteallowlist override, the heartbeat usage hook, the per-user LLM resolver, and the payload-capture observers. They cannot be per-app, which is whycreate_app()does not touch them. get_kek_provider()inauth/loader.pyis load-bearing for data. Returning a different provider than the one that wrote a row makes everyEncryptedStringcolumn on it unreadable. Changing its resolution order is a data migration, not a refactor.
services/llm_eval/ answers "can this user be moved to a different model" by
replaying their own recent turns through the incumbent and a candidate and
diffing the two decisions. The admin console drives it; routers/admin_llm_eval.py
owns the job lifecycle.
Three invariants, each of which the feature is worthless without:
- A replay never executes a tool. It stops at the model's first decision for a turn. Executing would text real customers and mutate real job records on every evaluation.
- Prompts are built by
ClawboltAgent.assemble_prompt, the same method the live loop calls. A second assembly implementation would score prompts no user ever received. If you change how the agent assembles a turn, the evaluator follows automatically; keep it that way. - Safety findings are never averaged into a quality score.
metrics.pykeeps them in their own tier, and one occurrence of a blocking finding forcesdo_not_switch. Not every finding blocks:BLOCKING_FINDINGSis the set that does, and the rest (a provider error, a tool name the replayed history carries but the current schema does not) describe the fixture or the measurement rather than the candidate, so they surface as run warnings. Anything readingbool(safety_issues)as "disqualified" is a bug. - Whether a tool mutates comes from
ToolTags.READ_ONLY, not the approval policy. Untagged means mutating. See step 7 of "Adding a New Agent Tool". - A finding is judged against what the turn actually did, not against the
incumbent's first decision alone. A replay captures one decision, so
check_safetytakes the turn'shistoric_tool_names: a mutation the live agent went on to make is not unrequested, and a tool name the incumbent also reaches for is a fixture artifact (UNRESOLVED_TOOL_NAME, non-blocking) rather than a candidate hallucination. Both mistakes produced almost every blocking finding in the first real runs. - A failing provider stops the run.
MAX_CONSECUTIVE_CALL_FAILURESconsecutive errored turns end it withFAILED, the evidence already gathered, andinconclusivestamped on both the column and the summary. A run competes with live traffic at the same gateway, so it must not spend a 200-sample budget rediscovering that the provider is down.
Consent-gated like /admin/shared-data: a run reads real conversations and the
report renders them back, so both require User.data_sharing_consent. Content is
PII-redacted at serialization, after the comparison, so verdicts are computed on
real values and only the human-readable drill-down is masked.
The agent's capabilities are extended by adding tools. Tools follow a factory/registry pattern with auto-discovery.
- Core tools (
core=True): Always available to the agent on every message. Use for universal capabilities (math, messaging, files, workspace). No activation step needed. - Specialist tools (
core=False): Gated on the integration'sauth_check; loaded onto the schema from turn 1 once the user connects the integration (the tool list is held stable per auth state for prompt-cache reasons, issue #1170). Use for integrations and domain-specific features (calendar, QuickBooks, CompanyCam). Thelist_capabilitiesmeta-tool surfaces unconnected integrations and serves SKILL.md guidance; it does not activate tools.
-
Create the tool module at
backend/app/agent/tools/<name>_tools.py. Follow the pattern inheartbeat_tools.py(simplest example): Pydantic params model, async tool function returningToolResult, factory function, and_register()called at module level. The_toolssuffix is required for auto-discovery. -
Add tool name constants to
backend/app/agent/tools/names.pyin theToolNameclass. All tool name strings must be defined here to prevent silent breakage on renames. -
Register in the dashboard at
backend/app/routers/user_tools.py:- Add the factory name to
_CORE_FACTORIES(if core) so it cannot be disabled - Add a
_FACTORY_METAentry with a description (anddomain_group/domain_group_orderif specialist)
- Add the factory name to
-
Add to the registry test at
tests/test_tool_registry.py: add"backend.app.agent.tools.<name>_tools"toEXPECTED_TOOL_MODULES. -
Wire up approval policies for any mutating tool. If a
SubToolInfodeclaresdefault_permission="ask", the correspondingToolobject must haveapproval_policy=ApprovalPolicy(default_level=PermissionLevel.ASK). Without this, the WebUI shows "ask" but the runtime auto-executes. Seequickbooks_tools.pyfor the reference pattern. The global testtest_ask_sub_tools_have_approval_policyintest_tool_registry.pyenforces this. -
Set a
concurrency_groupif your tool mutates shared state. The agent runs all approved tool calls from a single LLM turn concurrently by default. Tools with the same non-Noneconcurrency_groupserialize in submission order; tools with different keys (orNone) may run in parallel. Set this whenever your tool could race with another tool in the same turn against a shared resource, for example a DB row, a workspace document, a disk file, or the user-facing message stream. Read-only and stateless tools should leave itNone. Accepts either a static string or a callable that takes the validated args and returns a key, for the case where a single tool routes to distinct resources by argument (e.g. workspace writers keyed by file path). Existing keys:"workspace_path:<path>"for workspace document mutations (resolved per call by_workspace_path_concurrency_key),"user_outbound"for reply senders,"user_integrations"for integration toggles. The global testtest_state_mutating_tools_have_concurrency_groupintest_tool_registry.pyenforces that any tool taggedMODIFIES_PROFILEorSENDS_REPLYdeclares one. -
Classify the tool as a read or a write. Tag it
tags={ToolTags.READ_ONLY}if calling it only looks something up; otherwise add its name to_MUTATING_TOOLSintests/test_tool_registry.py. Untagged means mutating. The approval policy cannot stand in for this:ApprovalPolicydefaultsdefault_leveltoASK, so most search and list tools are gated too, whilewrite_fileandmanage_integrationwrite without being gated. The model-swap evaluator blocks a switch on a single unrequested mutation, so a read left untagged sinks a run over a lookup and a writer left unlisted lets a candidate rewrite MEMORY.md with nothing reported. The global testtest_every_tool_is_classified_read_or_writeintest_tool_registry.pyenforces it, reaching integration tools through their own builders rather than the registry factories. -
Write tests at
tests/test_<name>_tools.py. Call the factory function directly (e.g.,_create_calculator_tools()) and invoke the tool function. No database needed for stateless tools. -
(Specialist only) Add a SKILL.md at
backend/app/agent/skills/<name>/SKILL.mdif the tool has complex workflows the LLM needs guidance on. This markdown is delivered into the conversation as a tool result: either when the LLM callslist_capabilities("<name>"), or auto-appended to the first result of the category's tools when the model skips discovery (each delivery carries a[skill-guidance: <name>]marker so it happens at most once per context window). Core tools do not need SKILL.md; theirdescriptionandusage_hintfields in the Python code serve the same purpose. See "SKILL.md structure for specialist tools" below for the expected skeleton.
| File | Purpose |
|---|---|
backend/app/agent/tools/base.py |
Tool, ToolResult, ToolErrorKind definitions |
backend/app/agent/tools/names.py |
All tool name constants (ToolName class) |
backend/app/agent/tools/registry.py |
ToolRegistry, ToolFactory, ToolContext, auto-discovery |
backend/app/agent/skills/loader.py |
SKILL.md loader (get_skill_instructions) |
backend/app/routers/user_tools.py |
Dashboard wiring (_CORE_FACTORIES, _FACTORY_META) |
SKILL.md files and the agent system prompt are injected into the LLM context on every relevant turn, so prose costs tokens on every conversation. Be terse and non-redundant when editing them.
- Do not duplicate information that already lives elsewhere in the file. If a field's shape is shown in a payload example or an example workflow, do not re-document it in a separate "field shapes" preamble. The agent reads the whole file.
- State rules at the failure mode, not as top-of-section framing. A "do not claim X is unavailable" warning belongs inside the workflow that prevents X. A general preamble at the top of a section is usually padding the headings or steps already imply.
- Trust the steps. A numbered workflow does not need an intro paragraph explaining when to use it; the heading and the cross-references from other sections already do that.
- Cut padding. Phrases like "Use this whenever ...", "If a field is listed here, the entity has it ...", "It is important to ..." are framing the structure already implies. Delete the sentence; if the meaning is intact, it was redundant.
After editing, read the diff and ask: did I add a new fact, or restate an old one? Restated facts double the prompt without doubling agent behavior.
A specialist SKILL.md is delivered at most once per context window (on list_capabilities("<name>") or first use of the category's tools), so it pays its cost in one place. Aim for 60-150 lines covering what tool descriptions and usage_hint strings cannot carry on their own.
Use this skeleton; drop sections that do not apply:
- Lead paragraph. Name the platform, the entities that live in it, and the scope of this integration's coverage.
- Available Tools table. Group by domain when the surface is wide (e.g. Projects / Photos / Checklists). Columns: tool name in backticks, purpose, approval (
AutoorAsk). Every registered tool gets a row. - Entity vocabulary. Bullets defining each first-class entity (id, key fields, relationships). Include enum literals (status values, type codes) verbatim; the agent has no other way to know them.
- Failure-mode rules. Per-topic sections (e.g.
## Photo handles,## Dates) stating non-obvious constraints at the place they apply, plus the consequence of breaking them. - Common Workflows.
### Named workflowsubsections with numbered steps, one tool call or one decision per step. Cover the multi-tool recipes the agent cannot infer from individual tool descriptions. - Companion integrations. Bullets pointing to other integrations the agent may compose with (e.g. "X customer ids are not Y project ids"). Reciprocate when another SKILL.md cross-references this one.
- Connecting. OAuth flow, paste-token, or magic-link steps. Note what happens if the connection lapses.
Reference implementations: backend/app/integrations/servicetitan/SKILL.md (clean, mid-sized) and backend/app/integrations/companycam/SKILL.md (wider tool surface).
Every change must pass all checks before it's considered complete:
uv run pytest -v # tests pass
uv run ruff check backend/ tests/ alembic/ # lint passes
uv run ruff format --check backend/ tests/ alembic/ # format passes
uv run ty check --python .venv backend/ tests/ alembic/ # type checking passes
cd frontend && npm run typecheck # TypeScript type checking passes
cd frontend && npm run deadcode # no dead JS/TS code (knip)
cd frontend && npm run test # vitest suite passesWhen backend schemas change (backend/app/schemas/, route signatures, response models, or endpoint docstrings), you must regenerate the frontend OpenAPI types. Never hand-edit frontend/src/generated/api.d.ts. CI will fail if the committed file doesn't match what the generator produces.
The spec is exported in multi_user mode, so it documents every route the product can serve. The frontend ships as one bundle for both modes and calls those routes through a typed client; a spec narrowed to single_user would leave the sign-in, account, and admin calls untyped.
uv run python scripts/export_openapi.py # export openapi.json from backend
cd frontend && npm run generate:api # regenerate src/generated/api.d.tsCommit both frontend/openapi.json and frontend/src/generated/api.d.ts.
- Bug fixes include regression tests
- New features evaluate whether the user docs (
frontend/src/docs-content/) need updates - Features that change how users interact with the assistant must update the user guide (
frontend/src/docs-content/guide/) - When you manage a pull request, you must always adhere to the pull request template at .github/pull_request_template.md
- CI green
target/, node_modules/, and .venv/ don't persist between sessions. Run uv sync at the start of each session if needed.
Tests require a running PostgreSQL instance. In a sandbox without Docker, install and start PostgreSQL directly:
# Install PostgreSQL (Debian/Ubuntu)
apt-get update -qq && apt-get install -y -qq postgresql postgresql-client
# Start the cluster
pg_ctlcluster 16 main start
# Create the test user and database
su - postgres -c "psql -c \"CREATE USER clawbolt WITH PASSWORD 'clawbolt' CREATEDB;\""
su - postgres -c "psql -c \"CREATE DATABASE clawbolt_test OWNER clawbolt;\""The test suite connects to postgresql://clawbolt:clawbolt@localhost:5432/clawbolt_test. The conftest.py handles table creation and per-test TRUNCATE automatically.
pytest -n auto (or any -n <N>) is supported. Each xdist worker writes to its own database (clawbolt_test_gw0, clawbolt_test_gw1, …); the conftest auto-creates them on demand by connecting to the postgres admin DB (so the clawbolt role needs CREATEDB, which the install command above grants). Sequential runs and CI continue to use clawbolt_test unchanged.
Set OSS_TEST_DB=<name> to override the database name explicitly (useful when several agents or branches run pytest at the same time and you want stable per-branch DBs).
Git auth is pre-configured. Never push directly to main. Always create a branch and open a PR.
Always read DESIGN.md before making any visual or UI decisions. All font choices, colors, spacing, and aesthetic direction are defined there. Do not deviate without explicit user approval. In QA mode, flag any code that doesn't match DESIGN.md.
The frontend is token-driven; frontend/src/styles/README.md is the engineering reference. Rules:
- Never hard-code colors in components. Use semantic Tailwind utilities backed by tokens (
bg-card,text-muted-foreground,text-primary,border-border, the*-bg/*-textstate pairs). No raw palette colors (bg-gray-200), no hex literals, notext-whiteon a colored fill (use the fill's*-foregroundtoken). Brand/3rd-party icons are the only exception. - Colors live in two source files:
brand-tokens.css(--brand-color-*, drives app utilities) andpalette.ts(drives HeroUI components). To change a color, edit the token there, not components. If it is a HeroUI-used color, also editpalette.tsand runnpm run generate:tokens(regeneratesheroui-tokens.generated.css, which is committed and never hand-edited). - HeroUI is themed through the same tokens at runtime; customize HeroUI via props /
classNameswith token utilities, not by overriding internals. src/styles/tokens.test.tsguards undefined-token usage, palette/brand-tokens drift, and generated-file freshness.node scripts/audit-contrast.mjschecks WCAG AA for every semantic pairing. Keep all semantic pairings AA in both themes.- Mobile-first responsive utilities; wrap tables in
overflow-x-auto; prefer CSS state over JS layout swaps to avoid layout shift.