We have used two real CRMs.
First Salesforce, because our founding sales person said it was essential. Then HubSpot, because our founding marketing person said it was essential. Both people were right that tracking the pipeline is essential... but really, neither system earned its keep at our scale.
What the data actually looked like, once it escaped, was a spreadsheet: three columns of company names labeled Engaged Prospects, Legal, and Partners/Channels. >100 organizations, no statuses, no activity history, no next steps.
A full CRM had twice proven to be overkill; the spreadsheet was underkill.
So I replaced both with a mini-CRM built in an afternoon with Claude Cowork, using tools we already run: local PostgreSQL, the spreadsheet itself, and SWIRL for search. Here is the full build, including the bug we shipped and caught.
Step 1: From three columns to a schema
The agent read the spreadsheet with openpyxl and proposed a schema built for the actual job, which is tracking activity, not just listing names:
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('engaged_prospect', 'legal', 'partner')),
status TEXT NOT NULL DEFAULT 'active', -- free-form: active, stalled, won, dead...
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (name, category)
);
CREATE TABLE contacts (
id SERIAL PRIMARY KEY,
org_id INT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
title TEXT,
email TEXT,
phone TEXT
);
CREATE TABLE activities (
id SERIAL PRIMARY KEY,
org_id INT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
activity_date DATE NOT NULL DEFAULT CURRENT_DATE,
activity_type TEXT NOT NULL DEFAULT 'note', -- call, email, meeting, demo...
summary TEXT NOT NULL,
next_step TEXT,
next_step_due DATE
);
Two design decisions that paid off immediately:
- Activities are append-only history, not a mutable "last touched" field. You never lose the story of an account.
- Status is free-form text. We started with a suggested vocabulary, but the first real update was "dead", which was not on the list. A CHECK constraint on status would have turned a one-word instruction into a schema migration. Constrain the things that break joins (category); leave vocabulary to the humans.
Two views do most of the daily work:
-- Latest activity per organization
CREATE VIEW org_latest_activity AS
SELECT o.id, o.name, o.category, o.status,
a.activity_date AS last_activity_date,
a.activity_type AS last_activity_type,
a.summary AS last_activity_summary,
a.next_step, a.next_step_due
FROM organizations o
LEFT JOIN LATERAL (
SELECT * FROM activities a
WHERE a.org_id = o.id
ORDER BY a.activity_date DESC, a.id DESC
LIMIT 1
) a ON true;
-- The daily to-do list
CREATE VIEW followups_due AS
SELECT o.name, o.category, a.next_step, a.next_step_due
FROM activities a
JOIN organizations o ON o.id = a.org_id
WHERE a.next_step IS NOT NULL
AND (a.next_step_due IS NULL OR a.next_step_due <= CURRENT_DATE)
ORDER BY a.next_step_due NULLS LAST;
An idempotent loader script pulled every name out of the three spreadsheet columns and inserted it with the right category. ON CONFLICT (name, category) DO NOTHING means re-running it after the spreadsheet grows is always safe.
Step 2: Make it searchable with SWIRL
We run SWIRL for federated search, so the obvious next move was a SearchProvider that folds the CRM into the same search box as everything else.
First, a denormalized view so a single ILIKE sweep can hit everything worth matching:
CREATE VIEW crm_search AS
SELECT o.id, o.name, o.category, o.status,
COALESCE(o.notes, '') AS notes,
la.last_activity_date, la.last_activity_type,
COALESCE(la.last_activity_summary, '') AS last_activity_summary,
COALESCE(la.next_step, '') AS next_step,
la.next_step_due,
COALESCE((SELECT string_agg(
c.name || COALESCE(' (' || c.title || ')', '')
|| COALESCE(' <' || c.email || '>', ''), '; ')
FROM contacts c WHERE c.org_id = o.id), '') AS contacts,
o.updated_at::date AS updated
FROM organizations o
LEFT JOIN org_latest_activity la ON la.id = o.id;
Then the provider. SWIRL's PostgreSQL connector takes a query template with mapped fields:
{
"name": "Mini-CRM - PostgreSQL (SQL)",
"connector": "PostgreSQL",
"url": "localhost:5432:crm:<db-user>:<db-password>",
"query_template": "select {fields} from {table} where {field1} ilike '%{query_string}%' or {field2} ilike '%{query_string}%' or {field3} ilike '%{query_string}%' or {field4} ilike '%{query_string}%' or {field5} ilike '%{query_string}%' or {field6} ilike '%{query_string}%'",
"query_mappings": "fields=*,sort_by_date=updated,table=crm_search,field1=name,field2=notes,field3=last_activity_summary,field4=contacts,field5=category,field6=next_step",
"result_processors": [
"MappingResultProcessor",
"CosineRelevancyResultProcessor"
],
"result_mappings": "title='{name} ({category} / {status})',body='Last activity: {last_activity_summary} ({last_activity_type}, {last_activity_date}). Next step: {next_step} (due: {next_step_due}). Contacts: {contacts}. Notes: {notes}',date_published=updated"
}
Searching a company name, a status ("won"), a category ("partner"), or a phrase from a call summary all just work, ranked by SWIRL's relevancy pipeline alongside every other source.
The result_mappings line went through one iteration worth mentioning. The stock SQL-provider pattern is result_mappings: "DATASET", which collapses all rows into a single result carrying a table payload. Fine for analytics, wrong for a CRM: you want each account as its own result with its status and next step visible. Switching to explicit template mappings gives every org its own card:
Acme Manufacturing (engaged_prospect / active)
Last activity: intro call re search POC (call, 2026-07-15).
Next step: send scoping doc (due: 2026-07-25). ...
Mapping date_published=updated also makes date-sorting real instead of "unknown".
One more detail for the LLM era: SWIRL providers can carry query_instructions in their config, which is handed to an LLM when the source is queried through SWIRL's assistant or MCP server. Ours documents the full schema, the enumerated category/status values, and five SQL templates (activity history, due follow-ups, pipeline counts). That turns "what needs attention this week?" into real SQL against followups_due instead of a keyword guess.
Step 3: The update loop nobody hates
The first plan for data entry was conversational: the agent walks through each account and asks what happened. That died on contact with reality after one answer. Dictating 100+ updates one at a time is miserable.
The fix: the spreadsheet stays the editing surface. The agent added a second sheet, "Tracking", one row per organization, pre-filled from the database:
Name | Category | Status | Activity Date | Activity Type | Activity Summary | Next Step | Next Step Due | Notes
Edit any cells, save, run the sync:
- Status or Notes differ from the DB: the org is updated.
- Activity Summary is filled in and differs from the org's latest activity: a new activity row is inserted. History accumulates; the spreadsheet only ever shows the latest.
- New name with a category: org created.
- The script never deletes anything, and running it twice in a row is a no-op.
After any bulk change made directly in SQL, the sheet is regenerated from the database so the two never fight.
For bulk updates, plain English turned out to beat both surfaces. "Mark everything dead except these accounts; add this new deal as won via partner X" became one reviewed transaction. The agent applies it, prints the resulting pipeline counts, and refreshes the sheet.
The bug: names are not keys
The sync script's first version keyed organizations by name. One company in our data legitimately exists in two categories (it is both a service firm and a channel partner), which is exactly why the table's unique constraint is (name, category).
The script's in-memory state dict silently kept only one of the two rows, and its UPDATE ... WHERE name = X hit both. Net effect: syncing the sheet resurrected a row that a bulk update had just marked dead.
It surfaced immediately for one reason: the sync prints a change report, and we expected zero. A refresh-then-sync cycle should be a perfect no-op, and it reported "1 org updated" instead. That single unexpected line of output was the whole detection mechanism. The fix was mechanical (key by name plus category, scope every UPDATE and INSERT the same way), verified by re-running until the no-op was real.
If your loader is idempotent, "re-run it and demand zero changes" is the cheapest integration test you will ever write.
What we ended up with
- A Postgres database with full activity history, one
psqlaway. - A spreadsheet that is now a UI, not a database.
- CRM accounts as first-class results in our federated search, next step and status on the card.
- An LLM-queryable source: schema-aware SQL through SWIRL's MCP server.
- A natural-language admin loop for bulk operations, with SQL you can read before it runs.
Total schema: three tables, three views, zero ORM, and two canceled CRM subscriptions. Claude Cowork wrote the schema, the loaders, the provider, and the bug; the change report caught the bug; the humans just answered questions and edited cells.
Would this scale to a 50-seat sales team? No, and it is not trying to. For a founder-led pipeline of a hundred accounts, the boring stack is hard to beat: every piece is inspectable, every update is a SQL statement you can read, and the search box already knew where to look.
Top comments (1)
The composite-key bug has a clean general form worth carrying: whenever your loader's dictionary key is not literally the same tuple as the database's unique constraint, you have that bug already, you just have not met the row that proves it yet. Cheap habit is to derive the key from the constraint rather than from what feels natural in Python.
The one decision I would hedge is the free-form status. Append-only activities is exactly right, and free text is the same schema making the opposite bet. It holds fine while one person is typing, and the day a second person does you get dead, Dead and closed-lost coexisting, with every view that filters on status quietly drifting. Zero-cost hedge that keeps your flexibility: a view listing distinct statuses with counts. The morning it shows fourteen values where you expected five, the data tells you rather than a wrong follow-up list.