CRM Claw Handbook

The complete operator manual. If you're new to vibe coding, new to this codebase, or new to running a self-hosted CRM, start here. Everything you need to understand, deploy, customize, and operate CRM Claw is in this single document.

If something is missing or wrong, that's a bug in the docs — open an issue.


What you'll learn in this handbook

By the end of Part 12 you will know:

  • How to back up and restore a tenant safely
  • How to publish the iOS app to your iPhone via Diawi (and what an Apple Developer account costs you)
  • How to set up Google Workspace + GCP so the agent can send emails, create calendar events, and start Google Meet links
  • What openclaw is and exactly which parts of it CRM Claw reuses
  • What MCP is and how to plug Claude Desktop into your CRM
  • How the iOS app talks to the agent via context blocks and tool result blocks
  • The full file tree, layer by layer, with "what's this for" annotations
  • How to add a new HTTP route, a new agent tool, a new email provider, a new chunk type
  • How to read the logs and debug a stuck request
  • How to fork the repo for your own business
  • How to keep iterating with Claude without breaking things
  • What's secret and what's safe to commit
  • Where the project is going next

Let's start.


This is a starting point, not a finished product

CRM Claw is a working base. The agent runs, the iOS app runs, emails go out, memory persists. But the CRM that fits your business perfectly is the one you shape from here — and you don't need to know how to code to do it.

You have specific data you want on your dashboard? Just ask. You want the agent to automatically follow up leads that haven't replied in 5 days? Just ask. You want a new tab in the iOS app for recording call notes? Just ask.

Open the repo in Claude Code, describe what you want in plain language, and Claude reads the existing code, fits the new feature into the existing patterns, and shows you exactly what it changed. You read the diff, you accept it, you restart the server. That's the whole loop.

Every file in this repo was written that way. Not a single line was typed by hand. The architecture decisions are yours — the typing is the AI's job.

The one rule: read every diff before accepting it. Not to understand every line of TypeScript, but to check that Claude did what you actually asked. If something looks off, ask it to explain. If it's wrong, ask it to fix. The diff is your checkpoint.


Table of contents


Part 1 — Architecture tour for beginners

The 30-second version

CRM Claw is a TypeScript backend (Node.js + Hono HTTP + Postgres) plus a Swift iOS client. The backend exposes a REST + SSE API that the iOS app consumes. The backend also hosts an AI agent that talks to Claude over OAuth and uses the same backend's tools to act on the CRM data. Each "tenant" runs as a separate process with its own database, its own memory file, its own prompt, and its own brand. There are no shared singletons.

The file tree, annotated

crmclaw/
├── README.md                  Public face. Has the badges and the quickstart.
├── LICENSE                    MIT.
├── .nvmrc                     Pins Node 22 (so `nvm use` picks the right version).
├── .npmrc                     Tells pnpm to allow native build scripts (better-sqlite3).
├── .gitignore                 Excludes data/, node_modules/, build/, secrets, etc.
├── package.json               Root pnpm workspace manifest. Defines `pnpm dev`, `pnpm typecheck`.
├── pnpm-workspace.yaml        Tells pnpm which folders are workspace packages.
├── tsconfig.base.json         Shared TypeScript config: strict mode, ES2022, ESM.
├── .github/workflows/ci.yml   GitHub Actions: scrub-check + typecheck on every push.
│
├── docs/
│   ├── HANDBOOK.md            <- you are here
│   ├── ARCHITECTURE.md        Short architecture overview. The condensed version of Part 1.
│   ├── INSTALL.md             Quickstart + prerequisites.
│   ├── PROVIDERS.md           Email provider catalog + how to add one.
│   ├── MULTI-TENANT.md        The seven isolation boundaries.
│   ├── MOBILE-FIRST.md        Why mobile is the primary client.
│   └── VIBE-CODED.md          The manifesto.
│
├── scripts/
│   ├── install.sh             First-boot: deps check + boot test of example tenant.
│   ├── new-tenant.sh          Scaffold a new tenant from config/profiles/example/.
│   ├── new-provider.sh        Scaffold a new email provider from _template/.
│   ├── doctor.sh              Cold BootGuard check across every tenant.
│   ├── backup.sh              Backup one tenant or all tenants.
│   └── scrub-check.sh         CI guard: refuse commits that contain forbidden brand strings.
│
├── docker/
│   ├── docker-compose.yml     Local Postgres 16 stack for dev (port 5455).
│   └── README.md              How to use it.
│
├── config/
│   ├── profiles/
│   │   ├── .gitignore         Allows ONLY example/ to be committed. Everything else is private.
│   │   └── example/
│   │       ├── profile.ts     The Profile object (zod-validated)
│   │       ├── prompt.md      System prompt (loaded by the agent at every turn)
│   │       ├── seeds/         Optional SQL seeds run after migrations
│   │       ├── .env.example   Env var template
│   │       └── README.md      How to copy this template into a real tenant
│   └── schemas/
│       └── profile.schema.json  Future: JSON schema for editor autocomplete
│
├── mcp-server/
│   ├── package.json           Workspace package: dependencies for the backend
│   ├── tsconfig.json          Backend-specific TS config (rootDir = src)
│   ├── migrations/
│   │   └── 000_crmclaw_tenant.sql   Creates the fingerprint table + index
│   └── src/
│       ├── index.ts           Entry point: loads profile, runs BootGuard, opens services, starts HTTP
│       │
│       ├── config/
│       │   ├── profile.ts     Zod schema for Profile (Supabase, email, memory, agent, brand, apns)
│       │   ├── loader.ts      Dynamic import of config/profiles/<id>/profile.ts at boot
│       │   └── boot-guard.ts  Runs the four fingerprint checks; returns Sql client on success
│       │
│       ├── db/
│       │   ├── connection.ts  postgres library wrapper (porsager/postgres) per tenant
│       │   └── migrate.ts     Idempotent migration runner with checksum tracking
│       │
│       ├── email/
│       │   ├── provider.ts    EmailProvider interface + ProviderContext type
│       │   ├── registry.ts    registerEmailProvider() / createEmailProvider()
│       │   ├── capabilities.ts EmailCapabilities flags (inbound, drafts, tracking, ...)
│       │   └── providers/
│       │       ├── index.ts   Side-effect imports that register all providers at boot
│       │       ├── noop/      No-op provider, used by the example tenant
│       │       ├── gmail/     Gmail Workspace via service account + domain-wide delegation
│       │       ├── resend/    Resend transactional API + svix webhook verification
│       │       └── _template/ Scaffold for new providers, copy-pasted by new-provider.sh
│       │
│       ├── memory/
│       │   ├── types.ts       ChunkRecord / SearchHit / SearchOptions / CORE_CHUNK_KINDS
│       │   ├── store.ts       MemoryStore class: schema, upsert, hybrid search, decay, MMR, prune
│       │   ├── factory.ts     createMemoryStore(profile) -> picks the right embedder + opens the store
│       │   ├── embedder.ts    Embedder interface + StubEmbedder (deterministic)
│       │   └── embedders/
│       │       └── bge3m.ts   Real bge-m3-Q4_K_M via node-llama-cpp, lazy load, queue
│       │
│       ├── agent/
│       │   ├── types.ts       AssistantContext / ChatMessage / AgentEvent / ToolDef / Agent
│       │   ├── oauth.ts       Loads the Claude Code OAuth token (file -> Keychain fallback)
│       │   ├── prompt.ts      buildSystemPrompt(profile, context) -> reads the prompt.md + appends ctx
│       │   ├── factory.ts     createAgent(profile) -> { profileId, streamChat() }
│       │   ├── loop.ts        The streaming chat generator (pi-ai + tool loop, max iterations)
│       │   └── tools/
│       │       ├── index.ts   buildCoreToolset() + ANTHROPIC_WEB_SEARCH placeholder
│       │       └── bash.ts    Sandboxed Bash tool (localhost-only by default)
│       │
│       └── http/
│           └── server.ts      Hono app: /health, /boot-guard, /db/health,
│                              /email/{provider,providers}, /memory/{stats,chunks,search},
│                              /agent/{info,chat (SSE)}
│
└── ios/
    ├── CRMClawApp.xcodeproj   Xcode project (single scheme "CRMClawApp")
    ├── CRMClawApp.entitlements Push Notifications, App Groups, Keychain Sharing
    ├── CRMClawApp/
    │   ├── App/
    │   │   ├── CRMClawApp.swift           App entry point (@main)
    │   │   ├── AppDelegate.swift          UIKit lifecycle hooks (push token, deep links)
    │   │   ├── PushNotificationManager.swift
    │   │   └── ProjectProfile.swift       <- the Info.plist tenant config singleton
    │   ├── Components/                    Reusable UI bits (BadgeView, EmailPreviewCard, ...)
    │   ├── DesignSystem/                  Colors, Typography
    │   ├── Models/                        Codable structs that mirror the API shape
    │   ├── Services/                      APIClient, AuthManager, AssistantAPIService, ...
    │   ├── ViewModels/                    @Observable view models per screen
    │   ├── Views/                         SwiftUI views grouped by feature
    │   │   ├── Auth/
    │   │   ├── Inbox/
    │   │   ├── Contacts/
    │   │   ├── Organizations/
    │   │   ├── Tasks/
    │   │   ├── Dashboard/
    │   │   ├── Recordings/
    │   │   ├── Campaigns/
    │   │   ├── Sequences/
    │   │   └── Assistant/                 The chat overlay + voice orb + PTT button
    │   └── Resources/
    │       ├── Info.plist                 Holds CRMCLAW_* keys read by ProjectProfile
    │       └── Assets.xcassets            App icons, color sets
    ├── AssistantWidget/                   Live Activities target (Dynamic Island)
    ├── ShareExtension/                    System share sheet integration
    ├── scripts/
    │   ├── build-ipa.sh                   xcodebuild archive + export
    │   ├── deploy.sh                      build + upload-diawi in one shot
    │   ├── upload-diawi.cjs               Playwright automation for diawi.com
    │   └── add-pending-files.cjs          pbxproj patcher (used after the legacy copy)
    └── ExportOptions.plist                Signing config for the IPA export step

How a request flows through the system

A user opens the iOS app, taps the chat button, says "show me the contacts at Acme that I haven't emailed yet". Here's what happens, layer by layer:

  1. iOS ViewsAssistantOverlayView captures the user's voice, sends it to the backend's /assistant/transcribe endpoint (Voxtral / Whisper / etc.), gets back text.
  2. iOS ServiceAssistantAPIService.sendMessage(text, context) builds an AssistantContext from the current screen state and posts it to POST /agent/chat with application/json.
  3. HTTP layermcp-server/src/http/server.ts validates the body via zod, opens an SSE response, calls ctx.agent.streamChat({ message, history, context }).
  4. Agent loopmcp-server/src/agent/loop.ts loads the OAuth token, builds the system prompt (reads config/profiles/<tenant>/prompt.md and appends a "current UI context" block built from AssistantContext), opens a pi-ai streamAnthropic call, listens for events.
  5. Claude — receives the prompt + tools + history, decides the next action. In this case it calls the Bash tool with a curl command targeting /contacts?organization_id=<acme uuid>&....
  6. Tool execution — the Bash tool runs curl http://localhost:3940/contacts?..., which hits the same Hono server's contacts route, which queries Postgres, returns JSON.
  7. Agent loop, iteration 2 — the tool result is appended to the message list as a toolResult message. pi-ai is called again with the updated history. Claude reads the JSON, formats a human-readable list, and emits a final text answer.
  8. SSE stream — the agent yields a series of chunk events with the answer text. The HTTP layer forwards them as data: {...}\n\n lines. The iOS client renders them in the chat overlay.
  9. done — the agent yields a final done event. The HTTP layer closes the SSE connection.

The whole thing typically takes 3-8 seconds depending on how many tool calls are needed. The user sees text streaming in token-by-token because pi-ai forwards every Anthropic event as it arrives.

Why pnpm workspaces

The repo is a monorepo with two packages: mcp-server (the Node backend) and admin-console (the optional Next.js admin UI). Both share the root tsconfig.base.json and root package.json scripts. pnpm is used instead of npm because it handles workspaces natively, deduplicates dependencies across packages, and is significantly faster.

When you run pnpm install at the root, pnpm installs deps for both packages and links them together. When you run pnpm dev, the root package.json script is pnpm --filter @crmclaw/mcp-server dev, which runs the dev script of the mcp-server package only. The --filter flag selects which package's scripts to run.

Why TypeScript strict

tsconfig.base.json enables strict: true, which turns on all the safety checks:

  • noImplicitAny — every variable must have a known type
  • strictNullChecksT | null is a different type from T
  • noUnusedLocals and noUnusedParameters — catches dead code
  • noFallthroughCasesInSwitch — explicit return or break required between cases

The reason: this codebase is meant to be vibe coded. AI-assisted code makes mistakes that pure runtime tests don't catch — passing undefined where the function expected a string, forgetting to handle a null case, leaving an unused import. The strict TypeScript compiler catches every one of those at the moment Claude writes them, before the code ever runs. Run pnpm typecheck after every change.

Why Hono

Hono is a small, fast, edge-friendly HTTP framework that runs anywhere (Node, Bun, Deno, Cloudflare Workers, etc.). We use it because:

  • It's stateless — every route is a pure function
  • It supports SSE natively via streamSSE
  • It works with zod for body validation
  • Its API is small enough that you can read the source in an hour

The whole HTTP layer is under 200 lines because Hono lets the routes stay focused on their job (parse the request, call the service, return the response).

Why Postgres + SQLite both

CRM Claw supports two database drivers because they fit two different deployment models:

  • SQLite is the zero-dependency option. It runs in the same process as the server. There's no daemon, no socket, no docker. The example tenant uses it so a fresh checkout boots without any infrastructure. SQLite is also fine for small tenants — a single-user CRM with 10k orgs and 100k emails fits in a 50 MB SQLite file with no performance issues.

  • Postgres is the production option. You point CRM Claw at an existing Supabase / RDS / self-hosted Postgres, and BootGuard verifies the connection + tenant fingerprint before letting the server start. Use this when the database is shared with other systems (e.g. you already have a CRM database that other services read from).

The database driver is set per-tenant in profile.supabase.driver. The rest of the code doesn't care which one it has — both expose the same schema (organizations, contacts, etc.) and the service layer queries them through a thin wrapper.

Why bge-m3 specifically

The agent's memory needs an embedder. There are dozens to choose from:

  • OpenAI text-embedding-3-small — fast, cheap, requires an API key + every call goes to OpenAI.
  • Cohere embed-multilingual-v3 — same, requires a Cohere API key.
  • bge-m3 — open-source from BAAI, multilingual, runs locally via llama.cpp, no API key, no network round trip per embedding.

We picked bge-m3 because the agent's memory is supposed to be local-first. Every embedding call should be free, fast, and offline-capable. bge-m3 fits all three. The 1024-dim vectors are competitive with proprietary models on hybrid retrieval benchmarks.

The cost: a one-time 437 MB model download per tenant data dir, and 2-3 GB of RAM while loaded. For a single-user laptop that's fine. For a production server hosting many tenants, share the model file via symlink.


Part 2 — Getting started: your first tenant

The mental model

CRM Claw is the runtime. Your business is the configuration. Most of what makes a CRM "yours" lives in three places:

  1. Your tenant profile (config/profiles/<tenant>/profile.ts) — which database, which email provider, which brand info, which features
  2. Your prompt (config/profiles/<tenant>/prompt.md) — what the agent knows about your business, what it's allowed to do, what tone it should use
  3. Your service-layer code (mcp-server/src/services/) — domain-specific business logic (e.g. "score a lead", "match an org to an industry")

The first two are pure configuration: you change them by editing files in your gitignored tenant directory. No code touches needed. The third is where you'd write actual business code — but most tenants get away without writing any, because the generic CRUD over organizations / contacts / email_threads covers the basics.

Where to start

Day 1:

  1. ./scripts/new-tenant.sh mybiz — scaffolds config/profiles/mybiz/
  2. Edit config/profiles/mybiz/profile.ts — pick database driver, email provider, brand
  3. Edit config/profiles/mybiz/prompt.md — write a paragraph in your own voice describing your business + what you want the agent to help with
  4. CRM_PROFILE=mybiz pnpm dev — boot
  5. Open the iOS app, try a few chat messages, see what the agent does

Day 2-7 loop

  • Identify the things the agent gets wrong. Add them as explicit instructions in prompt.md.
  • Identify the things the agent should do but lacks the tools for. Add the tools (Part 8 cookbook).
  • Identify the data the agent should know about but doesn't have. Add the routes (Part 8 cookbook) and tell the agent about them in prompt.md.

This is a tight loop. Each iteration is "edit prompt.md → restart server → try the chat → repeat". The prompt is the single biggest lever; spend your time there before writing code.

What to keep when upstream changes

If you forked the public repo (or pulled it as a remote), upstream might evolve. To keep your tenant-private changes from conflicting with upstream changes:

  • Tenant profile + prompt live in config/profiles/<tenant>/, which is gitignored. Upstream can't touch them. Free.
  • APNs key, Google service account JSON, Resend API key all live in data/<tenant>/, also gitignored. Free.
  • Custom service code that you wrote in mcp-server/src/services/yourbiz/ is committed. If upstream changes touch the same files, you'll see merge conflicts. Solution: keep your custom services in their own subdirectory, never edit core service files directly.
  • Custom prompts that need to be in code (e.g. a system prompt for a maintenance job, not a chat prompt) live in config/profiles/<tenant>/prompts/maintenance.md. Read them from your service code via readFileSync against profile.dataDir + '/../config/profiles/<id>/prompts/...'.
  • Schema migrations for your custom tables go in mcp-server/migrations/ with a high prefix like 9XX_yourbiz_*.sql so they sort after the core ones and don't conflict.

If you ever want to upstream a custom service back, refactor it to be tenant-agnostic first: any reference to your own brand, your own SQL schema, your own URLs becomes a profile field.

Customizing the iOS app

The iOS app is also yours to fork. The brand strings and the API URL are runtime config (Part 4's ProjectProfile.swift), so the most common customization — point at a different server, change the FROM email, change the tracking domain — needs zero code changes.

Beyond that, the app's UI lives in ios/CRMClawApp/Views/. To add a new screen:

  1. Create a new SwiftUI view in Views/MyFeature/
  2. Add it to a tab in MainTabView (or to a navigation push from an existing view)
  3. Run node ios/scripts/add-pending-files.cjs to register the new file in the pbxproj (or use the Xcode UI to add it manually)
  4. xcodebuild to verify it compiles

The legacy app uses @Observable view models (Swift 5.9+) and SwiftUI navigation stacks. Keep the same patterns and your new screens will fit in.

Customizing for a non-CRM use case

CRM Claw is structured as a "CRM" but the bones — multi-tenant runtime, persistent agent memory, plugin email providers, mobile-first iOS client — are useful for any vertical agent app. If you're building, say, a personal trainer app or a real-estate management tool:

  1. Keep config/profile.ts, boot-guard.ts, memory/, agent/, email/ — they're all generic.
  2. Replace the service-layer assumptions ("organizations", "contacts", "email_threads") with your own domain (gyms, members, workouts or properties, tenants, leases).
  3. Rewrite the prompt to be about your domain.
  4. Rebuild the iOS views to show your domain entities instead of CRM ones.
  5. Maybe drop the email/ provider system entirely if your app doesn't send mail.

The core architectural principles transfer one-to-one. The CRM-ness is in the names, not the structure.


Part 2b — Deploying on a VPS

If you don't have a Mac that can stay on 24/7, you can run CRM Claw on a cheap Linux VPS. The installer handles everything: Docker, Supabase, HTTPS certificates, reverse proxy.

Choosing your server

OptionCostBest for
Mac mini / Mac Studio (recommended)One-time hardware purchaseFull control, no monthly bill, compile iOS locally
Hetzner Cloud~4 EUR/month (CX22)Cheapest VPS with good perf, EU datacenter
OVH VPS~5 EUR/month (Starter)French provider, EU datacenter
Hostinger VPS~5 EUR/monthBeginner-friendly interface
DigitalOcean~6 USD/month (Basic)Good docs, US/EU datacenters

Minimum specs: 2 vCPU, 4 GB RAM, 40 GB SSD. One Supabase stack + one mcp-server uses about 1.5 GB RAM. Each additional workspace adds ~1.5 GB.

Install on VPS

SSH into your server and run:

git clone https://github.com/ncleton-petitmaker/crmclaw && cd crmclaw && ./scripts/install.sh

The installer will:

  1. Ask you to choose VPS + public domain (option 2)
  2. Ask for your domain name (e.g. crm.mycompany.com)
  3. Check that your DNS A record points to this server's IP
  4. Install Caddy (reverse proxy with automatic HTTPS via Let's Encrypt)
  5. Ask how many workspaces, their names, admin emails, passwords
  6. Spin up one Supabase stack per workspace
  7. Start Caddy with auto-TLS
  8. Print the HTTPS URL to paste in the iOS app

After install, your CRM is live at https://crm.mycompany.com with a real TLS certificate. No ports to remember, no IP addresses.

Building the iOS app without a Mac

Your VPS runs Linux, but the iOS app requires Xcode (macOS only). Two options:

Option A — You have access to any Mac (even a friend's, even briefly): Clone the repo, open in Xcode, Build & Run to your iPhone. Takes 5 minutes. Free.

Option B — No Mac at all, use Codemagic (cloud build): Codemagic compiles iOS apps on Mac mini M2 machines in the cloud. Free tier: 500 build minutes per month (~33 builds of 15 minutes each).

Steps:

  1. Fork the CRM Claw repo on GitHub
  2. Create a free account on codemagic.io (sign in with GitHub)
  3. Add your forked repo in Codemagic
  4. Upload your Apple Developer certificate (.p12) and provisioning profile (.mobileprovision) in Codemagic settings
  5. Push a commit or click "Start build"
  6. Download the .ipa file from the build artifacts
  7. Upload to diawi.com and open the link on your iPhone to install

The repo ships a ready-to-use codemagic.yaml that handles everything automatically.

Cost summary for VPS path: VPS ~5 EUR/month + Apple Developer 99 EUR/year + Codemagic free tier. No Mac needed.

Connecting from your phone

After installation, run:

crmclaw pair

This shows the URL to paste in the iOS app. On a VPS it will be your HTTPS domain:

Collez cette URL dans l'app :
https://crm.mycompany.com

Network note: Tailscale vs public domain

  • VPS with a domain: your CRM is on the public internet. Anyone with the URL can reach the login page (but they need valid credentials to get in). HTTPS is automatic via Caddy + Let's Encrypt.
  • Local Mac with Tailscale: your CRM is on a private network. Only devices signed into your Tailscale account can reach it. No TLS needed (Tailscale encrypts everything). Tailscale is a free VPN that connects your devices together securely, even across different WiFi networks or on cellular. Install it on your Mac and your iPhone, sign in with the same account, and they can see each other.

Part 3 — Google Cloud setup

What you're going to do

If your tenant uses the gmail email provider, you need to:

  1. Create a Google Cloud project
  2. Enable the Gmail, Calendar, and (optionally) Meet APIs
  3. Create a service account
  4. Download the service account JSON key
  5. Authorize domain-wide delegation in your Google Workspace admin console
  6. Wire the credentials into your tenant profile

After this, the agent can:

  • Send emails as a delegated user (sender@yourcompany.com for example)
  • Read the user's inbox via polling
  • Create calendar events
  • Attach a Google Meet link to any calendar event

If your tenant uses resend instead, skip this part — see docs/PROVIDERS.md for the Resend setup walkthrough.

Prerequisites

  • A Google Workspace account with admin access. The free gmail.com consumer accounts do NOT support service-account delegation. You need Workspace (paid, starting at ~$7/user/month for Business Starter).
  • A user mailbox in your domain that the agent will impersonate (typically your own).
  • About 20 minutes.

Step 1 — Create a Google Cloud project

Go to console.cloud.google.com.

If this is your first project, Google asks you to accept the terms and pick a country. Then:

  • Top bar → project dropdown → New Project
  • Name: "CRM Claw" (or whatever you like)
  • Organization: pick your Workspace org if you have one
  • Click Create

Note the Project ID (looks like crmclaw-492009). You'll need it in a moment.

Step 2 — Enable the APIs

In the Cloud Console for your project:

  1. Sidebar → APIs & Services → Library
  2. Search for and enable each of:
    • Gmail API
    • Google Calendar API
    • Google People API (optional, used to fetch user profiles when populating sender info)
    • Cloud Asset API (only if you plan to use Cloud Storage features later)

You don't need to enable a separate "Google Meet API" — Meet links are created via the Calendar API by passing conferenceData when inserting an event. The Calendar API itself is the only thing that needs to be enabled.

Step 3 — Create the service account

  1. Sidebar → IAM & Admin → Service Accounts
  2. Create Service Account
  3. Name: crmclaw-mailer (or whatever you like)
  4. Service account ID: auto-fills from the name
  5. Description: "Sends mail and creates calendar events on behalf of the delegated user"
  6. Create and continue
  7. Skip "Grant this service account access to project" — you don't need any roles for the APIs we use
  8. Skip "Grant users access to this service account"
  9. Done

You'll land on the service accounts list with your new account.

Step 4 — Download the JSON key

  1. Click on the service account you just created
  2. Tab Keys → Add Key → Create new key
  3. Type: JSON
  4. Create

The browser downloads a JSON file (crmclaw-492009-abc123.json). This is the only copy. Lose it and you have to create another key. Do not commit it.

Also, on the same page, find the Unique ID (a long number). This is the Client ID you'll need in Step 6. Copy it.

Step 5 — Drop the JSON key into the tenant data dir

mkdir -p data/acme
mv ~/Downloads/crmclaw-492009-abc123.json data/acme/google-sa.json
chmod 600 data/acme/google-sa.json

data/acme/ is gitignored. The key never enters the repo.

Step 6 — Enable domain-wide delegation in Workspace

This is the critical step. Without it, the service account exists but cannot impersonate any user, and all Gmail / Calendar calls fail with unauthorized_client.

  1. In a NEW browser tab, open admin.google.com (you need a Workspace admin role)

  2. Sidebar → Security → Access and data control → API controls

  3. Under "Domain-wide delegation", click Manage domain-wide delegation

  4. Add new

  5. Client ID: paste the unique id from Step 4

  6. OAuth scopes (comma-separated, exact match — copy this verbatim):

    https://www.googleapis.com/auth/gmail.send,
    https://www.googleapis.com/auth/gmail.readonly,
    https://www.googleapis.com/auth/gmail.modify,
    https://www.googleapis.com/auth/calendar.events,
    https://www.googleapis.com/auth/calendar.readonly
    

    What each scope buys you:

    • gmail.send — send mail as the delegated user
    • gmail.readonly — poll the inbox for incoming mail
    • gmail.modify — mark messages as read, move to trash (omit if you only want send)
    • calendar.events — create / update / delete events with Meet links
    • calendar.readonly — list upcoming events for context blocks
  7. Authorize

Apple Workspace propagates the change in 5-15 minutes. Until it propagates, the API calls return unauthorized_client.

Step 7 — Wire it into the tenant profile

In config/profiles/acme/profile.ts:

email: {
  provider: 'gmail',
  config: {
    serviceAccountJsonPath: 'google-sa.json',   // resolved relative to data/acme/
    delegatedUser: 'sender@yourcompany.com',    // the mailbox to impersonate
    // scopes defaults to gmail.send + gmail.readonly. If you authorized
    // gmail.modify in Step 6, add it here too. If you did not authorize
    // a scope here that you also did not authorize in Workspace, the
    // ENTIRE token request fails (Google rejects all scopes if any
    // scope is unauthorized) — keep both lists in sync.
    scopes: [
      'https://www.googleapis.com/auth/gmail.send',
      'https://www.googleapis.com/auth/gmail.readonly',
      'https://www.googleapis.com/auth/gmail.modify',
      'https://www.googleapis.com/auth/calendar.events',
      'https://www.googleapis.com/auth/calendar.readonly',
    ],
    inboundPollSeconds: 60,
  },
  allowedFromDomains: ['yourcompany.com'],
},
brand: {
  name: 'Acme',
  fromName: 'Sales Team',
  fromEmail: 'sender@yourcompany.com',          // must match delegatedUser
  trackingDomain: 'yourcompany.com',
},

brand.fromEmail must equal delegatedUser for Gmail. Gmail will not let you send-as an unrelated address without extra setup (you'd have to add the address as a Send-As alias in the user's Gmail settings, which is fragile).

allowedFromDomains is the BootGuard fingerprint that refuses to start the server if brand.fromEmail's domain is anywhere else. Keeps a misconfigured profile from sending mail under the wrong identity.

Step 8 — Test the email path

CRM_PROFILE=acme pnpm dev

Watch the boot log. You should see:

[boot]   [OK] FROM email fingerprint: domain=yourcompany.com
[boot] email provider id=gmail

Then:

curl -X POST http://localhost:3940/agent/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"Use the Bash tool to POST a draft email via /api/emails/drafts addressed to test@example.com with subject hello and body world."}'

The agent calls Bash → curl → drafts endpoint. Check that a row appears in your email_drafts table. The agent's prompt should already prevent it from sending directly (drafts only).

Step 9 — Test the Meet path (optional)

The legacy assistant looks for <a href="about:pending-meet?start=ISO&dur=N"> markers in the email body and materializes them into real Google Meet events on send. To test that path manually:

curl -X POST http://localhost:3940/calendar/test \
  -H 'Content-Type: application/json' \
  -d '{
    "summary": "Test meeting",
    "startISO": "2026-04-20T14:00:00+02:00",
    "durationMinutes": 30,
    "attendees": ["test@example.com"]
  }'

The server creates an event in the delegated user's calendar with a Meet link attached. Check the user's Google Calendar to verify.

Common Google Cloud errors and what they mean

ErrorCauseFix
unauthorized_clientThe scopes you're requesting in profile.email.config.scopes don't all match what's authorized in Workspace admin → API controls → Domain-wide delegationEither narrow the requested scopes, or widen the Workspace admin authorization. Both lists must agree.
Insufficient PermissionThe user being impersonated doesn't have permission for the API in question (e.g. trying to read a shared calendar they don't have access to)Check the user's actual Workspace permissions. The service account inherits the user's permissions, not the admin's.
Service account key file not foundserviceAccountJsonPath is wrong, or the file isn't readable by the server processVerify with ls -la data/acme/google-sa.json. The path is relative to the tenant data dir unless absolute.
Domain-wide delegation is not enabledYou enabled the API but didn't add the client id in Workspace adminStep 6. The propagation can take up to 15 minutes after you add it.
Quota exceededYou hit a Gmail or Calendar daily quotaWait 24h or request a quota increase in the Cloud Console. The defaults are generous (1B Gmail units/day) so this only happens under heavy abuse.

Recap of files and where they go

data/<tenant>/
  google-sa.json              # service account JSON, NEVER commit, chmod 600

config/profiles/<tenant>/profile.ts
  email.provider = 'gmail'
  email.config = { serviceAccountJsonPath, delegatedUser, scopes, inboundPollSeconds }
  email.allowedFromDomains = [ 'yourcompany.com' ]
  brand.fromEmail = 'sender@yourcompany.com'

Workspace admin → Security → API controls → Domain-wide delegation:
  Client ID = <from the service account's Unique ID>
  Scopes    = <same list as in profile.ts>

Part 4 — Publishing the iOS app

What you're going to do

  1. Get an Apple Developer account ($99/year).
  2. Create an APNs key so the agent can send push notifications.
  3. Create a bundle identifier and the matching provisioning profile.
  4. Build the .ipa from the command line.
  5. Upload it to Diawi to get a one-tap install link you can send to your phone.
  6. Trust the developer profile on the iPhone the first time.
  7. Open the app and watch it talk to your local server.

The whole thing takes about 30 minutes the first time. After that, every redeploy is a single shell command.

Why an Apple Developer account is required

Apple does not let you install your own app on your own iPhone for more than 7 days unless your code is signed by an Apple Developer team. The free option (Xcode personal team) lets you build to a connected simulator or a connected device, but the install expires every week. For real use — installing on the phone you actually carry — you need a paid account.

The paid account also gives you:

  • APNs key (required for push notifications)
  • TestFlight (the official Apple-blessed beta channel)
  • Distribution certificates (required for Diawi / ad-hoc / TestFlight)
  • The ability to add up to 100 test devices per year
  • A team identifier (a 10-character string like ABC1234DEF) that signs everything you ship

Sign up at developer.apple.com/programs. Pick "Individual" if you're solo, "Organization" if you have a registered company. The annual fee is $99 USD or local equivalent. Approval takes a few hours to a few days.

After approval, log into developer.apple.com/account and note your team ID — it's at the top right of the Membership page. You'll need it three times over the next 30 minutes.

Step 1 — Create the bundle identifier

In developer.apple.com/account → Certificates, IDs & Profiles → Identifiers → +:

  • Type: App IDs
  • Description: "CRM Claw"
  • Bundle ID: explicit, com.yourcompany.crmclaw (use your own reverse-DNS)
  • Capabilities to enable:
    • Push Notifications
    • App Groups (only if you want the share extension and the main app to share UserDefaults — most setups want this)
    • Background Modes is configured in Xcode itself, not in the App ID

Click Continue, Register.

You also need the same bundle ID as a separate App ID for the share extension if you ship one (com.yourcompany.crmclaw.share) and for the assistant widget (com.yourcompany.crmclaw.assistantwidget). The legacy app uses all three.

Step 2 — Create the APNs key

This is the single biggest improvement Apple made to push notifications in the last decade: instead of generating a per-app certificate that expires every year, you create one auth key that signs every push for every app on your team.

In developer.apple.com/account → Keys → +:

  • Key Name: "CRM Claw APNs"
  • Tick Apple Push Notifications service (APNs)
  • Continue → Register

Apple shows you the key id (10 characters, looks like A5R88635N4) and lets you download the .p8 file once. Save it somewhere safe — Apple does not let you re-download it. If you lose it, revoke and create a new one.

Drop the file in your tenant data dir:

mkdir -p data/acme/apns
mv ~/Downloads/AuthKey_A5R88635N4.p8 data/acme/apns/key.p8
chmod 600 data/acme/apns/key.p8

The data/ tree is gitignored, so this key never enters the repo.

Step 3 — Wire APNs into the tenant profile

Open config/profiles/acme/profile.ts and fill in the apns block:

apns: {
  keyId: 'A5R88635N4',                      // from the Keys page
  teamId: 'ABC1234DEF',                     // from your Membership page
  bundleId: 'com.yourcompany.crmclaw',      // matches your App ID
  keyPemPath: 'apns/key.p8',                // relative to data/acme/
  sandbox: false,                           // true while developing, false for prod
},

sandbox: true tells the server to send pushes through Apple's sandbox APNs gateway (api.sandbox.push.apple.com). Use it while you're testing on a device built in Debug mode. Switch to false when you build in Release configuration — the sandbox gateway rejects production-signed builds and vice versa.

Step 4 — Capabilities in the Xcode project

Open ios/CRMClawApp.xcodeproj in Xcode. Select the CRMClawApp target → Signing & Capabilities tab.

Make sure these capabilities are present (the legacy project ships with them, but verify):

  • Push Notifications
  • Background Modes with the boxes ticked for:
    • Audio, AirPlay, and Picture in Picture (so dictation keeps working when the app is backgrounded)
    • Background fetch
    • Remote notifications
    • Push to Talk (iOS 16+, used by the volume-up assistant button)
  • Push to Talk capability (separate from Background Modes; it's its own row)
  • App Groups if you ship the share extension

In the same tab, set:

  • Team: your Apple Developer team (the Xcode dropdown shows everything you have access to)
  • Bundle Identifier: com.yourcompany.crmclaw (matches Step 1)
  • Signing Certificate: Apple Development (for Debug builds) and Apple Distribution (for Release / TestFlight / Diawi). Xcode auto-generates these the first time you build.

Step 5 — Code signing and provisioning profiles

Xcode 14+ does this automatically as long as "Automatically manage signing" is on (it is by default). The first time you build, Xcode logs into Apple, creates a provisioning profile that lists every test device you added under Devices on developer.apple.com, downloads it, and signs the build with it.

If you switch to Manual signing (rare for solo devs, common for CI):

  1. developer.apple.com → Profiles → +
  2. Type: Ad Hoc (for Diawi distribution) or App Store (for TestFlight)
  3. Pick your App ID and your distribution certificate
  4. Add the devices that should be allowed to install (only ad-hoc)
  5. Download the .mobileprovision file and double-click it

For Diawi, Ad Hoc is the right choice. The provisioning profile baked into the IPA controls who can install. If a device's UDID is not in the profile, the install fails on the phone.

Step 6 — Build the IPA

The legacy ships a build script at ios/scripts/build-ipa.sh:

cd ios
bash scripts/build-ipa.sh

What it does:

  1. Bumps CFBundleVersion to a timestamp (YYYYMMDDHHmm)
  2. Runs xcodebuild archive to produce a .xcarchive
  3. Runs xcodebuild -exportArchive with ExportOptions.plist to export an .ipa
  4. Drops the result in ios/build/CRMClawApp.ipa

ExportOptions.plist controls how the IPA is signed. The shipped one uses ad-hoc:

<key>method</key>
<string>release-testing</string>
<key>teamID</key>
<string>YOUR_TEAM_ID</string>
<key>signingStyle</key>
<string>automatic</string>

Edit it to match your team ID before the first run.

Step 7 — Upload to Diawi

Diawi is a free service that hosts your IPA and gives you a short URL. The user opens the URL on their iPhone in Safari, taps Install, and iOS prompts them to install. Free tier limits: 500 MB max IPA size, 60 day link expiry, 25 installs per link. Paid tier removes limits.

The legacy ships an automation script at ios/scripts/upload-diawi.cjs:

cd ios
node scripts/upload-diawi.cjs build/CRMClawApp.ipa

What it does:

  1. Opens diawi.com via headless Playwright
  2. Drag-and-drops the IPA into the upload widget
  3. Waits for upload + processing
  4. Reads back the install URL and copies it to the clipboard

If you don't want the Playwright route, you can upload manually at diawi.com — it takes about 30 seconds. The script just removes that one click.

The combined deploy script ios/scripts/deploy.sh does both in one shot:

cd ios
bash scripts/deploy.sh

Step 8 — Install on the iPhone

  1. Open the Diawi link in Safari (not Chrome, not in-app browsers — Safari is the only one that triggers the iOS install flow)
  2. Tap Install
  3. Wait for the icon to appear on the home screen
  4. Settings → General → VPN & Device Management → Developer App → Trust "Your Team Name". Apple makes you do this once per developer team per device. Without it the app refuses to launch.
  5. Open the app

If the install button does nothing, the most common cause is that the device UDID is not in the provisioning profile. Add it under developer.apple.com → Devices and rebuild.

Step 9 — Push notifications: end-to-end test

In the iOS app, log in (the legacy login screen accepts any value the first time). The app will request notification permissions — tap Allow.

The app then registers with APNs and sends its device token to your server (the route is POST /devices in the legacy). The server stores it in the device_tokens table.

To verify, ssh into your server and run:

curl -X POST http://localhost:3940/notifications/test \
  -H 'Content-Type: application/json' \
  -d '{"deviceToken":"<the token from device_tokens>","title":"hello","body":"from your server"}'

A push should appear on the phone within seconds. If it doesn't:

  • Check the server logs for [push] or [apns] lines — APNs returns clear error codes (BadDeviceToken, Unregistered, TopicDisallowed, etc.).
  • Verify apns.sandbox matches your build configuration (Debug = sandbox, Release = production).
  • Verify apns.bundleId exactly matches the bundle id of the IPA you installed.
  • Verify the .p8 file at data/<tenant>/apns/key.p8 is readable by the server process.

Step 10 — TestFlight (alternative to Diawi)

If you want to share the app with people you don't physically control (clients, beta testers, employees), TestFlight is cleaner than Diawi:

  1. Go to App Store Connect
  2. Create a new app with the bundle ID from Step 1
  3. In your Xcode project, archive in Release with App Store Connect distribution
  4. Upload via Xcode Organizer → Distribute App
  5. Wait for processing (5-30 minutes)
  6. Add testers in App Store Connect
  7. Apple emails them a TestFlight link

TestFlight requires Apple's review the first time you submit (24-48 hours typically). After approval, subsequent builds skip review for internal testers and need only a quick review for external testers.

Diawi is faster (zero review), TestFlight is more legitimate (Apple-approved channel, no expiring profiles, no Trust dance for the user). Pick based on how many people you're sending to.

Recap of files and where they go

data/<tenant>/apns/
  key.p8                     # APNs auth key, NEVER commit, chmod 600

config/profiles/<tenant>/profile.ts
  apns: { keyId, teamId, bundleId, keyPemPath, sandbox }

ios/CRMClawApp.xcodeproj    # set Team + Bundle Identifier in Signing & Capabilities

ios/ExportOptions.plist     # set teamID before the first IPA build

ios/scripts/build-ipa.sh    # builds the IPA
ios/scripts/upload-diawi.cjs # uploads to Diawi
ios/scripts/deploy.sh       # both in one shot

What the user sees in the app after install

  • A login screen (you can wire it to whatever auth you want; the legacy uses local-only credentials stored in Keychain)
  • Five tabs: Inbox, Contacts, Organizations, Tasks, Dashboard
  • A floating mic button in the bottom right — tap to summon the assistant overlay
  • Volume Up while the app is in foreground starts a Push-to-Talk recording
  • The assistant streams responses, runs tools, and renders structured cards (email previews, batch progress, filter results)

Everything you see in the app comes from the legacy iOS source, copied verbatim into ios/CRMClawApp/. Branding strings come from Info.plist via ProjectProfile.shared — you change them by editing the plist or by overriding per-scheme via xcconfig.


Part 5 — The agent: context and tool results

What's a context block

When you talk to the agent from the iOS app, each chat turn carries two kinds of "context":

  1. What the user just typed (the message)
  2. What the user is currently looking at (the UI state at the moment they hit send)

The second one is the context block. It's an AssistantContext object the iOS client builds and ships with every POST /agent/chat call. The agent's prompt loader merges it into the system prompt so the model knows what "this contact" or "this campaign" or "the current filter" refers to without having to ask.

The shape of AssistantContext

From the legacy iOS client (Models/AssistantModels.swift):

struct AssistantContext: Codable {
    let currentTab: Int                          // 0=Inbox, 1=Contacts, 2=Orgs, 3=Tasks
    let currentOrganizationId: String?
    let currentOrganizationName: String?
    let currentContactId: String?
    let currentContactName: String?
    let currentListId: String?
    let currentListName: String?
    let currentCampaignId: String?
    let currentCampaignName: String?
    let currentContactsAIFilterPrompt: String?  // free-form filter the user typed
    let currentContactsAIFilterSql: String?     // SQL the model generated for that filter
    let currentContactsFilterSummary: String?
    let currentContactsFilterTotal: Int?
    let currentOrgsAIFilterPrompt: String?
    let currentOrgsAIFilterSql: String?
    let currentOrgsFilterSummary: String?
    let currentOrgsFilterTotal: Int?
}

Every field is optional except currentTab. The iOS app fills in whatever's relevant for the screen the user is on:

  • On the Inbox tab, none of the org/contact/list fields are set (unless the user has a thread open).
  • On a contact detail page, currentContactId + currentContactName are set.
  • On a campaign detail page, currentCampaignId + currentCampaignName are set.
  • On the contacts list with an active filter, the four currentContactsAI* and currentContactsFilter* fields are set so the agent knows what subset is on screen.

How the agent uses context blocks

The prompt loader (mcp-server/src/agent/prompt.ts) appends a "current UI context" section to the system prompt at every chat turn. The legacy version went further and injected tool-call recipes parameterized by the current id (e.g. when the user is viewing a list, the prompt gets the bulk-action curls pre-filled with that list's id, so the agent doesn't have to look it up).

The model uses the context to disambiguate pronouns: "send him a follow-up" → it looks at currentContactName and knows who "him" is; "delete this campaign" → currentCampaignId; "filter by EHPAD" → it patches the existing filter in currentOrgsAIFilterSql.

Tool-result blocks streamed back to the iOS app

The chat is one-way for the user (text in) but multi-block for the model (text + tool calls + structured cards out). The agent loop yields events of these types over SSE:

Event typeWhat it isHow the iOS app renders it
chunkA piece of text from the modelAppended to the current assistant bubble
replaceReplace the current bubble's text wholesaleUsed for error messages and the cancellation marker
thinkingA piece of the model's interleaved thinkingShown in a smaller, dimmer style above the answer (or hidden depending on user setting)
tool_startThe agent has begun executing toolsShows a "..." indicator
tool_resultOne tool finished, here's its resultThe legacy app peeks at the result for known shapes (curl JSON, batch progress) and renders an inline card
ui_filterA {...} filter object the model emitted in a ```filter blockThe app applies the filter to the current screen (changes the SQL, refetches the list) without showing it to the user as text
email_previewA structured email preview card from the EmailPreview toolRenders a full email envelope (From / To / Subject / Body) inside the chat with a "Save as draft" button
full_enrich_progressLive progress for a long-running enrichment batchRenders a progress card with N out of M, with retry button
batch_startA batch job startedPersisted in the conversation so the user can scroll back
action_doneThe agent mutated CRM data and wants the app to refreshThe app closes the chat overlay and reloads the current screen
doneEnd of streamCloses the SSE connection cleanly
errorSomething went wrongReplaces the bubble with a typed error message

How the assistant message persistence works

Every event that's user-visible (chunks, tool results, ui_filter, email_preview, full_enrich_progress) is also stored in the assistant_messages table with a message_type column. That way:

  • The conversation history loads correctly on app restart
  • The agent's pre-truncation memory flush can re-read its own past tool results when extracting durable facts
  • A future analytics layer can count how often each tool was invoked

The message_type column is the single source of truth for "what kind of block is this". Adding a new block type means adding a new value, teaching the iOS app to render it, and teaching the agent to emit it.

How to add a new block type

Five files to touch:

  1. mcp-server/src/agent/types.ts — add the new event variant to the AgentEvent union.
  2. mcp-server/src/agent/loop.ts (or wherever the agent calls tools) — emit the event when the relevant tool runs.
  3. The HTTP layer (mcp-server/src/http/server.ts) — already forwards every event as data: JSON\n\n, so no change unless you want server-side filtering.
  4. assistant_messages.message_type enum / check constraint in your database — add the new value via a migration.
  5. The iOS app — add a SwiftUI view that decodes the event payload and renders a card. Wire it into the chat stream's switch statement.

The iOS app's chat stream parser is in Services/SSEStreamParser.swift (legacy) — it decodes each data: line into a typed event and dispatches to the correct view. New event = new case in the switch + a new view.


Part 6 — MCP servers

What MCP is

MCP stands for Model Context Protocol. It's a specification published by Anthropic that defines how a model (or any other client) can talk to a "tool server" over stdio or HTTP. The protocol covers:

  • Tools — functions the model can call
  • Resources — files / endpoints / structured data the model can read
  • Prompts — reusable prompt templates the user can invoke

A server that implements MCP exposes its capabilities through these three primitives. A client (Claude Code Desktop, Claude Desktop, Cline, Continue, etc.) connects to the server and gets access to whatever the server exposes.

CRM Claw can run as an MCP server. That's the second interface (the first being the HTTP REST API the iOS app talks to). The same backend, two transports.

Why CRM Claw exposes both HTTP and MCP

Because the same data is useful to two different clients:

  • The iOS app wants HTTP + SSE. It needs to scroll lists of contacts, render rich email cards, stream chat tokens, and fall back gracefully when the network blips. HTTP REST + SSE is the right shape for that.
  • Claude Code Desktop wants MCP. The user opens Claude Code on their laptop, types "find me the most recent emails from contacts at Acme", and Claude calls into the CRM via MCP tools. The user never opens the CRM app — Claude does it for them.

Both clients hit the same service layer (organizations, contacts, emails, etc.), so the operations are identical. Only the transport differs.

This is the dual-interface pattern the legacy upstream CRM pioneered: one Node.js process exposes HTTP routes via Hono and MCP tools via @modelcontextprotocol/sdk simultaneously. The MCP transport speaks stdio (for desktop Claude) and the HTTP transport binds to a port (for the iOS app).

How to connect Claude Desktop to your CRM Claw

Open ~/Library/Application Support/Claude/claude_desktop_config.json (macOS — Linux/Windows have similar paths). Add an entry under mcpServers:

{
  "mcpServers": {
    "crmclaw-acme": {
      "command": "pnpm",
      "args": [
        "--filter",
        "@crmclaw/mcp-server",
        "exec",
        "tsx",
        "src/index.ts"
      ],
      "cwd": "/Users/you/Documents/CRMclaw/crmclaw",
      "env": {
        "CRM_PROFILE": "acme",
        "CRM_API_PORT": "0",
        "MCP_STDIO": "1"
      }
    }
  }
}

MCP_STDIO=1 tells the server to expose its tools over stdio (for Claude Desktop) instead of (or in addition to) HTTP. CRM_API_PORT=0 skips the HTTP listener — useful when the only client is desktop Claude.

Restart Claude Desktop. The next chat session will see your CRM tools listed.

What tools the legacy MCP server exposed (reference)

The legacy upstream CRM shipped 45 MCP tools across 9 families. They give a sense of what a fully wired CRM looks like over MCP:

FamilySample tools
orgslist, get, create, update, delete, search, bulk_create, find_duplicates
contactslist, get, create, update, delete, find_by_org
leadslist, get, create, update, pipeline_stats
activitieslist, create
listslist, get, create, delete, preview_filter, add_from_filter, add_contacts
campaignslist, get, create, update, preview, send, test_email, stats
enrichmentstats, pending
crawlercrawl
dashboardoverview

CRM Claw ships zero business tools out of the box because the core is intentionally domain-agnostic. As you build out your tenant, you add tools to the MCP server (one file per family, registered in mcp-server/src/mcp/tools.ts once you wire it up). The same handlers can be invoked from HTTP routes too — keep the business logic in mcp-server/src/services/ and the MCP tool definitions are thin wrappers.

Adding a new MCP tool

The basic shape (you'd add this to mcp-server/src/mcp/tools.ts):

import { z } from 'zod';

export const TOOLS = [
  {
    name: 'orgs__list',
    description: 'List organizations with optional filters.',
    inputSchema: {
      type: 'object',
      properties: {
        search: { type: 'string', description: 'Substring search on name' },
        limit: { type: 'number', description: 'Max results (default 20)' },
      },
    },
  },
  // ... more tools
];

And the handler (mcp-server/src/mcp/handlers.ts):

import { listOrganizations } from '../services/organizations.service.js';

export async function handleTool(name: string, args: Record<string, unknown>) {
  switch (name) {
    case 'orgs__list': {
      const result = await listOrganizations({
        search: args.search as string | undefined,
        limit: (args.limit as number | undefined) ?? 20,
      });
      return { content: [{ type: 'text', text: JSON.stringify(result) }] };
    }
    // ... more cases
    default:
      throw new Error(`unknown tool: ${name}`);
  }
}

Wire both into the MCP server initialization in mcp-server/src/index.ts. CRM Claw's current index.ts doesn't have the MCP path wired in yet (it ships HTTP only); see Part 12's roadmap.

MCP vs HTTP — which one to use when

SituationPick
You're building a mobile or web clientHTTP
You want Claude Desktop to act on your CRM dataMCP
You want a CLI that talks to the CRMHTTP (curl) or MCP via stdio — both work, HTTP is simpler
You want to test a tool quicklyHTTP — you can curl it directly
You want the model to discover tools at runtime without being told what's availableMCP — the protocol's tools/list endpoint enumerates them

In the long run, the MCP path is the more interesting one for a multi-tenant CRM: it lets each user run Claude Desktop locally and point it at their tenant, getting full agent capabilities without the iOS app being involved at all. The iOS app is for "I'm on the go", MCP is for "I'm at my desk".


Part 7 — Iterating with Claude

The vibe coding workflow that works on this repo

  1. Open the repo in Claude Code. From ~/Documents/CRMclaw/crmclaw, run claude. Claude reads the CLAUDE.md (you might want to write one — see below) and any open files.
  2. Tell Claude what you want, with context. "Add a new HTTP route at /notes that returns the latest 50 notes from the database. Look at how /health is structured — same shape." The reference to an existing route helps Claude pick the right file and the right conventions.
  3. Read the diff before accepting. Claude shows the exact lines that will change. Read them. If anything looks off, ask Claude to explain it.
  4. Run typecheck and scrub-check. Always. pnpm typecheck && ./scripts/scrub-check.sh. They catch 90% of vibe-coding mistakes for free.
  5. Restart the server, test the change. pnpm dev, hit the new route, see if it works.
  6. Commit. Small commits, descriptive messages, Co-Authored-By: Claude trailer.

The whole loop is 5-10 minutes for a small change. The slower steps (running tests, reading the diff carefully, restarting the server) are the ones that catch problems early.

Prompts that work well in this codebase

These are the shapes I've seen produce good output:

"Add a route X that does Y. Look at route Z for the conventions." Tells Claude both what to add and where to look for the existing pattern. Concrete reference > abstract description.

"Add a tool X to the agent. The handler should call /api/Y. Look at how Bash is implemented for the registration pattern." Same shape, applied to the agent layer.

"Refactor X to be per-tenant instead of global. Look at how MemoryStore was refactored from openclaw's singleton." Points at a known-good example of the same refactor.

"Add a new email provider for X. Use the _template directory as the starting point and follow the gmail provider for the dependency-injection pattern." Combines the scaffold + the example.

"Find all the places where Y is hardcoded and propose a way to make it tenant-configurable." Investigation prompt — Claude grep-greps and reports back. Good for big refactors before you write a single line.

Prompts that produce bad output

"Make it better." Too vague. Claude will randomly improve random things and you'll have a 500-line diff to review.

"Add tests." Claude will write tests that pass against the current behavior, which is rarely what you want. Ask for tests of specific cases instead.

"Rewrite this file." Almost always wrong. Specific edits beat full rewrites. If the file is genuinely bad, delete it and ask for a fresh implementation with explicit constraints.

"Add a feature like the one in [other repo]." Claude can't actually look at the other repo. Either paste the relevant code or describe the feature in plain language.

Writing a CLAUDE.md for your fork

A CLAUDE.md at the repo root is auto-loaded by Claude Code at the start of every session. It's where you tell Claude about your specific project conventions. A good one for this codebase:

# CRM Claw — agent guide

This repo is a self-hosted multi-tenant CRM. Read docs/HANDBOOK.md before
making changes — it's the source of truth for architecture decisions.

## Conventions

- One process per tenant. No global singletons. Every service that needs
  per-tenant state takes a Profile in its constructor.
- The forbidden strings CI (./scripts/scrub-check.sh) blocks any commit
  that mentions specific tenant brands by name. Keep tenant-specific
  values in config/profiles/<tenant>/ which is gitignored.
- Always run `pnpm typecheck && ./scripts/scrub-check.sh` before commit.
- Commits use Co-Authored-By: Claude trailer (per the vibe-coded
  manifesto).

## Where things live

See docs/HANDBOOK.md Part 1 for the full file tree annotated.

## Style

- TypeScript strict, ESM imports with .js extensions, prefer types over
  interfaces unless declaration merging is needed.
- Hono routes are pure functions taking the ServerContext.
- New email providers go under mcp-server/src/email/providers/<id>/
  and self-register at import time.

Claude reads this on every session start. It saves you from re-explaining the same things every time.

How to ask Claude to read code before writing code

A common failure mode: Claude writes code based on what it remembers from training, without reading what's actually in your repo. The fix is explicit:

"Before writing the new route, read mcp-server/src/http/server.ts and tell me what conventions the existing routes use (zod validation, error handling, response shape). Then write the new route following the same conventions."

This forces Claude to grep + read first, propose conventions, then write. Slower but more reliable.

Iterating without breaking things

Three habits that pay off:

  1. One change per commit. Every commit should answer one question. "Add /notes route" or "Add CreateNote tool" — not both. If Claude wants to do both, ask it to split.
  2. Run the doctor after every commit that touches a service. ./scripts/doctor.sh walks every tenant and runs BootGuard cold. If a service refactor breaks any tenant, doctor catches it before the user does.
  3. Keep a rollback handy. git tag rollback-2026-04-15-15h before a risky change. If it goes sideways, git reset --hard rollback-2026-04-15-15h and you're back to a known good state.

Part 8 — Cookbook: how to do X

Recipes for the things you'll most often need to do. Each one is self-contained — you can jump to the recipe you need without reading the others.

Add a new HTTP route

Open mcp-server/src/http/server.ts. Find a route that's similar to what you want (e.g. /health for a GET that returns JSON, /agent/chat for a POST that streams).

// add this inside createServer(), next to the other routes:
app.get('/notes', async (c) => {
  const tenantId = ctx.profile.id;
  // Query whatever you want here. If you need DB access, ctx.pgClient is
  // available when profile.supabase.driver === 'postgres'.
  if (!ctx.pgClient) return c.json({ error: 'postgres only' }, 400);
  const rows = await ctx.pgClient`SELECT id, body, created_at FROM notes WHERE tenant_id = ${tenantId} ORDER BY created_at DESC LIMIT 50`;
  return c.json({ tenant: tenantId, notes: rows });
});

Restart the server. Hit curl http://localhost:3940/notes. Done.

If you want zod body validation for a POST:

const NoteCreateSchema = z.object({
  body: z.string().min(1),
});

app.post('/notes', async (c) => {
  const body = NoteCreateSchema.parse(await c.req.json());
  // ...
  return c.json({ ok: true });
});

The z.parse throws on invalid input; Hono catches the throw and returns a 500. You can install a Hono error handler if you want a 400 with a typed error message instead.

Add a new tool to the agent

Open mcp-server/src/agent/tools/. Create a new file notes.ts:

import type { ToolDef } from '../types.js';

export function buildCreateNoteTool(): ToolDef {
  return {
    name: 'CreateNote',
    description: 'Create a freeform note attached to the current tenant.',
    parameters: {
      type: 'object',
      properties: {
        body: { type: 'string', description: 'Note content (markdown allowed)' },
      },
      required: ['body'],
    },
    handler: async (input) => {
      const body = String(input.body ?? '').trim();
      if (!body) return 'ERROR: empty body';
      // Call your own /notes route via fetch — keeps the tool stateless
      const res = await fetch('http://localhost:3940/notes', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ body }),
      });
      if (!res.ok) return `ERROR: ${res.status} ${await res.text()}`;
      return await res.text();
    },
  };
}

Register it in mcp-server/src/agent/tools/index.ts:

import { buildCreateNoteTool } from './notes.js';

export function buildCoreToolset(opts: CoreToolsetOptions): ToolDef[] {
  return [buildBashTool(opts.bash), buildCreateNoteTool()];
}

Restart the server. Now ask the agent: "create a note saying 'meeting with Acme on Tuesday'". The agent picks the new tool, calls it, you get a note.

If you want the tool only for some tenants, filter by profile.features in the factory.

Modify the system prompt

Edit config/profiles/<tenant>/prompt.md. The file is loaded fresh on every chat turn, so editing it requires NO server restart. Save the file and the next message uses the new prompt.

If you want a global change for every tenant, edit config/profiles/example/prompt.md and copy the diff into your private profiles. There's no shared "base prompt" by design — two tenants can have totally different prompts.

If you want the prompt to vary by what the user is looking at, that's the job of the context block (Part 5). The runtime appends a "Current UI context" section automatically based on the AssistantContext the iOS app sends. You can extend the context by adding fields to AssistantContext and teaching buildSystemPrompt to render them.

Add a new chunk type to memory

In mcp-server/src/memory/types.ts, add the constant to CORE_CHUNK_KINDS:

export const CORE_CHUNK_KINDS = {
  EVENT_SUMMARY: 'event_summary',
  // ...
  MEETING_NOTES: 'meeting_notes',  // new
} as const;

If the new kind should NOT decay over time (e.g. it represents a permanent fact), add it to DEFAULT_DURABLE_KINDS:

export const DEFAULT_DURABLE_KINDS: ReadonlySet<ChunkSourceKind> = new Set([
  CORE_CHUNK_KINDS.MEMORY_LONG,
  CORE_CHUNK_KINDS.CONTACT_BRIEF,
  CORE_CHUNK_KINDS.MEETING_NOTES,  // new
  // ...
]);

That's it. Upsert chunks with sourceKind: 'meeting_notes' and they're searchable. The schema is open — ChunkSourceKind is string, so you don't need to register anything.

To upsert from the agent, expose a tool that calls the memory store's upsertChunk method:

{
  name: 'RememberMeeting',
  description: 'Save a meeting summary to long-term memory.',
  parameters: {
    type: 'object',
    properties: {
      contactId: { type: 'string', description: 'Who the meeting was with' },
      summary: { type: 'string', description: 'Markdown summary' },
    },
    required: ['contactId', 'summary'],
  },
  handler: async (input) => {
    const text = String(input.summary);
    const embedding = await embedder.embed(text);
    memoryStore.upsertChunk({
      id: `meeting:${Date.now()}`,
      sourceKind: 'meeting_notes',
      sourceId: String(input.contactId),
      text,
      embedding,
      importance: 0.85,
    });
    return 'saved';
  },
}

Add a new email provider

Use the scaffold:

./scripts/new-provider.sh smtp

This copies mcp-server/src/email/providers/_template/ to mcp-server/src/email/providers/smtp/. Open smtp/index.ts and implement the EmailProvider interface:

import nodemailer from 'nodemailer';
import { z } from 'zod';
import type { EmailCapabilities } from '../../capabilities.js';
import type { EmailProvider, ProviderContext, SendNewParams, SentMessage } from '../../provider.js';
import { registerEmailProvider } from '../../registry.js';

const SmtpConfigSchema = z.object({
  host: z.string(),
  port: z.number().default(587),
  user: z.string(),
  pass: z.string(),
  secure: z.boolean().default(false),
});

class SmtpEmailProvider implements EmailProvider {
  readonly id = 'smtp';
  readonly capabilities: EmailCapabilities = {
    inbound: 'none',
    threading: false,
    labels: false,
    drafts: 'synthetic',
    meet: false,
    tracking: 'none',
    attachments: true,
    bounces: 'scan',
  };

  private transport;
  constructor(private readonly config: z.infer<typeof SmtpConfigSchema>, private readonly ctx: ProviderContext) {
    this.transport = nodemailer.createTransport({
      host: config.host,
      port: config.port,
      secure: config.secure,
      auth: { user: config.user, pass: config.pass },
    });
  }

  validateConfig(): void {
    SmtpConfigSchema.parse(this.config);
  }

  async sendNew(params: SendNewParams): Promise<SentMessage> {
    const info = await this.transport.sendMail({
      from: `${this.ctx.brand.fromName} <${this.ctx.brand.fromEmail}>`,
      to: params.to,
      subject: params.subject,
      html: params.bodyHtml,
      text: params.bodyText,
      replyTo: params.replyTo,
    });
    return {
      providerMessageId: info.messageId,
      threadId: info.messageId,
      sentAt: new Date().toISOString(),
    };
  }

  async replyToThread(params: any): Promise<SentMessage> { /* implement */ throw new Error('TODO'); }
  async markRead(): Promise<void> { /* SMTP is send-only */ }
  async moveToTrash(): Promise<void> { /* SMTP is send-only */ }
}

registerEmailProvider('smtp', (config, ctx) => new SmtpEmailProvider(SmtpConfigSchema.parse(config), ctx));

Add the import to mcp-server/src/email/providers/index.ts:

import './smtp/index.js';

Run pnpm install to add nodemailer if you need it. Restart the server. Set your tenant profile's email.provider = 'smtp' with the matching config. Done.

Import contacts from CSV

There's no built-in CSV importer because it's domain-specific. The simplest way:

// mcp-server/src/scripts/import-csv.ts
import { parse } from 'csv-parse/sync';
import { readFileSync } from 'node:fs';
import { loadProfile } from '../config/loader.js';
import { createDbClient } from '../db/connection.js';

const profile = await loadProfile(process.env.CRM_PROFILE);
const sql = createDbClient(profile);
const rows = parse(readFileSync(process.argv[2], 'utf8'), { columns: true });

for (const row of rows) {
  await sql`INSERT INTO contacts (first_name, last_name, email, phone)
            VALUES (${row.first_name}, ${row.last_name}, ${row.email}, ${row.phone})
            ON CONFLICT (email) DO NOTHING`;
}

console.log(`imported ${rows.length} rows`);
await sql.end();

Run with:

CRM_PROFILE=acme pnpm --filter @crmclaw/mcp-server exec tsx src/scripts/import-csv.ts ./contacts.csv

You'll need to pnpm add csv-parse. The script reuses the BootGuard-validated profile + db client, so it can't accidentally write to the wrong tenant.

Connect a real iPhone to your local server over Tailscale

The iOS app hardcodes a single API URL via ProjectProfile.shared.apiBaseURL (Info.plist key CRMCLAW_API_BASE_URL). You want it to point at your laptop's IP from anywhere in the world. Tailscale is the cleanest option.

  1. Install Tailscale on your laptop and your iPhone. Both join your private tailnet.

  2. From the laptop, find your tailnet IP: tailscale ip -4. Looks like 100.64.1.2.

  3. Edit ios/CRMClawApp/Resources/Info.plist:

    <key>CRMCLAW_API_BASE_URL</key>
    <string>http://100.64.1.2:3940</string>
    
  4. Rebuild the IPA, redeploy via Diawi.

  5. The phone now talks to your laptop from anywhere — Tailscale handles the routing and the encryption.

For real production, replace Tailscale with a public TLS endpoint (Caddy + Let's Encrypt, or Cloudflare Tunnel).

Run the server in the background as a launchd service (macOS)

Create ~/Library/LaunchAgents/com.crmclaw.acme.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.crmclaw.acme</string>
  <key>WorkingDirectory</key>
  <string>/Users/you/Documents/CRMclaw/crmclaw</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>CRM_PROFILE</key>
    <string>acme</string>
    <key>CRM_API_PORT</key>
    <string>3940</string>
    <key>ACME_PG_URL</key>
    <string>postgres://...</string>
  </dict>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/pnpm</string>
    <string>--filter</string>
    <string>@crmclaw/mcp-server</string>
    <string>start</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardOutPath</key>
  <string>/tmp/crmclaw-acme.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/crmclaw-acme.log</string>
</dict>
</plist>

Load it:

launchctl load ~/Library/LaunchAgents/com.crmclaw.acme.plist

The server now runs in the background, restarts on crash, and writes to /tmp/crmclaw-acme.log. To stop:

launchctl unload ~/Library/LaunchAgents/com.crmclaw.acme.plist

Inspect a tenant's memory file

The memory file is plain SQLite. Open it with the sqlite3 CLI:

sqlite3 data/acme/memory.sqlite

sqlite> .tables
chunks            chunks_fts        chunks_fts_data   chunks_rowid
chunks_vec        embedding_cache   files             meta              tenant

sqlite> SELECT id, source_kind, importance, substr(text, 1, 60) FROM chunks ORDER BY created_at DESC LIMIT 10;

sqlite> SELECT source_kind, COUNT(*) FROM chunks GROUP BY source_kind;

sqlite> SELECT key, value FROM meta;

sqlite> .quit

The chunks_vec table is the sqlite-vec virtual table — you can't SELECT * from it because it's binary blobs, but you can do similarity search:

SELECT cr.chunk_id, vec.distance
FROM chunks_vec vec
JOIN chunks_rowid cr ON cr.rowid = vec.rowid
WHERE vec.embedding MATCH ? AND k = 5
ORDER BY distance;

(You'd need to bind the ? to a Float32Array buffer to actually run that query; the server's searchHybrid does it for you.)


Part 9 — Debugging and common errors

Where the logs go

By default everything writes to stdout/stderr. When you run pnpm dev you see them inline. When you run via launchd / systemd, they go to whatever StandardOutPath / StandardError you configured.

Useful log prefixes to grep for:

PrefixWhat it tells you
[boot]Server startup: profile loaded, BootGuard check results, services opened
[migrate]Postgres migration runner: which file is being applied, in how long
[email:gmail]Gmail provider: poll cycles, send results, auth errors
[email:resend]Resend provider: webhook events received, send results
[email:noop]Noop provider: every send/draft/etc. logged for inspection
[memory]Memory store: FTS query failures, embedding warnings
[memory:bge-m3]bge-m3 embedder: model download progress, model load times, GPU detection
[http]HTTP server: bind address
[Assistant] (legacy)Agent loop iterations + token counts

How to read a BootGuard failure

The error message is structured. Example:

BootGuardError: BootGuard strict mode failed:
  - DB fingerprint: postgres database at postgres://...:5433/postgres belongs to tenant "tenant-a", but CRM_PROFILE=tenant-imposter

Read it like this:

  • Which check failed: "DB fingerprint" (the first of the four)
  • What was expected: tenant tenant-imposter (because that's what CRM_PROFILE is)
  • What was found: tenant tenant-a (the row already in crmclaw_tenant)

The fix is one of:

  • Set CRM_PROFILE to the right value (tenant-a)
  • Or change the database url to a different database
  • Or wipe the existing crmclaw_tenant row (only if you're sure no other process is using that database — drop and recreate is safer)

The other three checks fail with similar structure:

- Supabase host fingerprint: expected localhost:5455, got 127.0.0.1:5433
- Memory file fingerprint: file at /path/memory.sqlite belongs to tenant "tenant-b", but CRM_PROFILE=tenant-a
- FROM email fingerprint: domain "evil.com" not in allowedFromDomains [yourcompany.com]

In every case the fix is the same: align the field that's wrong (env, profile, file path, brand) with the others.

How to bypass BootGuard temporarily

For first-time setup or recovery work, set:

CRM_BOOT_GUARD=skip CRM_PROFILE=acme pnpm dev

The server prints a big warning at boot and runs without any of the four checks. Use this only for first runs (when none of the fingerprint tables exist yet) and for recovery. Never in production.

How to debug a tool call that fails

The agent's tool loop yields tool_result events as plain text. If a tool returns an error message, the model sees it and either retries or tells the user what went wrong.

To see exactly what's happening:

  1. Run the server in the foreground (pnpm dev).
  2. Hit POST /agent/chat with the message that triggers the failing tool.
  3. Watch stdout. The agent prints [Assistant] lines for each iteration; the tool handler logs whatever it logs.
  4. If the tool is Bash, the curl command is in the agent's emitted toolCall.arguments.command — you can copy-paste it into a separate terminal and run it directly to see the raw output.

If you want even more detail, add a temporary console.log in mcp-server/src/agent/loop.ts right where you handle each tool:

for (const tc of toolCalls) {
  console.log(`[debug] tool=${tc.name} args=${JSON.stringify(tc.arguments)}`);
  // ...
  console.log(`[debug] result=${result.slice(0, 500)}`);
}

Strip them when done.

How to see the exact prompt pi-ai is sending to Claude

The agent loop builds the system prompt in agent/prompt.ts and passes the full message list to streamAnthropic. To see what that looks like, add a one-line log right before the streamAnthropic call:

console.log('[debug] system prompt length:', systemPrompt.length);
console.log('[debug] system prompt (first 500):', systemPrompt.slice(0, 500));
console.log('[debug] message count:', messages.length);
console.log('[debug] last message:', JSON.stringify(messages[messages.length - 1]).slice(0, 500));

If you want the literal HTTP request that pi-ai sends to api.anthropic.com, set NODE_DEBUG=http and grep for api.anthropic.com lines. Or use a proxy like mitmproxy to MITM your own outbound traffic.

How to know if your Claude OAuth token is expired

Quick test:

node -e "
const fs = require('fs');
const path = require('os').homedir() + '/.claude/.credentials.json';
const raw = JSON.parse(fs.readFileSync(path, 'utf8'));
const exp = raw.claudeAiOauth?.expiresAt;
console.log('expires at:', new Date(exp).toISOString());
console.log('now:        ', new Date().toISOString());
console.log('expired:    ', Date.now() > exp);
"

Claude Code refreshes the token automatically every few hours when you have it open. If the token is expired and you haven't opened CC recently, just open the CC app once — it refreshes on launch. The loadOAuthToken() function in agent/oauth.ts falls back to the macOS Keychain if the file is stale, which usually saves you.

Common error messages

MessageWhat's wrongFix
Cannot find module '@crmclaw/mcp-server'You ran a command from outside the repo rootcd to the repo root
EADDRINUSE :::3940Another process is on port 3940 (often a leftover server you forgot to kill)lsof -ti:3940 | xargs kill
better-sqlite3 build failed during pnpm installMissing C++ toolchainmacOS: xcode-select --install. Linux: apt install build-essential
Migration X failed: relation Y already existsA previous migration was applied without recording in crmclaw_migrationsManually insert the row: INSERT INTO crmclaw_migrations (filename, checksum) VALUES ('X.sql', '<sha256 of file>'). Or wipe the table and rerun if safe.
unauthorized_client from GoogleWorkspace admin scope authorization doesn't match your profile.email.config.scopesOpen the Workspace admin console and align both lists (Part 3)
Resend send failed: Domain is not verifiedDNS records for SPF/DKIM aren't published yetCheck Resend dashboard, finish DNS verification
Agent answers but no tool was calledEither the model decided no tool was needed, or the prompt didn't make tool use clear enoughBe more explicit in the user message ("use the Bash tool to...")
messages.X.content.0.image.source.base64.media_type: Field requiredThe agent loop is pushing tool results in the wrong shape (Anthropic raw vs pi-ai native)Verify your loop pushes role: "toolResult" messages with content: [{type: 'text', text: result}], not Anthropic-style tool_result blocks inside a user message
streamAnthropic failed: 401OAuth token expiredOpen Claude Code once to refresh
streamAnthropic failed: 429Rate limited by Anthropic — your CC subscription is throttled or your account hit a daily capBack off, or upgrade your CC plan

Part 10 — Security and secrets

What's secret and what isn't

File / locationSecret?Why
config/profiles/example/NOPublic template, no real values
config/profiles/<tenant>/ (any non-example)YESHolds API keys, FROM emails, prompt contents, brand info
data/<tenant>/google-sa.jsonYESGoogle Cloud service account JSON
data/<tenant>/apns/key.p8YESApple Push Notification auth key
data/<tenant>/memory.sqliteYESHolds chat history + extracted facts about your business
data/<tenant>/crm.sqlite (sqlite-driver tenants)YESThe full CRM database
~/.claude/.credentials.jsonYESClaude Code OAuth token (lives outside the repo)
.env, .env.<tenant>YESTenant-specific env vars (db urls, api keys)
mcp-server/src/**/*.tsNOPublic source code, no secrets
ios/**/*.swiftNOPublic source code, no secrets (the bundle id and FROM email are runtime config from Info.plist)
backups/YESContains the encrypted form of all of the above

The .gitignore is configured to block all the secrets above. The scrub-check.sh script is a second line of defense: it greps for known tenant brand names and refuses to commit if any are found in the public code.

How to audit secrets before pushing

Before any push to a new remote (especially before going public), run:

./scripts/scrub-check.sh

If it returns "OK: no forbidden tenant strings found.", the public code is clean.

If you're paranoid, also grep the entire git history:

git log --all -p -S "your_secret_string"

This searches every commit ever made for the string. If anything turns up, the secret leaked at some point in history. The fix is to rewrite history (git filter-repo) or, more simply, nuke the .git directory and re-init:

rm -rf .git
git init
git add .
git commit -m "Initial commit"
git remote add origin <new url>
git push -u origin main --force

You lose the commit history but you get a clean repo. For an OSS launch, that's usually the right tradeoff.

Rotating a leaked secret

If you accidentally pushed a secret to a public remote:

  1. Assume it's compromised the moment it's public. Don't wait — rotate immediately.
  2. Revoke the old credential at the source:
    • Google service account JSON → IAM & Admin → Service Accounts → Keys → delete the leaked key, create a new one
    • Resend API key → Resend dashboard → API Keys → revoke
    • APNs key (.p8) → developer.apple.com → Keys → revoke (you can have at most 2 active APNs keys per team, so revoke before creating new)
    • Google OAuth token → security.google.com → Third-party access → revoke
  3. Replace it everywhere: new file in data/<tenant>/, new env var, new Info.plist
  4. Rewrite git history or repo-init as above
  5. Force-push the cleaned repo

Time matters. Bots scan public repos for credentials within minutes. Don't let yours sit.

Encrypting backups

The backup.sh script writes plain .gz files. For off-site rotation, encrypt them before upload:

# Encrypt with age (https://github.com/FiloSottile/age)
tar -czf - backups/acme/ | age -r age1abc...your-public-key... > acme-backup.age

# Decrypt later
age -d -i ~/.config/age/key.txt acme-backup.age | tar -xzf -

Or use openssl enc with a passphrase:

tar -czf - backups/acme/ | openssl enc -aes-256-cbc -pbkdf2 -salt -out acme-backup.tar.gz.enc
openssl enc -d -aes-256-cbc -pbkdf2 -in acme-backup.tar.gz.enc | tar -xzf -

Pick one and integrate it into your scheduled backup script before the rclone push. If your backup destination is a private bucket with no public access, encryption is defense in depth (good); if it's a public bucket or a shared drive, encryption is the only thing standing between an attacker and your tenant data (mandatory).

What CRM Claw never touches in the host database

If you point a tenant at an existing CRM database (the recommended path), CRM Claw adds exactly two tables:

CREATE TABLE crmclaw_tenant (
  id          TEXT PRIMARY KEY,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE crmclaw_migrations (
  filename     TEXT PRIMARY KEY,
  applied_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  checksum     TEXT NOT NULL
);

That's it. Every other table — organizations, contacts, email_threads, email_messages, assistant_messages, crm_memory_events, etc. — belongs to the host. CRM Claw reads and writes them via the existing service layer, but it does not own their schema and it does not run migrations against them.

The total footprint on a host database is one table with one row plus one table with N rows (N = number of CRM Claw migrations applied, currently 1). You can drop both at any time and the host database returns to its original state.


Part 11 — Backup and restore

What constitutes a tenant's complete state

A CRM Claw tenant has its state spread across three places. To restore from a backup, you need all three:

  1. The host database. This is where organizations, contacts, email_threads, assistant_messages, etc. live. CRM Claw never owns this database — it talks to whatever Postgres (or SQLite) you point it at. Backup procedure depends on the driver:
    • postgres: standard pg_dump
    • sqlite: copy the file
  2. The memory file. The agent's persistent memory lives in data/<tenant>/memory.sqlite — a single SQLite file. Back it up by copying the file.
  3. The tenant profile + secrets. config/profiles/<tenant>/ holds profile.ts, prompt.md, the email provider config, optional service account JSON, optional .env. Back it up as a tarball.

You do NOT need to back up:

  • node_modules (regenerated by pnpm install)
  • the bge-m3-Q4_K_M.gguf model (auto re-downloaded on first use, ~437 MB)
  • build artifacts, logs, derived data
  • the public source code (it's in git)

One-shot manual backup

./scripts/backup.sh acme

This script:

  1. Reads config/profiles/acme/profile.ts to detect the database driver (postgres or sqlite).
  2. Runs pg_dump (or copies the sqlite file) to backups/acme/<timestamp>/.
  3. Copies data/acme/memory.sqlite to the same folder.
  4. Tars the profile directory into the same folder.
  5. Writes a manifest.json listing what's in the backup.
  6. Prints the total size.

The output goes to backups/<tenant>/<ISO timestamp>/ so you can keep many timestamped backups side by side. Backups are gitignored — they live on disk only.

Backup every tenant at once

./scripts/backup.sh --all

Walks every directory in config/profiles/ (skipping example/) and runs the one-shot backup against each. Exits non-zero if any tenant failed.

Scheduling

Linux (systemd timer):

# /etc/systemd/system/crmclaw-backup.service
[Service]
Type=oneshot
WorkingDirectory=/srv/crmclaw
ExecStart=/srv/crmclaw/scripts/backup.sh --all

# /etc/systemd/system/crmclaw-backup.timer
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target

Then systemctl enable --now crmclaw-backup.timer.

Linux (crontab):

0 3 * * * cd /srv/crmclaw && ./scripts/backup.sh --all >> /var/log/crmclaw-backup.log 2>&1

macOS (launchd):

<!-- ~/Library/LaunchAgents/com.crmclaw.backup.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.crmclaw.backup</string>
  <key>WorkingDirectory</key>
  <string>/Users/you/Documents/CRMclaw/crmclaw</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>scripts/backup.sh</string>
    <string>--all</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>3</integer>
    <key>Minute</key><integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/tmp/crmclaw-backup.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/crmclaw-backup.log</string>
</dict>
</plist>

Then launchctl load ~/Library/LaunchAgents/com.crmclaw.backup.plist.

Off-site rotation

Local backups are good for accidents, useless for disasters. To survive a stolen laptop or a wiped disk, push the backup folder to remote storage:

# After the backup script runs, sync to S3 / B2 / rclone-supported provider
rclone sync backups/ remote:crmclaw-backups/ --max-age 90d

A simple wrapper:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/crmclaw
./scripts/backup.sh --all
rclone sync backups/ b2:my-bucket/crmclaw-backups/ \
  --max-age 90d \
  --progress

Set the wrapper to run after the backup script in your scheduler.

Restore procedure

Step 1. Get a fresh CRM Claw checkout on the target machine and run ./scripts/install.sh.

Step 2. Restore the tenant profile:

mkdir -p config/profiles
tar -xzf backups/acme/2026-04-15T03-00-00/profile.tar.gz -C .

Step 3. Restore the memory file:

mkdir -p data/acme
gunzip -c backups/acme/2026-04-15T03-00-00/memory.sqlite.gz > data/acme/memory.sqlite

Step 4. Restore the database.

For a postgres tenant, recreate the database, then load the dump:

createdb acme_restored
gunzip -c backups/acme/2026-04-15T03-00-00/postgres.sql.gz | psql acme_restored

Then update profile.ts to point at the new connection string and bump expectedHost/expectedPort to match.

For a sqlite tenant:

gunzip -c backups/acme/2026-04-15T03-00-00/crm.sqlite.gz > data/acme/crm.sqlite

Step 5. Boot:

CRM_PROFILE=acme pnpm dev

BootGuard validates that the restored database and memory file still belong to tenant acme. If they were swapped or corrupted, the server refuses to start.

What to do if BootGuard refuses to boot after a restore

The two most common cases:

  1. DB fingerprint mismatch. You restored a postgres dump into a database that already had a different crmclaw_tenant.id row. Drop and recreate the database, then re-run the restore.
  2. Memory file fingerprint mismatch. You restored the wrong tenant's memory file. Restore the right one, or delete the file (the agent rebuilds memory from scratch — you lose the old chunks but the server boots).

When in doubt, ./scripts/doctor.sh acme runs all four BootGuard checks cold and prints which one failed.

How long backups take

For a tenant with ~10k organizations + ~5k contacts + ~50k email messages + 6 months of memory chunks, expect:

  • Postgres dump: ~10-30 seconds, ~50 MB compressed
  • Memory file copy: instant, ~5 MB compressed
  • Profile tarball: instant, < 50 KB

Run it daily during off-hours. Don't run it more than once an hour unless you have a reason.


Part 12 — Roadmap and contributing

Honest state of the project today

This is a fork of a production-tested tenant CRM, restructured to be multi-tenant and open source. Some things work end-to-end, some are scaffolded but not wired, some are intentionally left for later. Here's the map.

Working end-to-end (used in production):

  • Profile system + BootGuard 4-fingerprint check at boot
  • Postgres driver + idempotent migration runner with checksum tracking
  • SQLite driver for zero-dep dev tenants
  • Email providers: gmail (full), resend (send + webhook), noop (logs only)
  • Memory store: openclaw-style SQLite + sqlite-vec + FTS5 + RRF + temporal decay + MMR
  • Embedders: stub (deterministic, instant) + bge-m3 real (lazy load)
  • Agent loop: pi-ai streamAnthropic + OAuth Claude Code + tool loop with native pi-ai message types
  • Bash tool with localhost-restricted sandbox
  • HTTP routes: /health, /boot-guard, /db/health, /email/{provider,providers}, /memory/{stats,chunks,search}, /agent/{info,chat (SSE)}
  • iOS app: 143 Swift files copied verbatim from the legacy, builds clean on iOS Simulator, runtime config via Info.plist + ProjectProfile
  • Scripts: install.sh, new-tenant.sh, new-provider.sh, doctor.sh, backup.sh, scrub-check.sh
  • CI: forbidden tenant strings + typecheck on every push

Scaffolded but not wired:

  • The MCP stdio interface is referenced in docs (Part 6) but not yet plumbed into mcp-server/src/index.ts. Today the server is HTTP-only. Wiring the dual interface is a few hours of work.
  • The admin-console (Next.js) directory is referenced in the architecture but doesn't ship in the current commit — it's the recentred ex-web folder and will land separately.
  • Pre-truncation memory flush (the Haiku-based fact extractor that runs when chat history rolls past 10 messages) is described in the Appendix but not yet ported into agent/loop.ts.
  • mcp-server/src/services/ is empty — no business CRUD for organizations / contacts / etc. The scaffold is there for you to add them; the agent's Bash tool can still hit any HTTP endpoint you create.

Intentionally not in core:

  • The 130+ tenant-specific tools the legacy ships (FullEnrich, ImportPreview, BatchImport, Pennylane integration, etc.). Those belong in tenant-private extensions or get reimplemented as core tools when generalizable.
  • A unified database schema. CRM Claw connects to whatever existing CRM database you point it at; it does not impose a schema on host databases.
  • Vertical-specific iOS screens. The shipped iOS code includes the legacy's full UI; tenant-specific overlays live in private forks.

Roadmap (no dates, just direction)

Short term:

  • Wire the MCP stdio transport into mcp-server/src/index.ts so Claude Desktop can connect directly
  • Port the pre-truncation memory flush from the legacy
  • Add a few core service stubs (organizations.list, contacts.list, email_threads.list) so a fresh tenant has something to query out of the box
  • Bge3MEmbedder integration test that actually downloads the model and runs an embedding (currently it's lazy, so nothing in the test suite forces the download)

Medium term:

  • Admin console (Next.js) restored as admin-console/ with its scope reduced to operator dashboards
  • Per-tenant feature flags wired into the agent prompt (e.g. enable Pennylane tools only for tenants that have features.pennylane = true)
  • A mcp-server/src/services/_template/ directory with the scaffold for adding a new domain service

Long term:

  • Native Anthropic web_search server tool support once pi-ai grows it
  • Optional shared embedder model directory (symlink) so multiple tenants on one machine don't each duplicate the 437 MB GGUF
  • A "tenant marketplace" pattern where third-party providers publish profile templates that combine a database schema, a prompt, and a set of tools for a specific vertical

How to contribute

This is a public repo on GitHub. The contribution path:

  1. Fork on GitHub — click the Fork button.
  2. Clone your fork locallygit clone <your fork url>
  3. Branchgit checkout -b feature/your-thing
  4. Run the prerequisites check./scripts/install.sh from a fresh state, make sure your machine is set up
  5. Write the change — small, focused, with a CLAUDE.md-aware Claude Code session if you're vibe coding
  6. Run the safety netspnpm typecheck && ./scripts/scrub-check.sh && ./scripts/doctor.sh
  7. Commit — descriptive message, Co-Authored-By: Claude trailer (per the vibe-coded manifesto)
  8. Push to your forkgit push origin feature/your-thing
  9. Open a PR — describe what changed and why; link to any docs or issues; mention if you tested end-to-end against a real tenant

PR conventions:

  • One feature per PR. If you have two, split them.
  • Update docs in the same PR if your change affects them.
  • The CI must pass (forbidden strings + typecheck).
  • New email providers must include their docs/PROVIDERS.md section.
  • New core services must include a brief Part 8 cookbook entry.
  • Breaking changes need a migration note in the PR description.

Issues and feature requests

File issues on the GitHub repo. Templates:

Bug report:

  • What you ran, what you expected, what happened
  • Tenant configuration (sanitized — no secrets)
  • Server logs (sanitized)
  • BootGuard output from ./scripts/doctor.sh

Feature request:

  • The use case in plain language
  • Why the current architecture doesn't support it
  • A sketch of what the new code would look like (Claude can help you write the sketch)

Most useful contributions in the near term:

  • New email providers (smtp, postmark, mailgun, ses, microsoft365)
  • A generic CSV importer
  • An OpenAPI dump of the HTTP routes for client codegen
  • A script to migrate from a legacy CRM database into the CRM Claw conventions

License

MIT. You can do anything you want with this code. If you fork it for commercial use, please don't claim you wrote the parts you didn't write, and please leave the openclaw credit visible.

Where to ask questions

  • Open an issue on the GitHub repo
  • The Claude Code Discord (#vibe-coding channel)
  • Tag @ncleton-petitmaker on GitHub if you're stuck and the issue tracker hasn't gotten a response in a few days

Appendix — Inspiration: openclaw

What openclaw is

openclaw is an open-source agent runtime by Mario Zechner. It's the project that introduced the SQLite-based hybrid memory architecture CRM Claw uses, and it's where the pi-ai library that powers our Claude streaming comes from. The whole "openclaw-style memory" terminology in this codebase refers to it directly.

If you only know one thing about openclaw: it's a portable, local-first runtime for AI agents that keeps every piece of state in plain files on the user's machine. No vector database server, no embedding API key, no remote logging — just SQLite + a few extensions.

What CRM Claw reuses from openclaw

Direct ports, in mcp-server/src/memory/:

File in CRM ClawEquivalent in openclawWhat it does
memory/store.tsextensions/memory-core/src/memory/manager.tsThe SQLite schema (chunks + chunks_vec + chunks_fts + chunks_rowid + embedding_cache + files + meta) and the upsert/delete primitives
memory/store.ts (search)extensions/memory-core/src/memory/hybrid.tsRRF fusion of vector kNN + BM25 lexical search, with weighted contributions per modality
memory/store.ts (decay)extensions/memory-core/src/memory/temporal-decay.tsMath.LN2 / halfLifeDays exponential decay applied to non-durable chunks at search time
memory/store.ts (mmr)extensions/memory-core/src/memory/mmr.tsMaximal Marginal Relevance re-ranking with Jaccard similarity over tokenized chunk text, λ = 0.7 by default
memory/embedders/bge3m.tsextensions/memory-core/src/memory/embeddings.ts (bge-m3 path)Lazy-loaded bge-m3-Q4_K_M via node-llama-cpp, single-context queue, embedding cache lookup before each call

The pi-ai dependency (@mariozechner/pi-ai) is also openclaw's project — it's what agent/loop.ts uses to stream Anthropic responses with thinking + tool use.

What CRM Claw improved or changed

  1. Per-tenant factory instead of global singleton. openclaw's manager is a module-level singleton; CRM Claw's MemoryStore is a class instantiated by createMemoryStore(profile). Two tenants in the same Node.js process each get their own SQLite handle, their own embedder, their own everything. (We don't actually run two tenants per process — see Part 1's "one process per tenant" rule — but the factory pattern still matters because it makes the boundary auditable.)

  2. BootGuard-checked tenant fingerprint inside the memory file. openclaw doesn't have a tenant concept at all. CRM Claw adds a tenant(id, created_at) table inside every memory.sqlite and validates it against CRM_PROFILE at boot. Refuses to start if mismatched. Closes a class of bugs where the wrong memory file gets mounted at the wrong tenant's path (broken symlink, wrong Docker volume, etc.).

  3. Open ChunkSourceKind type. openclaw's chunk kinds are typed enums baked into the core. CRM Claw exposes ChunkSourceKind = string and ships a CORE_CHUNK_KINDS constant for the well-known names. Tenants can introduce new kinds (pennylane_document, interaction_summary, etc.) without touching the core types.

  4. Embedder injection. openclaw bakes the embedder selection into the manager. CRM Claw passes the Embedder interface in via the MemoryStore constructor, so the same store works with the stub embedder in tests, the real bge-m3 in prod, and any future embedder you write.

  5. Profile-driven enabledChunkTypes. Each tenant declares which chunk kinds it expects to see. The maintenance job uses the list to scope its prune passes, so a tenant that doesn't use email_thread_summary chunks doesn't pay for them.

  6. Removed: CJK bigram tokenization in MMR. openclaw's mmr.ts builds bigrams from adjacent CJK characters (Chinese, Japanese, Korean) so its Jaccard similarity works on languages without whitespace. CRM Claw drops this for now because the prompts and content are Latin-script. If you ship a tenant with significant CJK content, port the bigram tokenizer back from openclaw — it's about 30 lines.

  7. Removed: file watcher. openclaw's manager watches a directory of markdown files and reindexes them automatically. CRM Claw does not (the agent's memory is built from CRM events, not from a markdown directory). The corresponding files table is still in the schema as future-proofing.

Why we didn't just take openclaw as a dependency

We could have added @mariozechner/openclaw to package.json and called its API. Three reasons we copied instead:

  1. openclaw is an agent runtime, not a library. Its API surface is shaped around being the host application, not being embedded. Importing it as a library means importing a lot of CLI, config, and provider plumbing we don't need.

  2. Per-tenant isolation is a load-bearing requirement. openclaw's singleton design works fine for its use case (one runtime, one user, one memory store) but doesn't fit a multi-tenant CRM. Wrapping a singleton in a factory is more invasive than reimplementing the schema as a class.

  3. The schema is the contract. CRM Claw guarantees its memory file format across versions. Pinning that contract means owning the schema migrations end-to-end, which is harder when the schema is owned upstream.

We do depend on @mariozechner/pi-ai directly, because pi-ai IS designed as a library and its API surface fits ours (a streaming function that takes a model + context + options). When pi-ai changes, we update the version pin and adapt. When openclaw changes, we read the diff and decide whether to backport.

Crediting upstream

When you ship a CRM Claw fork, please leave the openclaw credit visible in this handbook and in the README. The memory architecture is theirs — we just adapted it for multi-tenant use.


End of handbook

If you got this far, you understand the project as well as anyone does. The remaining gaps in your knowledge are gaps in this handbook — open an issue and tell us what's missing. We'll fix the docs.

Now go build something. The CRM is the example. The architecture is the actual product.

Glossary — click to expand

If a term in this handbook doesn't make sense, look here first.

TermMeaning
TenantOne isolated instance of CRM Claw. Each tenant has its own database, its own memory file, its own profile, its own port, and its own process. Two tenants on one machine cannot see each other.
ProfileA configuration object (config/profiles/<id>/profile.ts) that describes one tenant: which database to talk to, which email provider to use, which prompt to load, what brand info to display, etc.
BootGuardA safety check that runs at server startup. It verifies four "fingerprints" (database, Supabase host, memory file, FROM email) before letting the server accept any traffic. If any check fails, the server refuses to boot — making it structurally impossible to point a tenant's process at the wrong database.
openclawAn open-source agent runtime by Mario Zechner (@mariozechner). Its memory architecture (SQLite + sqlite-vec + FTS5 + bge-m3) is the inspiration for the memory layer of CRM Claw. See the Appendix.
MCP (Model Context Protocol)A standard protocol that lets Claude (or any other model) call tools, read resources, and prompts via a structured interface. CRM Claw exposes its API both as HTTP routes (for the iOS app) and as MCP tools (for desktop Claude). See Part 6.
EmbedderA function that turns text into a fixed-size vector. CRM Claw ships two: a deterministic stub (no model download, instant boot) and a real bge-m3-Q4_K_M model loaded via node-llama-cpp. The store stays the same — the embedder is injected.
RRF (Reciprocal Rank Fusion)A technique to merge results from two different search engines (vector kNN + lexical BM25) into one ranked list. Used by the memory store to produce hybrid search results.
MMR (Maximal Marginal Relevance)A re-ranking algorithm that picks each next result based on relevance minus similarity to already-picked results. Reduces redundancy in search output. The memory store applies it after RRF.
sqlite-vecA SQLite extension that turns a SQLite database into a vector store. CRM Claw uses it to do kNN search on bge-m3 embeddings inside the same SQLite file that holds the chunks.
FTS5SQLite's built-in full-text search engine. CRM Claw uses an FTS5 contentless table for the lexical side of hybrid search.
pi-aiA small TypeScript library (@mariozechner/pi-ai) that wraps Anthropic's streaming API with a clean event protocol and tool-use support. The agent loop uses it to talk to Claude.
Claude Code OAuthThe authentication mechanism Claude Code uses to talk to Anthropic. CRM Claw reads the Claude Code token from ~/.claude/.credentials.json (or the macOS Keychain) so the agent runs against your existing CC subscription instead of needing a separate API key.
Profile fingerprintA single-row table inside each tenant's database (crmclaw_tenant) and inside each tenant's memory file (tenant) that holds the tenant id. BootGuard reads it on every boot and refuses to start if it doesn't match CRM_PROFILE.
ChunkOne indexed unit of agent memory. A chunk has text, metadata (source kind, source id), an embedding vector, and an importance score. The agent searches over chunks using hybrid search.
Source kindA tag on a chunk that says what kind of content it represents (event_summary, daily_digest, memory_long, org_brief, etc.). Used to filter searches and to control which chunks decay over time.
Provider contextThe small object passed to every email provider factory: tenant brand info (FROM name + email) and the data dir. Lets providers read those values without coupling to the full Profile schema.
Vibe codedWritten by talking to an AI rather than typing. See the "What is vibe coding" section above.