Inbox-to-CRM Agent (Project 2)
Turns inbound business email into tracked CRM records with drafted replies — while a human approves anything consequential.
Status: the full chain is built, including approve-with-edits and a mock outbound adapter; it has
session-based operator authentication and a public read-only demo mode that is off by default. The full chain runs: ingest → understand →
resolve → decide → approve → execute. An approved plan is applied to the CRM atomically and
idempotently, with before/after snapshots on every action. Work waiting on a person now has a real
queue — sorted by how little time is left on it, with a before/after diff of what approving would
change — and an approval that outlives its window is swept to human review rather than staying
silently executable. No real email is ever sent: by default (both locks off) an approved reply stops at the outbox as suppressed;
with both locks set it reaches a deterministic mock sender, which delivers nothing real.
The remaining known gap is a real outbound email provider: only a deterministic mock sender is
implemented, and ALLOW_OUTBOUND_SEND/OUTBOUND_PROVIDER will not deliver anything real until one
exists.
Full specification: docs/PROJECT2_INBOX_CRM_SPEC.md.
Run it locally
Prerequisites: Node.js 24 or newer (the engines field in package.json). Nothing else — no
database server, Docker, API key or account.
npm run demo
Run that from the repository root. It installs the dependencies, builds the dashboard, resets a local
SQLite database to the seeded demo CRM, and starts the app. When it prints Open http://localhost:3100,
open that address and sign in with the password printed in the terminal (made fresh for each run,
local only). Then use Inbox → Ingest → Understand → Resolve → Decide. The analyse button says how many emails it will read
before you click it. An email the assistant hands to a person shows under Approvals → Needs a person: it can always be
archived (no CRM change), and an ambiguous one can be let through to CRM matching, after which its plan still needs
approval. One that may be trying to give the assistant instructions can only be archived. Every one of those actions is
written to the audit log with who did it.
A step-by-step guide, written for a newcomer and checked from a fresh clone, is in docs/SETUP.md.
One terminal is enough: the server serves the dashboard on the same address as the API. It runs on synthetic data with the deterministic mock LLM provider, so no API key is needed, and nothing is ever sent. Every run starts from the same pristine state.
The demo starts at manual autonomy: every plan waits for you to approve it, so it never does
anything you did not click. AUTONOMY_LEVEL=assisted npm run demo starts it at assisted, where the
harmless tier-0 plans (archiving the spam, logging a support request) run on their own while anything
consequential still stops and asks. No autonomy level lets a consequential action run unattended.
To run it on the real model (Gemini, gemini-3.5-flash-lite), put GEMINI_API_KEY in server/.env and run
cd server && npm run live. It is the same one command with the real provider; it refuses to start without the
key and prints the provider, model and temperature (never the key). It has been run: 107 requests across the
evaluations described under Evaluation below, all with Gemini. Each email costs one to two requests, so click through
the batches rather than scripting them. The Anthropic provider is implemented, not yet run.
Hosting your own copy is optional and covered in docs/HOSTING.md. Nothing is hosted for you.
https://github.com/user-attachments/assets/35721368-c06c-4af2-8849-6390167e99bb
Screenshots
All taken from one recorded run of the real app on the real model (gemini-3.5-flash-lite), on invented data. Nothing was edited.
![]() |
![]() |
| The sign-in page. The owner signs in; anyone else can try the demo. | The ten emails after the model read them: category, priority, confidence. |
![]() |
![]() |
| Hover a field and the sentence it came from lights up. | The plan, before anything happens. The reply is queued "held, not sent". |
![]() |
![]() |
| Unclear or suspicious emails go to a person, with the reason. | An email that tries to give the assistant orders is treated as data. |
![]() |
![]() |
| The guest demo: private sandbox, replayed answers, nothing sent. | Who did what, when, and why: the audit timeline of one email. |
The results card from the video is docs/screenshots/results.png (figures from docs/results/, with their caveats); phone views are mobile-390.png, mobile-390-menu.png and guest-banner-mobile-390.png in the same folder.
Try it: two ways in
As the owner (the person running it): sign in with the password printed in the terminal. You get your own data, and with
npm run live the real model. Everything on this page about running it locally is this way in.
As a guest (anyone, no password): click Try the demo — no sign-in on the sign-in page. You can run the whole flow yourself:
ingest, analyse, match, decide, open an email, edit a proposal, approve or reject. Nothing you do is permanent. You get a private
copy of the demo data that is deleted when you leave (or after 30 minutes idle, or 2 hours at most), and Start over puts it back
at the beginning. Model answers are replayed from a recorded real run of gemini-3.5-flash-lite (2026-10-17, 1 of 3 runs), so no
model is called and no key is needed, and no real email is ever sent. A guest cannot see or change the owner's data or another guest's,
turn sending on, change server settings or trigger a model call. Every check around the model still runs live on each replayed
answer. GUEST_DEMO=false turns it off. Nothing is hosted: see docs/HOSTING.md and
docs/ARCHITECTURE.md.
Development setup
The two-process setup below is for working on the code. The API refuses requests without a session, so
either use npm run demo above, or set OPERATOR_PASSWORD_HASH (from npm run hash-password) and
COOKIE_SECURE=false in server/.env. Nothing here needs an API key, a database server, Docker, or
an account.
# API
cd inbox-crm-agent/server
npm install
npm run migrate # creates the schema (local SQLite by default)
npm run seed # loads the demo CRM: 6 companies, 9 contacts, 4 deals, 5 tasks, 20 activities
npm run dev # http://localhost:3100/api/health
# then, from the dashboard or with curl:
# POST /api/emails/ingest pulls the ten demo emails in
# POST /api/emails/understand reads every one of them
# POST /api/emails/resolve matches them against the CRM
# POST /api/emails/decide turns each into a plan and a draft
# POST /api/decisions/:id/approve approves and runs it
# POST /api/decisions/:id/reject rejects it, with a reason
# GET /api/approvals?state=pending the queue, soonest deadline first
# POST /api/approvals/expire sweeps anything past its window to human review
# Dashboard, in a second terminal
cd inbox-crm-agent/web
npm install
npm run dev # http://localhost:5175
npm run reset clears everything and re-seeds, restoring the demo to an identical starting state —
ids included. It refuses to run against a database it does not recognise as demo data.
Port 3100, not 3000, so this runs alongside Project 1 during a demo.
Verify it
cd server && npm test && npm run typecheck && npm run lint
cd server && npm run eval:understand && npm run eval:resolve && npm run eval:decide
cd server && npm run eval:execute && npm run eval:revise && npm run eval:outbound
cd web && npm test && npm run typecheck && npm run lint && npm run build
cd server && npm run verify:browser # signs in to the real app in headless Chrome (needs Chrome installed)
The same checks, plus npm audit --audit-level=high, run in CI on every push and pull request. The six
evaluations use the mock provider, so they say nothing about a real model; see Evaluation below.
The architecture rule
The model proposes. Deterministic code disposes. A human authorises anything consequential.
The model is used for the three things it is genuinely better at than code — classification, extraction, and drafting — and for none of the things it is worse at: authority, arithmetic, state transitions, and judging whether an action is safe.
This is the same decision Project 1 made when guardrails became application code rather than prompt text, and for the same reason: a hostile or confusing email can influence what the model says, but it can never influence what the code allows.
These parts of that rule are implemented and tested:
| Guarantee | Where | How it is enforced |
|---|---|---|
| The agent cannot express a destructive action | src/domain/actions.ts |
The action registry is closed and contains no delete or bulk-update action. Not gated — absent. |
| A consequential action always needs a human | src/agent/policy/approval.ts |
requiresApproval() is pure, deterministic, and never reads the model's opinion. No autonomy level, setting, or environment variable permits a tier-2 action to run unattended. |
| The audit trail cannot be rewritten | src/db/repositories/audit.ts |
The repository exposes append() and reads. There is no update or delete method to call. |
| An email cannot instruct the agent | src/agent/understand/prompt.ts |
Content never enters the system prompt; it arrives fenced in a user turn, and the fence is escaped so content cannot close it. The only tool the model has describes an email — there is no tool that acts. This is a statement about what the code allows, not a claim that a model never follows an attack: the injection detector missed 2 of 12 test attacks, and in an earlier run the model once obeyed one (see Results). |
| Matching is decided by code, never by a model | src/agent/resolve/ |
Entity resolution makes no model call at all. The analysis supplies evidence (a domain, a company name); fixed scores and thresholds decide what it is worth. |
| A close call is never silently promoted to a match | src/agent/resolve/resolve.ts |
Two candidates within 0.10 are a conflict — checked before the auto-link threshold — and a lone mid-band candidate is not promoted for lack of competition. |
| The UI is not the security boundary | src/agent/execute/executor.ts |
Before anything is written the executor re-runs the approval policy from stored facts, checks the approval record, its state, its expiry and a fingerprint of the plan that was approved. A caller cannot assert approval; there is no field for it. |
| An approved reply is never really sent | src/agent/execute/executor.ts |
By default the reply is written to the outbox as suppressed; with both locks set it reaches a deterministic mock sender (gmail is a declared name with no implementation). No code path in this build delivers a real message, and a guardrail-blocked draft is refused before the plan runs at all. |
| The model never chooses an action | src/agent/decide/rules.ts |
Every action comes from a deterministic rule over the understanding and the resolution. The model is called after the plan is final, and its only tool records two strings of prose. |
| A draft cannot invent a price, a promise or a statistic | src/agent/decide/draftGuardrails.ts |
Seven deterministic post-checks, one of which blocks a promise (a proposal, a refund, a call) that nothing in the plan does. The prompt asks; these check. A blocked draft is kept visible and forces approval rather than vanishing. |
| A value with no evidence never reaches the database | src/agent/understand/validate.ts |
Every non-null field must carry a quoted span that appears in the email. For names, titles, companies, email addresses and phone numbers the value itself must also be written in the email; free-form fields (service interest, requirement summary and so on) are judged by their quoted span, not by their value. Unsupported values are dropped to not_provided, and the drop is audited. |
What is built
server/
migrations/ 10 SQL files — the schema from the specification, plus the columns added since
src/domain/ closed vocabularies: states, categories, actions, risk tiers, audit events
src/db/ two drivers behind one interface, repositories, migration runner, seeding
src/adapters/ llm (mock, gemini, anthropic) · email (demo source) · crm (local reader)
src/agent/ingest/ sanitisation and ingestion
src/agent/understand/ prompt, tool schema, validator, injection detector, orchestration
src/agent/resolve/ normalisation, candidate generation, scoring, verdicts
src/agent/decide/ business rules, draft prompt, draft guardrails, orchestration
src/agent/execute/ the verification boundary and plan application
src/agent/policy/ the approval gate
src/eval/understand/ UNDERSTAND evaluation: dataset loader, pure metrics, runner
src/eval/resolve/ RESOLVE evaluation: dataset loader, scoring and runner in one module
src/eval/decide/ DECIDE evaluation: same shape as RESOLVE
src/agent/approve/ the expiry sweep
src/eval/execute/ EXECUTE evaluation: approval bypass, duplicates, blocked sends, expiry
src/handlers/ pure {status, body} handlers
src/lib/ errors, validation, redaction, logging, ids, clock
data/demo/ E-01..E-10, each with a canned model response
test/ 918 tests
web/
src/router.ts ~60-line hash router, unit-tested
src/components/planDiff.tsx the read-only before/after view of a plan
src/screens/ Overview · Inbox · email detail · Approvals + placeholders for the rest
test/ 185 tests
1103 tests total (918 server + 185 web), all currently passing.
The UNDERSTAND pipeline
sanitised email
→ prompt content fenced in a user turn, never in the system prompt
→ one model call forced tool call; the only tool describes, it cannot act
→ shape is this the right kind of object at all? (fatal → one repair retry)
→ provenance does every value quote text that is in the email? (per-field → dropped)
→ coherence does the answer contradict itself? (corrected, recorded)
→ confidence high / medium / low band
→ injection deterministic detector; its verdict is added to the model's own flag (either one marks the email suspected: it can add suspicion, never remove it)
→ persist model output, effective understanding, validation and security kept apart
→ state resolving | needs_review (+reason) | understand_failed
Uncertainty never resolves into action. Low confidence, ambiguity, missing information and suspected injection each route the email to a person with a machine-readable reason.
Entity resolution
understanding + the seeded CRM
→ candidates everyone plausibly related, by address, domain and name token
→ scores the specification's table: exact email 1.00 · exact domain 1.00 · name 0.85/0.80
· fuzzy 0.70/0.65 · name-only 0.40 · thread +0.20
→ verdict >= 0.80 MATCH · < 0.50 NO_MATCH · anything between, or two
candidates within 0.10, MATCH_CONFLICT
→ persist every candidate with its score, method, evidence and the run verdict
→ state deciding | needs_review (match_conflict)
No model call happens here, so the same email always produces the same candidates and the same scores — which is what makes "how did it know that was the same company?" answerable with a number and a quoted reason rather than "the AI decided".
Resolution reads the CRM and never writes to it. A NO_MATCH is a proposal to create a record, which DECIDE turns into an action and EXECUTE runs behind the approval gate. The evaluation asserts this by counting CRM rows before and after a full run.
DECIDE
understanding + resolution + CRM + settings
→ rules 13 deterministic rules (R-01 to R-03, R-10 to R-18 and R-20), then R-19 as a separate step over the finished plan (a returning contact's open deal is advanced, never duplicated); every one records why it did or did not fire
→ plan ordered actions from the closed registry, with a risk tier
→ policy requiresApproval() — the foundation function, unchanged
→ draft the model writes prose, only where a reply is warranted
→ guardrails seven deterministic checks; a blocked draft forces approval
→ persist plan, policy result, draft and their sources, in separate columns
→ state awaiting_approval








