Build log · One-person company · AI automation
Inbound leads answered in minutes instead of never. Negotiations, follow-ups, and payment chasing on autopilot, with a human approving every send. Plus the money the AI found that I didn't know I had.
I run ToolJunction, an AI discovery platform where founders and marketers find the right AI and SaaS products, and where the companies building those products compete to be seen. Visibility here is worth real money to them: a placement means showing up exactly where buyers are comparing tools, ranking in the searches that convert, and increasingly, being present in the sources that AI search engines and LLMs cite when someone asks "what's the best tool for X". So the demand side never sleeps: every month, more than 500 companies land in our inbox asking for placements, listings, sponsorships, and coverage.
Here's the uncomfortable part: that inbox is a real revenue stream, and the whole operation behind it is one person. Me. I build the platform, write the content, do the SEO, handle partnerships. Somewhere at the bottom of that list sits the inbox where the money actually arrives.
Every email in it is a deal, from $200 to even $2,000. And a deal dies quietly if nobody replies within a day or two. I was that nobody. Replying meant context-switching out of real work, remembering what I quoted someone three weeks ago, chasing people who said "sounds good" and vanished. When I finally exported six months of the mailbox and analyzed it, the damage was worse than I feared:
That's not an inbox problem. That's a hole in the boat.
First instinct: someone must have solved this. I looked at sales CRMs, email automation platforms, outreach tools. They all fell into the same two buckets:
Add the setup time and the learning curve, and the math was obvious: I'd spend a week configuring someone else's tool to do 60% of the job. Or I could build exactly the thing I needed. I'm one person; the tool has to fit ME.
So I built my own CRM with an AI brain. Python, SQLite, and a Claude subscription. Here's how, and here's everything that broke on the way.
If you've never touched email programmatically, this part is genuinely simpler than you think.
Your email doesn't live in your Gmail or Zoho app. It lives on a server, and the app is just a viewer. IMAP is the protocol every mail app uses to talk to that server, and nothing stops your own code from speaking it. You connect with a host, a login, and a password, and the server hands you numbered messages. Python ships with everything you need:
import imaplib
M = imaplib.IMAP4_SSL("imappro.zoho.in", 993) # your provider's IMAP host
M.login("contact@tooljunction.io", APP_PASSWORD) # an app password, not your real one
M.select("INBOX")
# every message that arrived after the last one I processed
typ, data = M.uid("search", None, "UID 1201:*")
# read one WITHOUT marking it as read
typ, raw = M.uid("fetch", data[0].split()[0], "(BODY.PEEK[])")
That's the whole trick. SMTP is the same idea for sending. Two details that matter in practice:
BODY.PEEK reads mail without marking it read. Your unread badges stay yours. This tiny flag is the difference between a system that observes your inbox and one that tramples it.Every email tool you've ever paid for is these two protocols plus opinions. Mine is too.
The way I think about it now: I hired a small team of AI agents, each with exactly one job. A drafter that reads whole conversations and writes the next reply. A deal-desk brain that decides what each inbound message needs. A follow-up worker that revives threads that went quiet. A stricter auditor that re-checks pipeline states and is forbidden to write anything. A sweeper that recovers whatever gets stuck. None of them is allowed to send an email on its own.
Mechanically, it's humble: a scheduled job (macOS launchd, every 5 minutes) runs one cycle: pull new mail, decide what each message needs, write drafts, sync what got sent. A small web dashboard shows the team everything and takes approvals. SQLite sits in the middle holding workflow state. That's the whole system: no framework, no queue, no ORM, ~2,900 lines of Python including the entire frontend.
The interesting decisions are in the middle layer.
Code decides everything with a dollar sign. The AI decides everything with nuance. Before any AI runs, plain Python drops mail that isn't addressed to the sales address, bins newsletters and bounces by header inspection, and force-escalates anything big: offers of $250 or more, bulk orders of 5+ posts. A language model can be argued out of a rule. An if statement cannot.
One AI judgment per conversation, not a pipeline of classifiers. Version 1 of this system (more on it below) classified the LAST message. The rebuilt brain gets the ENTIRE conversation transcript, my price list, my niche rules, and, critically, a block of facts computed by code:
FACTS (trust these over your own inference): We have replied 2 time(s) in this thread. The last message was from them, 0 day(s) ago.
Models read tone and intent well. They count turns and judge elapsed time badly. Handing over pre-computed facts killed an entire class of embarrassing drafts, like follow-ups to conversations we never actually replied to.
The model must answer in strict JSON, an action rather than prose:
{"action": "DRAFT", "category": "guest_post", "stage": "negotiation",
"subject": "Re: ...", "body": "...",
"reason": "they countered $105 on our $150 quote; counter once toward standard"}
That reason field gets stored on the thread and shown in the dashboard. Every draft is explainable after the fact. This mattered more than any prompt trick: it turned "the AI is being weird" into an audit trail I can read.
Any failure falls to a human, never to a template. Model timeout, garbage output, unparseable JSON: the mail lands in a review queue for me. The worst-case output of this system is a human reading an email. There is no path where a bad AI reply goes out on its own.
And the AI runs on a CLI, not an API key. The drafting brain is the claude command-line tool in headless mode, authenticated by the same login as my terminal:
r = subprocess.run(
[cli, "-p", prompt, "--output-format", "json", "--model", model],
capture_output=True, text=True, timeout=180)
One subprocess per decision, capped at 20 calls per 5-minute cycle, overflow deferred to the next cycle. No key management, no separate billing, and upgrading the model is a config string.
I have to confess what came before the AI brain, because the failure shaped everything.
The first engine was keywords and templates: see "guest post" and "price", send the rate card. It demoed great. Then I read what it prepared for real threads and rated it 5 out of 100:
The lesson: understanding a deal means reading the whole conversation, not pattern-matching the last message. That's exactly the thing LLMs are good at and regex is hopeless at. Version 2 exists because version 1 was confidently, fluently wrong.
Every scary bug in this project failed SILENTLY. Three examples, with the fixes, so you don't repeat them.
1. The sync that went blind when I read my own mail. The first version asked IMAP for UNSEEN (unread) messages. Then I read a few emails on my phone, and the agent never saw them, because they were no longer unread. The dashboard sat 12 hours stale while looking perfectly healthy. Fix: remember the highest message UID you've processed and ask for UID SEARCH last+1:* instead. Read status becomes irrelevant. Advance that watermark inside the same database transaction that stores the message, so a crash can never skip mail.
2. The alias that was secretly a whole different mailbox. The system ran for half a day connected to my personal mailbox, believing contact@ was an alias of it. It was actually a separate Zoho user with its own inbox, its own IMAP toggle, its own app password. My test mail "disappeared"; every instinct said the sync code was broken. The code was perfect. It was reading the wrong mailbox. Check your connection-level assumptions before debugging your logic: one test-mail round trip would have saved me hours.
3. Every sent email existed twice. When you approve a draft, the CRM records it and sends via SMTP. Zoho then saves its own copy to the Sent folder, which my next sync cycle happily imported as a SECOND copy of the same reply. The thread looked like we were pestering buyers twice. The dedupe compares whitespace-normalized body prefixes within a 2-day window, because the mirrored copy doesn't reliably carry the same Message-ID.
Nothing sends on its own. Every draft waits in a mail-client-style dashboard where my (now 3-person) team reads, edits if needed, and clicks Approve:
The decisions that turned out to be load-bearing:
In-Reply-To and References headers from the stored Message-ID). A reply that starts a new thread screams "bot".UPDATE messages SET status='sending' WHERE id=? AND status IN (...): whoever gets rowcount 1 sends, the other click is told it's already handled.Killing version 1 left a mess behind: 531 threads stuck in a "needs human" state the old engine had dumped them into, with reasons like "could not classify intent". The new drafter correctly refuses to touch escalated threads, so these were invisible to automation. Forever. A graveyard with deals buried in it.
So I wrote a second, stricter prompt: a classifier that is FORBIDDEN to write replies. It only assigns the true pipeline state, and it must cite its evidence:
- AWAITING_PAYMENT: BOTH sides explicitly agreed a specific deal (price + what's being bought), and payment is genuinely the next step. Merely MENTIONING prices, rates, budgets, or invoices is NOT enough.
It re-read all 531 conversations overnight. What it found is the single best argument for the whole project, and it's in the results section below.
The audit also surfaced two infrastructure bugs worth stealing the fixes for:
PRAGMA journal_mode=WAL plus timeout=30 on every connection. Gone.re.search(r"\{.*\}", raw, re.S) works until the model returns TWO JSON objects, and greedy matches from the first { to the last }. Walk the string with json.JSONDecoder().raw_decode and take the first valid object instead. If you parse LLM output with a regex, you have this bug; you just haven't logged it yet.The dashboard has a /logs page: a live, color-coded tail of the agent's actual log file. I built it because sync chatter was cluttering the header. The very night it shipped, while taking a screenshot for this post, I saw this:
The follow-up worker escalated a thread, recorded the escalation, but never marked the THREAD as escalated, so it re-picked the same thread every cycle. New AI call, new escalation row, new desktop notification, forever. One sender had accumulated 56 duplicate escalations.
Two invariants fixed it for good: escalating a thread must move it out of the worker's selection set, and the escalate function itself now refuses to open a second escalation for a sender who already has one open. Belt and suspenders, because the next caller with this bug is already being written.
The same screenshot exposed a second infinite loop: the Sent-folder sync skipped self-addressed mail BEFORE advancing its position marker, so one test email I'd sent to myself was re-fetched every cycle until the end of time. The general rule: in watermark loops, advance the watermark for every item you VISIT, not every item you process. Skip paths are where "forever" bugs live.
The system has a full-autonomy mode built in: drafts auto-send after a 30-minute grace period unless a human discards them. It is tested and OFF. The plan is stages: weeks of every draft human-approved (the send lock is a config interlock the UI cannot override), then auto-send for routine first-touch quotes only, then wider. An AI that emails real buyers about real money earns autonomy gradually. And the stage where a human reads everything is exactly where all six bugs above got caught.
The audit alone surfaced over $1,000 of real money from threads everyone had written off: buyers who wrote "the invoice has been paid" into the void, published articles never paid for. Every pipeline number on the dashboard is now either set by the AI with a cited reason, audited by it, or set by hand. I trust my own CRM, which is a strange sentence to be able to write.
reason field, and store the reasons. Explainability beats prompt engineering.PEEK over politeness. Advance on visit, not on success.All screenshots are from the production system; buyer identities are pseudonymized. Every outbound email is still human-approved.