A Custom CRM for an Engineering Firm
Overview
Our partner is an engineering firm that does residential and commercial projects. We had already moved them off inboxes and spreadsheets onto a self-hosted Twenty CRM, with n8n workflows, a Slack bot, and small services for documents and file capture. That first system is written up in Open Source Ops Stack for an Engineering Firm.
Twenty was the right starting point. It gave the team one record for a project, a partner, and a file. It stopped being the right system once the work itself had rules: a proposal cannot start without a fee and a scope, an invoice cannot generate if none exists, a site visit needs a calendar invite, a finished job needs a follow-up that a person still approves. Those rules lived in n8n graphs next to the CRM. Every new step was another workflow to keep in sync, and the team still did most of the job from Slack.
We replaced that stack with a CRM built for the firm. The app owns the project, the stage, the document, and the notification. They still host it.
Where Twenty stopped fitting
- Twenty could store projects, contacts, files, and invoices. It did not know what Proposal In Progress, Drawing for Review, or Report Reviewed - Revise meant for this office.
- A stage change in the CRM did not, by itself, draft a proposal, ping the review channel, or refuse to invoice a job with no invoice on it. That logic sat in n8n.
- The Slack bot was easy to adopt, and it hid the record. Engineers could ask for a status. They could not see the pipeline, the files, and the open invoice in one place.
- Time-in-stage, follow-ups, and permissions meant querying Twenty's workspace schema and stitching workflow logs.
- Document rendering, file capture, and intent parsing were separate services. Each one was reasonable. Together they were a second product to operate.
What we built
A single app: a NestJS API, a React front end, Postgres, and Redis. Document generation, audio transcription, and visit summaries run as BullMQ jobs. Auth, organizations, and invites use Better Auth. Files live on a Docker volume and are downloaded only through authenticated routes. The stack deploys with Docker Compose on a VM the firm controls.
Contacts were imported from the old Twenty Postgres schema, so the team did not retype the address book. Project managers are people records. A generated proposal pulls that person's name, email, and cell phone instead of free text typed onto the job.
Spec-driven development, with AI
We used AI to build this faster. The method was spec-driven development: each piece of behavior was written down before any code, then implemented against that spec.
A stage change, a document job, a secure share, or a site-visit summary started as a short spec. It named the trigger, what the system must do, and what it must not do. An invoice cannot generate with no invoice on the project. A completion email is drafted, and it is not sent until a person clicks Send. A stage change from the in-app agent has to fire the same jobs as a click in the UI. Coding agents implemented against those specs. We reviewed the result and kept only what matched.
That is how a small team shipped the pipeline, the field tool, client follow-up, and the agent without standing up another workflow layer beside the CRM. AI wrote a large share of the code. The specs, and the review, stayed with us.
The pipeline is the product
Projects move through the stages the firm already uses: Inquiry, Proposal In Progress, Proposal For Review, Proposal Sent, Awaiting Client Decision, Accepted / Preparing Delivery, Site Visit, Drawing for Review, Report For Review, Report Reviewed and Signed, Report Reviewed - Revise, Delivery Sent, Invoicing, Payment Pending, and Complete, plus Hold and Closed - Lost.
Every move is stored. The app closes the open time window for the current stage and opens one for the next. If a report goes back for revision and returns to review, that second visit counts on its own. From that history the firm can see how long completed jobs spent in each stage, which stages are the slow ones, and what is sitting in a stage right now.
The transition does the work that used to be an n8n workflow:
- Proposal In Progress requires a proposal amount, a deadline, and a scope before the stage can change. Maple AI writes the structured sections (executive summary, scope of work, schedule, fee, assumptions). A template service renders the file and attaches it to the project. Slack posts when generation starts, when the file is ready, and if retries are exhausted.
- Change orders bundle the original proposal, the added scope, and the price change into one document, generated from the project and saved with its files.
- Site Visit posts a Slack card with the date, company, address, and project manager, and emails a calendar invite with accept and decline when notify addresses are set. If an office address is configured, the UI shows driving time from the office to the site before the team confirms.
- Proposal For Review, Report For Review, and Drawing for Review post to the review Slack channel with a link back to the project.
- Invoicing is blocked until the project has an active invoice. The job creates a Stripe payment link, embeds it in the document, and posts the payment QR to Slack. Staff can regenerate from the latest invoice without leaving the stage.
- Complete, the first time only, creates a completion email draft and a referral link. Nothing is sent until someone reviews the draft and clicks Send.
Failed document jobs retry with backoff. After the last failure, Slack includes the reason and a link to retry from the project. User-triggered regenerations always run again. Automatic jobs do not pile up as duplicates.
Plans, with Claude and pyRevit
The structural drawings still happen in Revit. The slow part was the repeat work of getting a plan set out: sheets, views, annotations, and the details that show up on both residential and commercial jobs. We use Claude with pyRevit to speed that up. Claude drafts and revises the Python tools. pyRevit runs them inside Revit on the model. Engineers review the drawing before it goes out. Finished plans are filed on the project in the CRM with the rest of the documents.
Field notes from the visit
Engineers needed a tool for the site, not only a record they updated back at the office. The Site Visit tool attaches photos and voice notes to a visit. Audio goes to a self-hosted Whisper server in Docker, with no external speech API. Transcripts come back with timestamps the player can seek to. Generate Summary sends captions and transcripts to Maple AI and returns a short headline plus key points. Each point links to the photo or the moment in the recording it came from.
Client email, after a person checks it
Customer engagement covers the follow-up Twenty left as a manual email:
- Completion outreach, including the firm's Google review link when one is set in team settings. Staff edit the draft, see an email preview, type a recipient, and send through Resend.
- A public referral form on a per-project link. Submissions land for review. Turning one into a project is a separate step. Regenerating the link invalidates the old one.
- Invoice follow-ups. The draft starts with the invoice number, balance, and due date when a current invoice exists. Staff attach a file, pick or type a recipient, and send.
Secure shares cover drawings and reports. Staff set a password, an expiry, and a download limit, and the CRM emails the link. The recipient confirms the address the link was sent to, then the password. Expired, revoked, and fully downloaded links are rejected. The download URL itself expires after 15 minutes. Shares can be created from the file manager or from the secure shares list.
An assistant on the same rules as the screen
The old bot asked a model to guess intent, then n8n called Twenty. The new agent sits inside the CRM and calls the same services the UI uses, through an in-process tool bridge. It can look up projects, tasks, companies, people, notes, the calendar, stage durations, support stats, and the in-app automation docs. It can create a project and update one. A stage change from chat fires the same Slack, email, and document jobs as a click in the app. The organization always comes from the signed-in session. If a name matches more than one record, it asks which one. It does not invent a project number, a fee, or a contact.
The rest of the office
- Companies, people, invoices, notes, and a file manager with folders, preview, and bulk download.
- Tasks assigned to team members, with due dates on a calendar next to site visits and invoice dues. An @mention in a task sends a Slack DM.
- Support tickets that post to Slack when opened, moved to in progress, or closed.
- An activity log, module permissions, and a dashboard for what is due and what is in review.
- In-app documentation of every automation, which the agent reads when someone asks what a stage change will do.
What changed for the firm
- One system to run. Postgres, Redis, the API, the web app, Whisper, and document rendering come up from Compose. There is no n8n graph to keep aligned with the CRM.
- The pipeline enforces the work. Missing proposal fields or a missing invoice stop the transition before a bad document goes out.
- Proposals, change orders, invoices, review pings, and site-visit invites happen because the project moved, and the files stay on the project.
- Plan sets come together faster. Claude writes the pyRevit tools, pyRevit runs them in Revit, and an engineer still checks the drawing.
- Site notes are transcripts and summaries tied to the visit, not a voice memo on someone's phone.
- Client email goes out only after a person reviews the draft. File shares are passworded, expiring, and revocable.
- Contacts came over from Twenty, and the firm still hosts the data.
Tech stack
- React, Vite, Tailwind: web app
- NestJS: API and stage side effects
- Postgres and Drizzle: CRM data, including stage history
- Redis, BullMQ, Bull Board: proposals, invoices, receipts, change orders, transcription, summaries
- Better Auth: sessions, organizations, invites
- Resend: invites, site-visit calendar mail, outreach, follow-ups, secure shares
- Slack: generation, review, site visit, and support notifications
- Stripe: invoice payment links
- AI, spec-driven development: behavior written as specs first, then implemented with coding agents and reviewed before it shipped
- Claude and pyRevit: plan creation inside Revit
- Maple AI: proposal sections, site-visit summaries, in-app agent
- Self-hosted Whisper: site-visit audio
- Template service: proposal, invoice, receipt, and change-order files
- Docker Compose: production on a Linux VM
Why the custom CRM
Twenty got the firm to a single record and got proposals and invoices out of blank documents. The custom CRM is the version that knows their stages, their reviews, and their site visits, and runs those steps inside the product. Claude and pyRevit take the repeat work out of plan sets. The team spends its time on the engineering. The pipeline handles the paperwork, the pings, and the follow-up draft, and a person still decides what gets sent to a client.