Before AI, I was doing a lot of manual watching. And for a 2-person fund, things are bound to slip through the cracks — a deal that didn't get followed up, a call recap that never made it into the CRM, an inbound email that sat there because nobody noticed it. Not because we were bad at ops. Just because there's only so much two people can catch.
Now with my BFF Claude Code, I turned it into a system.
Here's what I built, why I built it that way, and what broke before I got it right.
The setup
I run CRM and automation at a small VC firm. The CRM is Attio. Data flows in from everywhere — Gmail, Google Calendar, Fathom call recordings, Mixmax, Slack, Google Sheets someone on the team is maintaining by hand. None of these systems agree on what "a deal" is or when something happened. My job is to make them agree.
I patched this manually. It mostly worked. It also broke constantly in the worst possible way — the kind where the report still shows up in Slack, it's just empty, or wrong, and nobody notices for three days.
This spring I rewrote the whole thing as a long-running daemon. It's about 20,000 lines of TypeScript, runs on Railway, and handles the firm's daily operational loop end to end.
Why a daemon and not more crons
Cron is fine for actual repeating jobs. "Every morning at 6am, run this report." Great.
Cron is terrible for events. A Gmail thread arriving. A calendar event ending. A deal stage changing. To handle events with cron you end up polling — scan everything every 10 minutes, figure out what's new — and you spend most of your time reading data you already know about.
What pushed me over was Gmail. The team wanted a notification when an inbound founder email arrived. Polling Gmail every minute felt insane. Gmail has push notifications, but to receive them you need a webhook server that's always running. So I needed a daemon. And once you have a daemon, every other polling job just becomes "subscribe to a heartbeat" — almost free to run.
How it's structured
Three layers, each does one thing:
- Ingest — pulls or receives raw events from external systems. Normalizes everything into a small set of internal event types so the rest of the system doesn't have to care where data came from.
- Dispatch — given an event, decides which workflows should run. This is where most of the complexity lives and where most of my design work went.
- Workflows and actions — the actual business logic. A workflow runs automatically in response to an event. An action runs because a human asked for something. Both end up writing to Attio, posting to Slack, or sending mail.
Postgres holds the state. Having a real database meant I could ask questions like "show me every event from the last week that triggered a workflow but never completed" — which turned out to be a question I needed to ask basically twice a week.
The split that made everything simpler: workflows vs actions
This was the most useful design decision in the whole rewrite.
Workflows fire automatically. They answer "what should happen because this thing happened?"
- Meeting ended → enrich participants in Attio, attach the Fathom link, post a recap to Slack
- Founder emailed inbound → triage, add to pipeline if new, notify the team
- Deal moved to Reject → check if we owe them a response, queue one if so
Actions fire because a human asked. They answer "what should happen because someone wants it?"
- "Reject this person, they have no email" → mark the deal, write the reason, drop from outreach
- "Add this person to the Mixmax sequence" → resolve, validate, enqueue
They look similar on the surface so the temptation is to merge them. I tried that first. It was a mistake.
Workflows need to be idempotent — a webhook might fire twice for the same event and the system cannot break when that happens. Actions are one-shot — a human clicked once and they're waiting for a response.
Once I split them, both got simpler. The workflow registry handles idempotency keys. The action registry handles user-facing error messages. Neither has to care about the other's problems.
The AI triage layer: one handler, not the brain
The daemon classifies inbound emails using Claude — is this a founder pitch, a portfolio update, an LP request, a vendor, a newsletter? Claude writes the classification into the database. A deterministic dispatcher reads it and routes to the right workflow.
The key thing I got wrong early: I had Claude deciding which workflow to fire. That was bad in all the ways you'd expect. Non-deterministic, slow, expensive, impossible to debug.
The fix was simple: demote the LLM to one specific job. Classification of messy free text — that's a job for Claude. Routing based on a known label — that's a job for code.
Use the LLM where the input is fuzzy. Use code where the logic is clear. Mixing them gives you a system you can't reason about.
The three things that broke and taught me the most
1. Silent stuck events
When a workflow failed, it would log the error and move on. The next event for the same entity would also fail silently. After a week I noticed a deal that was supposed to be at "post-call" was still at "call scheduled" — the workflow had been failing every time because of a transient API error nobody caught.
More retries wouldn't have helped. They'd have hidden the problem longer. The fix was a health check script: "which events are in a state where a workflow should have run but didn't?" If anything shows up, something is wrong.
Every long-running daemon needs this query. Without it, you only find out about failures when a human notices the absence of something — the worst possible monitoring signal.
2. The freeze button
I shipped a workflow change on a Friday with a subtle bug. By Monday, a weekend's worth of events had run through buggy code and written wrong data to twenty deals. Reverting the code didn't fix the data. The cleanup took half a day.
I added two scripts: freeze and unfreeze. They flip one boolean in Postgres. Every workflow checks it first and exits early if it's set. Now if something looks wrong, I can stop everything in 5 seconds.
Not glamorous. Probably the most valuable 30 lines of code in the whole repo.
3. The error log as a weekly ritual
I have a helper that any workflow can call when something goes wrong. It logs the error, the workflow name, the event ID, and a note to a workflow_errors table. Once a week I read through it.
The value isn't debugging individual failures — logs handle that. It's seeing patterns. Two months of rows told me which external API was flaky, which Attio attributes the team kept editing in incompatible ways, and which workflows had assumptions that no longer matched how we actually worked. None of that was visible from individual log lines.
What I'd do differently
- Pick a real database on day one. I started with JSON files. Two months later I was migrating to Postgres with a thousand events to backfill. The complexity of a database is much smaller than the complexity of not having one.
- Write the introspection scripts before you need them. The health check scripts I built during incidents are now the most-used tools in the repo. If I'd written them first, I'd have caught the stuck-events bug a month earlier.
- Split workflows from actions from the start. The cost on day one is zero. The cost after the fact is a refactor.
- Don't run production on a personal cloud account. The daemon lived on my Railway account for months. Same architecture, but it should have been on a firm-owned account with proper access controls from the beginning.
- Treat the LLM as a function, not a manager. One specific judgment per call. Let code do the orchestration.
Closing
The best thing about this kind of system is that it's invisible when it's working. Nobody notices that every call gets a recap automatically, that every inbound email hits the right pipeline state, that every sequence write-back happens in minutes. They just notice they're not chasing those things anymore.
That's the goal. Felt as the absence of friction.
If you're building something similar — at a fund, an agency, a small ops team — this is the architecture I'd start with. It's not glamorous. It stays in sync.