SaleQue wanted a CRM assistant that never invents a promise. We built the guarantee into the database.
SaleQue is a micro-CRM for sales teams of 2 to 10 people. Their users kept asking one question the product could not answer: "what did we promise this client?" The answer was always in the CRM, spread across a deal, a note and an email thread. SaleQue asked Elegant to build an assistant that answers from the workspace's own records, cites every source, never sees another tenant's data, and runs at a cost a small team can pay. This is how we built it.
A CRM built for teams too small to have a RevOps person.
SaleQue sells to founders and small sales teams who run their pipeline in one workspace: leads, accounts, deals, tasks and activities, with an AI layer on top that scores leads, suggests next actions and enriches records. Their roadmap had one item their in-house team had not been able to ship safely: an assistant that could be asked anything about the workspace and trusted enough to be quoted to a customer.
They had prototyped it twice. The first version chatted over the whole database and produced fluent, confident answers with no way to check them. The second summarised records into a prompt and hallucinated a discount that had never been offered. Both were pulled before launch. When SaleQue brought the brief to Elegant, the requirement had become precise.
Five requirements. None of them negotiable.
Answer only from this workspace
Not from the model's general knowledge, and never from another customer's records. Isolation had to be structural, not a filter someone remembers to add.
Cite every claim
A sentence the rep cannot trace to a record must not appear. If the records do not contain the answer, the assistant says so.
Stay fresh without re-indexing everything
Reps edit records all day. Embeddings must follow content changes and ignore everything else, or the embedding bill becomes the product's biggest cost.
Cost a small team can see and control
Credits per question, a ledger per workspace, repeated questions free, and no lock-in to one model provider.
Decisions with a confidence the code can act on
Lead scores and routing must come back as typed values with a calibrated confidence, so the CRM can act when it is sure and hand the lead to a person when it is not.
A promise is never stored once.
The price is in the deal, the reason is in a note, the confirmation is in an email activity. Three record types, three screens, and the link between them existed only in the head of whoever made the promise.
CRM search is keyword search.
"Held the price" and "price freeze" never match each other. A rep who did not write the note cannot find it. Retrieval had to work on meaning and still catch exact names and amounts.
Both failed prototypes shared one flaw.
The model was trusted to decide what to say. In the version we built, the model proposes and code verifies. Anything the code cannot trace to a retrieved record is removed before render.
A score is not a sentence.
Asking a chat model to rate a lead returns a paragraph and a number with no calibration behind it. You cannot put a threshold on that. Scoring and routing needed a model built to return typed decisions, so we split the work: Jev decides, Claude and OpenAI write.
The model proposes. The code verifies. Anything that cannot be traced to a record in this workspace is removed before it reaches the screen.
Everything lives in Postgres, on purpose.
SaleQue already ran a React frontend on a Python and FastAPI backend over PostgreSQL. Adding a separate vector database, a message broker and a cache cluster would have tripled what a 2-person team has to operate. We put the embeddings in pgvector, the queue in an outbox table, the cache, the ledger and the lead scores in ordinary tables, and enforced tenancy with Row Level Security. One database, one backup, one thing to page someone about. Model calls leave the API through one service: Claude and OpenAI for language, Jev for decisions.
Two walls, because one filter is one bug away.
Every table that can hold customer text carries a workspace_id, and every query the application runs starts with that filter. That is the first wall, and on its own it is what most multi-tenant products rely on. We were not willing to let SaleQue's safety depend on every future query being written correctly.
The second wall is Row Level Security inside Postgres. Every FastAPI request opens one transaction, switches to the application role, sets the current workspace as a transaction-local setting, and from that point the database refuses to return or accept rows from any other workspace, even if a query forgets the filter entirely. Retrieval, full-text search, the embeddings table, the outbox, the cache and the ledger all sit behind the same policy.
Both settings are transaction-local, so a pooled connection can never carry one tenant's scope into the next request. The workspace itself never comes from the request. FastAPI resolves it from the signed session, and a body that tries to name a workspace is rejected with a 422 before any query runs.
The test that runs on every commit
Two seeded workspaces with overlapping company names and identical amounts. 12 adversarial questions ask workspace A about facts that only exist in workspace B, by name, by amount, by email address and by paraphrase. Then the same search with the workspace filter deliberately removed. The assertion is 0 rows from B in both cases, plus a rejected insert when a row is tagged with the wrong workspace.
Why the policy shows up in the query plan
The One-Time Filter line in the plan below is Postgres evaluating the RLS policy before the scan. It is not application code. If SaleQue's team writes a new query next year and forgets the filter, that line is still there.
1. scoped retrieval, EXPLAIN ANALYZE (3,010 embeddings, 2 workspaces, HNSW on cosine) Limit (actual time=14.471..14.479 rows=8 loops=1) -> Sort (actual time=14.468..14.471 rows=8 loops=1) Sort Key: ((embedding <=> $question::vector)) Sort Method: top-N heapsort Memory: 17kB -> Result (actual time=0.121..13.269 rows=1506 loops=1) One-Time Filter: ((current_setting('app.workspace_id'::text, true))::uuid = '4f2a6c1e-8b3d-4e7a-9c21-0d5f7a3b9e11'::uuid) -> Index Scan using embeddings_ws on embeddings (actual time=0.030..1.146 rows=1506 loops=1) Index Cond: (workspace_id = '4f2a6c1e-8b3d-4e7a-9c21-0d5f7a3b9e11'::uuid) Planning Time: 0.424 ms Execution Time: 14.730 ms note: the One-Time Filter line is the RLS policy; for a 1.5k-row tenant the planner picks the workspace index and an exact top-N sort 1b. same search across the whole table (large tenant or admin path: the planner switches to the HNSW index) Limit (actual time=1.045..1.145 rows=8 loops=1) -> Index Scan using embeddings_hnsw on embeddings (actual time=1.042..1.138 rows=8 loops=1) Order By: (embedding <=> $question::vector) Planning Time: 0.088 ms Execution Time: 1.180 ms 2. second wall (same table, NO workspace filter, running as app_user for workspace A) rows returned: 50 from workspace B: 0 policy: workspace_id = current_setting('app.workspace_id') 3. same query as the database owner (what the policy protects against) 4f2a6c1e 1506 embeddings b7e1d2c3 1504 embeddings 4. write with a foreign workspace_id, as app_user for A rejected new row violates row-level security policy for table "tasks"
Re-embed when the words change. Nothing else.
Reps touch records constantly: reassign an owner, move a stage, tick a task. Almost none of that changes what the record says. Each record type has a template that renders it into one readable paragraph, the same text the assistant will later cite. We hash that text with SHA-256 and store the hash beside the vector. On every write, the new hash is compared with the stored one. Same hash, no work. Different hash, one row goes into the outbox inside the same transaction as the record write, so there is no window where a record exists without its embedding job.
A Python worker claims pending rows with FOR UPDATE SKIP LOCKED, so several workers can run without ever taking the same job, calls the OpenAI embeddings adapter and upserts into the embeddings table with the workspace id, the record type and id, the content, the hash and the model version. The model version column means a future embedding model upgrade is a progressive backfill, not a big-bang re-index. Deleting a record fires a trigger that removes its embedding in the same transaction, so a removed note can never be retrieved.
Verified in the suite
An update to updated_at enqueues nothing. An update to notes enqueues exactly once, the worker drains exactly one row, and the stored hash changes. A deleted lead leaves no embedding behind. Three tests, tests/test_lifecycle.py.
Why an outbox and not a queue service
The outbox row and the record write commit together or not at all. There is no second system to keep in sync, no retry topology to design, and the worker is a loop over one table. For a 2-person team this is the difference between a feature they can operate and one they cannot.
Meaning and exact match, fused.
A vector search finds "held the price" when the rep asks about a "price freeze". It is bad at "$48,000" and at a person's name. Postgres full-text search is the reverse. So every question runs both in one SQL round trip, each scoped to the workspace, and the two lists are merged with reciprocal rank fusion inside Postgres. The top 8 records go to the model and nothing else does.
The plans tell an honest story about scale. For a tenant with 1,500 embeddings the planner chose the workspace index and an exact top-N sort, about 15 ms with 100% recall. Across the whole table it switched to the HNSW index at about 1 ms. Small tenants get exact answers, large tenants get the approximate index, and nobody had to tune anything.
The model fills in a form. The code marks it.
The prompt contains the question and the retrieved records, and nothing else. The model must return a fixed shape: an array of sentences, each with the ids of the records it relied on, and up to 3 proposed actions, each typed as a task, a deal update or a calendar hold. Free text outside the schema is rejected. The schema is one Pydantic model, and that same model generates the JSON schema sent to the provider, so the contract and the validator can never drift apart.
Then the verifier runs. Every cited id is checked against the set that was actually retrieved for this question. A sentence with no valid source is dropped. A sentence citing a record that was never retrieved is dropped. Citations are renumbered in order of first use so the UI can show [1], [2], [3]. If nothing survives, the response is the not-found state, with the closest records the search did find, so the rep can judge whether the promise was ever written down. The same verify-before-render rule now runs the email and meeting pipeline we built for OfficeSyncPro.
ask ws 4f2a6c1e · 6 records retrieved in 2 ms · answer miss in 3 ms q What did we promise Northwind before the renewal call? Three commitments are on record for Northwind. [1][2][3] Renewal price held at $48,000 for 12 months, confirmed by email on 9 Sep. [1] Two extra seats included at no charge, agreed on the 12 Sep call with Maya Chen. [2] An updated SOW was promised by 20 Sep. That task is still open. [3] The deal sits in Negotiation with a close date of 30 Sep. [4] dropped Northwind also asked about an API add-on. (no source in retrieved set) dropped They mentioned a competitor quote. (no source in retrieved set) sources [1] activity 9b2d5f6a Email on 2026-09-09: Pricing confirmation to Maya Chen. Confirmed the renewal … [2] activity e1d2c3b4 Note on 2026-09-12: Call notes: renewal scope. Call with Maya Chen. Agreed two… [3] task c56a4180 Task Send updated SOW to Maya Chen, open, due 2026-09-20, owner Sam. [4] deal 3f2504e0 Deal Northwind renewal, $48,000, stage Negotiation, closes 2026-09-30. Annual … proposed actions • task Send the updated SOW to Maya Chen due today • deal_update Add 2 seats to the Northwind renewal deal • calendar_hold Book the renewal call before 30 Sep ledger feature=assistant.ask provider=test cache=miss credits=1 balance=199
python -m scripts.ask command in the reference build. Two of the seven sentences the completer returned had no source in the retrieved set. Both were dropped before render. The credit ticked once and the answer was cached against the question and the record ids.One door for every model call.
No feature in SaleQue calls a provider directly. Everything goes through one service that does 4 things on every call. It meters the call against the workspace at a fixed credit price the customer can see. It checks a cache keyed on the workspace, the normalised question, the sorted ids of the retrieved records and the model, so the same question over the same evidence is free and instant, and a changed record misses on purpose. It routes through one adapter interface. Claude is the primary and is forced into the answer schema with a tool call. If Claude returns an outage, a rate limit or a timeout, the same request goes to OpenAI with a JSON schema response format, and the same verifier runs on whatever comes back. The ledger records which provider actually answered. And it refuses cleanly: a workspace at 0 credits sees a clear state instead of a failed request, while embedding of changed records continues so retrieval quality does not decay while the customer decides whether to top up.
Language models write. Jev decides.
Every new lead in SaleQue needs three decisions: how well it fits the workspace's ideal customer, what the sender wants right now, and whether money is mentioned. We did not want a chat model writing a paragraph and a number for that. The API sends the lead and the workspace's ideal customer profile to Jev, TypeSafe AI's System One model, with three typed questions: a score on a 4-level rubric, a choice between 4 intents, and a yes or no on budget. Jev returns each answer as a typed value with a confidence, through the official Python SDK.
The routing rule is plain Python that anyone on SaleQue's team can read. Below 0.80 confidence on intent, the lead goes to a person and nothing is guessed. Confident noise is archived. Support requests go to customer success. A buying lead with a good fit goes to sales at high priority. Every decision is stored in lead_scores with the raw answers and the model version, behind the same RLS policy as everything else, and metered in the same ledger as the language models.
score-leads model=jev-1.13 auto>=0.8 ws 4f2a6c1e 16fd2706 Daniel Reyes buying 0.93 fit 2.61 budget 0.91 -> sales/high 5e8b1c2d Priya Nair evaluating 0.58 fit 1.12 budget 0.04 -> review 0c1d2e3f SEO Growth Team noise 0.97 fit 0.06 budget 0.00 -> archived ledger feature=lead.score provider=typesafe calls=3 tokens=750 stored in lead_scores, scoped by RLS
python -m scripts.score_leads command in the reference build, run on recorded System One responses. The vague lead came back at 0.58 confidence on intent, so it went to review instead of being guessed. The spam was archived. The funded founder went to sales at high priority.Why not ask Claude for the score
A chat model can produce a number, but not a calibrated one, and the same lead can come back with a different number on a retry. Jev's confidence is what makes the 0.80 gate mean something. It also keeps the language models on the work they are good at: reading records and writing cited answers.
Verified in the suite
The typed questions reach /v1/systemone. A hot lead routes to sales. A 0.58 confidence routes to review. Noise is archived. Every decision is stored and metered inside the workspace. Four tests, tests/test_jev.py.
What SaleQue's users see.
The assistant uses the same components as the rest of the CRM, so an AI answer never looks like a widget bolted on. Six surfaces carry the whole feature.
One box, one workspace.
The ask box sits on the home screen next to the day's tasks. Prompt templates cover the questions reps ask every week. The scope line under the box says exactly which workspace the assistant can see and what a question costs. There is no "search everything" mode.
Cited, or not shown.
Each sentence carries its numbered sources. Clicking a citation opens the record in a side panel. The struck-through line is what the verifier removed: a sentence the model wrote with no record behind it. In production it is never rendered. It is shown here because it is the whole point.
Actions are typed, proposed, and confirmed by a person.
The same structured response carries up to 3 actions. Each has a type the CRM already understands. Nothing is written automatically. The rep clicks once, and the created task links back to the records that justified it, which makes the CRM richer every time the assistant is used.
The not-found state is a real screen.
When nothing survives verification, the assistant says so and shows the closest records it did retrieve, cited, so the rep can decide whether the promise was ever written down. This state has its own design because in a CRM it happens often, and it is where the second prototype lied.
A ledger the customer can read.
Every model call is a row: feature, provider, tokens, cache result, credits. Workspace admins see spend per feature and per day. Support sees the same table when a customer asks why a credit was used. The number in the top bar is this table, summed.
A lead inbox that knows when it is unsure.
Every inbound lead arrives scored and routed. The confidence sits next to the decision, so a rep can see why a lead jumped the queue. Anything under 0.80 lands in a review lane with the typed answers attached, and the rep makes the call.
What the test suite proves, file by file.
These are the guarantees SaleQue's team can rely on, and the tests that enforce them on every commit. The suite runs the real schema on PostgreSQL 17 with pgvector 0.8. Claude, OpenAI and Jev run through their real SDKs against recorded HTTP payloads, and a deterministic embedder stands in for the embeddings API, so CI runs offline and the results are reproducible.
| Property | How it is checked | Suite |
|---|---|---|
| The workspace comes from the session only | A body naming another workspace returns 422. A forged token returns 401. Scoring another workspace's lead returns 404. | tests/test_api.py |
| Workspace A can never retrieve a workspace B record | 12 adversarial questions by name, amount, email and paraphrase. 0 foreign rows. | tests/test_isolation.py |
| Forgetting the filter does not leak | The same search with no WHERE clause, as the app role. 0 foreign rows. | tests/test_isolation.py |
| A row tagged with another workspace is rejected | Insert as workspace A with workspace B's id. Policy violation. | tests/test_isolation.py |
| Metadata edits do not re-embed | Touching updated_at enqueues nothing. Editing notes enqueues once and changes the hash. | tests/test_lifecycle.py |
| Deleted records disappear from retrieval | Delete a lead. The trigger removes its embedding in the same transaction. | tests/test_lifecycle.py |
| The renewal question brings the 4 relevant records | Email, note, task and deal all in the top 8. Exact amount found by full text. | tests/test_retrieval.py |
| Uncited sentences never render | 7 sentences in, 5 out, the 2 without a valid source dropped. Not-found when 0 survive. Off-schema output rejected. | tests/test_contract.py |
| Repeat questions cost nothing, changed evidence costs 1 | Miss then hit on the same question, balance 199. Different record set misses again. A workspace at 0 credits is refused before any model is called. | tests/test_ledger.py |
| Claude is held to the schema, OpenAI takes over on an outage | Real Anthropic and OpenAI SDKs on recorded payloads. The forced tool call is checked. A 529 from Claude falls back to OpenAI under the same contract. | tests/test_providers.py |
| Uncertain decisions go to a person | Typed questions reach Jev. Intent at 0.58 routes to review, noise is archived, a hot lead goes to sales, every decision is stored and metered. | tests/test_jev.py |
Four things a demo would have included.
No fine-tuning
The value sits in the retrieval and the answer contract. A tuned model would have added cost without adding trust, tied SaleQue to one vendor, and made the provider swap meaningless.
No cross-workspace intelligence
"Deals like this usually close at" would be a good feature. It also requires reading other tenants' data. It stays out until it can be built on aggregates that never touch a record.
No answers from general knowledge
The model is not allowed to fill gaps. A question the workspace cannot answer gets the not-found state with the closest cited records.
No autonomous writes
Actions are proposed and typed. A person confirms every one. The CRM stays the system of record and the assistant does not get to edit it on its own.
A feature their team can run without us.
Six phases, one senior team, weekly demos to SaleQue.
Data model and isolation first
workspace_id on every table, RLS policies, the app role and the adversarial isolation suite, all before a single embedding existed. The most important tests were green before the feature had a UI.
Embedding lifecycle
Record templates, hashing, outbox and worker, model version column, backfill of SaleQue's existing workspaces.
Retrieval and the contract
Hybrid search and fusion, the response schema, the verifier and the not-found state, evaluated against a hand-written question set over seeded workspaces.
Assistant surfaces
Ask box, templates, answer rendering with citations, source panel, proposed actions to tasks, built with SaleQue's existing components.
AI service and decisions
Ledger, cache, Claude and OpenAI adapters with fallback, Jev lead scoring with the 0.80 review gate, per-feature pricing, the 0-credit state, admin spend view.
Hardening and handover
Outbox under bulk import, rate limits per workspace, audit log of every assistant call, runbook, decision records, walkthrough with SaleQue's engineers.
RAG is a data problem wearing an AI costume.
Almost none of the work on this project was prompt engineering. It was deciding what a record looks like as text, when it changes, who is allowed to see it, and what the model is allowed to return. Get those 4 things right and the assistant is boring in the best way: it answers, it shows its sources, and it says "I could not find that" instead of guessing.
If your product already holds the data your customers keep asking about, this is a 6-week feature with a fixed price. The tests on this page are the ones we would write for yours.
AI Agents and Workflow Automation
Retrieval, agents and typed AI features built into your product by one senior team. Fixed price, fixed timeline, full IP transfer.
Adding this to a product that is already live? See AI Integration for Existing Products
Ready to build something like this?
Book a free 15-minute call. We will look at what you are building and tell you honestly what it takes and whether we are the right fit.
