Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.
Three Gemini agents on Google Cloud research the sender, read the intent and score each inbound email. One trusted service writes the CRM data.
Key Results

A shared inbox is where B2B interest gets lost. Someone writes asking for a price, someone else offers a partnership, a third message is spam with a polite subject line. All of them look the same until a person opens them, and by then the one that mattered has waited behind five that did not.
The usual fix is a CRM, but a CRM only stores what somebody types into it. I wanted the opposite order: the message arrives, the system works out who wrote, what they want and what the message is worth, and a person sees the result and decides what to do. The agents propose, a human acts. Nothing in the system sends a reply.
The system accepts an inbound email and runs it through three agents, each with one job:
The first two do not depend on each other and run in parallel. The third waits for both. If one of the first two fails, the others still finish and the lead is qualified from what exists.
The results go to a single service, the golden-record-writer, which is the only thing allowed to change the CRM tables. It checks each result against its schema, merges it into BigQuery and writes an audit row with the state before and after. A model only ever returns a proposal; ordinary code decides whether it is written.

The CRM is a read-only web app over the Golden Record. The inbox lists emails with intent, priority, score and next step. An email page shows the text, each agent’s conclusion, the draft reply, the model, token count and latency of every agent run, and the audit entries with the fields that changed. Contacts and companies are separate records: a second email from the same sender attaches to the existing contact instead of creating a new client.
Every box on the Google Cloud side is its own Cloud Run service with its own service account. The private ones accept calls only from the identity that is meant to call them.
The mail provider stores the message and calls a webhook with the email ID and metadata, not the body. Ingest is the only endpoint open to the internet, so its protection is the signature: it verifies it over the raw body before doing anything else. It then creates a Firestore document keyed by the email ID. The creation fails atomically if the document exists, so a repeated delivery is answered with 200 and dropped. BigQuery cannot do this, because it does not enforce unique keys.
Ingest marks the email as received, publishes the ID to Pub/Sub and marks it as published. If publishing fails, it answers 503 and the provider retries; the retry finds the pending record and publishes again. No event is lost and none is sent twice.
A push subscription delivers each message to the worker with a Google-signed identity token. Failed deliveries are retried with backoff from 10 to 600 seconds, and after 5 attempts the message moves to a dead-letter topic, so nothing is dropped silently.
The worker fetches the full message from the provider API and writes it to BigQuery with a MERGE that inserts only if the email ID is new. It then publishes an email-stored event. Any failure returns HTTP 500, so Pub/Sub redelivers, and every step is safe to repeat.
Eventarc starts one Cloud Workflows execution per email-stored event. The worker knows nothing about the orchestration, so more subscribers can be added without touching it.
The workflow runs Company Research and Intent Analysis in parallel, then Lead Qualification with their results, then the writer. Transient failures (429, 502, 503, 504, timeouts) are retried up to three times with exponential backoff. If an agent still fails, its result is marked as failed and the others continue. Each run is visible step by step in the Workflows console.
All three share one wrapper around Gemini 2.5 Flash on Vertex AI. It forces structured output against a Pydantic schema, applies a timeout, and logs model name, prompt tokens, output tokens and latency for every call. The CRM shows those figures for each email, so cost and speed are visible per agent.
Takes the sender’s domain. Public mailbox providers (Gmail, Outlook and similar) get no company at all: the agent does not guess a company from a signature it cannot verify. For a business domain it looks up MX, SPF and DMARC records, fetches the home page and asks Gemini for a profile (name, what the company does, industry, size hint, confidence, supporting quotes) based only on that page text. If the site cannot be read, the result is ‘unknown’ and no profile is made up.
The domain is controlled by the sender, which makes the page fetch an SSRF risk. The fetch connects only to public IP addresses, checks every redirect hop, pins the connection to the address it checked, accepts only http(s) on default ports and limits time and response size. A crafted domain cannot reach internal services or the cloud metadata server.
Reads the email from BigQuery and returns a verdict that must match a fixed schema: intent (demo request, pricing inquiry, partnership, support request, recruiting, vendor solicitation, spam, other), urgency, summary, requested action, language, confidence and supporting quotes. Quotes that do not appear verbatim in the email are dropped, so the evidence shown in the CRM is always real text from the message.
Called by the workflow after both agents finish, with the email ID and their results. A failed or missing result is left out and the model is told to lean on the email alone. Gemini returns a score, a priority, a rationale, one next step (book a demo, answer a question, route to support, route to recruiting, no action) and a reply draft in the sender’s language.
Rules in code have the last word. Spam is forced to a low score with no reply, ‘no action’ never carries a draft, and a low-confidence lead cannot be high priority. The service has no tool for sending mail, so the draft stays a draft.
The writer is the only service with write access to the CRM tables. A malformed result is rejected and nothing is written. Otherwise one BigQuery transaction upserts the email into interactions, rebuilds the sender’s contacts row and the domain’s companies row from all their interactions, and appends rows to audit_log with the state before and after.
A contact is the sender address, a company is the sender domain, and public mailbox providers get no company. Counts and dates are recomputed from the interactions, not incremented, so a repeated run cannot inflate them. A company profile is replaced only by one with equal or higher confidence, and an agent that failed in a later run never erases what an earlier run stored. Audit rows are written only when something actually changed.
Every row gets a UUIDv7. It starts with a timestamp, so keys sort by creation time and keep inserts and clustered scans local. CRM URLs use these identifiers, so addresses and domains never appear in links.
| Concern | How it is handled |
|---|---|
| Duplicate delivery | Firestore claim in ingest, insert-if-new MERGE in the worker, keyed upserts in the writer: every queue may deliver twice and the result is the same |
| Failures are visible | Automatic retries, a dead-letter topic for exhausted messages, structured JSON logs in Cloud Logging with the email ID on each line |
| Alerts | Cloud Monitoring emails on a dead-letter message, a failed workflow execution, a service ERROR or a 5xx; a monthly budget alert watches the cost |
| Scheduled recovery | A nightly workflow finds emails from the last seven days where an agent failed and publishes them again; the idempotent writer fills in only what is missing |
| Least privilege | Each service has its own service account. Agents can read the email table but cannot write it; only the writer can change the CRM tables |
| Secrets | Provider keys and web app credentials live in Secret Manager and are injected at runtime. Nothing secret is in the repository or the image |
| Untrusted input | Email text, website text and earlier agent results go to the model as data inside delimiters, with an instruction not to follow them, and the answer shape is fixed |
| Cost and latency | Every model call logs model, prompt tokens, output tokens and latency, and the CRM shows them per email |
The agents are checked against 24 labelled test emails: corporate requests, pricing questions, partnership offers, support requests, spam and messages in several languages. Each case has an expected intent and next step, and some carry a cap on priority, because the expensive mistakes are not symmetric. Spam that gets a high priority costs more than a missed nuance between two kinds of sales message.
The harness calls the live model, so a change to a prompt or a model version shows up as a number instead of an impression.
| Metric | Result |
|---|---|
| Test emails | 24 |
| Intent correct | 23 of 24 (95.8%) |
| Next step correct | 20 of 24 (83.3%) |
| Priority cap respected | 24 of 24 |
| Latency per email, median | 6.0 s (two model calls) |
| Latency per email, maximum | 11.8 s |
| Tokens across all cases | 19,346 in, 7,197 out |
The misses are instructive. Two pricing questions got ‘book a demo’ instead of ‘answer a question’, a partnership and a general message were routed to support, and one email that tries to order the model to rate it as a high-priority demo request was called spam instead of vendor solicitation. The injection failed where it matters: the lead stayed at low priority with no action. The other misses are judgement calls between two reasonable next steps, which is why a person reads the result before anything goes out.
Python 3.13, FastAPI, Jinja2 and HTMX for the web app, Pydantic for every schema. Google Cloud: Cloud Run, Pub/Sub, Eventarc, Cloud Workflows, Cloud Scheduler, BigQuery, Firestore, Secret Manager, Cloud Logging and Monitoring, and Vertex AI with Gemini 2.5 Flash. Inbound mail arrives through Resend. The project is built with uv, ruff and pytest; 73 tests cover the services, the agents and the CRM pages.
This is a personal project, built end to end: the delivery guarantees, the trust boundary around the model and an evaluation loop that shows when a prompt change made things worse.
AvailableNeed something similar?
I build custom solutions – from APIs to full products. Let’s talk about your project.
Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.
A production localization pipeline: OpenAI translates, another vendor’s model verifies, a separate AI agent proofreads, pytest guards structure.
Stack
Why TypeScript types disappear at runtime, and how to use Zod to validate webhook payloads and form submissions at the boundary.
Stack
Why registrar and CA renewal emails leave gaps, what a self-hosted domain and SSL expiry check needs to do, and when it beats paid monitoring SaaS.
Stack