Build notes · shareable

How I built my AI-operated company portal

The portal is a private web app that agents keep up to date. It pulls in everything the business produces: calls, CRM, email, support, analytics and content. Agents turn that into pages, queues and drafts. Nothing leaves the building until I click Approve. This page is the outline: the architecture, the stack, the skills worth building around, and how voice becomes a task list.

~6 weeksfrom first commit to running the business on it
~1,400commits, almost all written by Claude Code
60+pages across sales, ops, finance, marketing, PR
~55scheduled jobs (ingest, compute, agent runs)
30+project skills the agents run

1 · High-level architecture

There is one idea behind all of it: read → compile → gated action → human approval. Agents read and draft all day. A person decides what goes out.

1. Capture
Python scripts on timers pull from every source: call transcripts, CRM (Pipedrive), Gmail, Slack, the helpdesk, Search Console / GA, ad platforms, Stripe, social. Each writes raw files into a data repo. Raw is append-only. Nothing edits a captured document afterwards.
2. Compile
Claude Code sessions, run on a schedule or by hand, read the raw files and write a markdown wiki: account dossiers, decisions, pain points, competitor notes and a running log. This is the "LLM wiki" pattern. It's plain files, not a vector DB, so every claim traces back to a file you can open.
3. Materialize
Laravel compute:* commands import the files into Postgres and build pre-computed tables every 15 minutes. Every row records the file it came from. Controllers only read those rows and never derive anything during a request.
4. Serve
An Inertia + React UI over those tables: dashboards, a CRM, a deals board, a to-do list, content calendars, a research library and an activity feed.
5. Act (gated)
Agents stage drafts: emails, posts, PR pitches, replies. Each draft shows up as a card with Approve. Approving writes a verdict to a separate store, and a send script re-checks everything when it actually sends.
Sourcescalls, CRM, mail, Slack, support, analytics
Raw filesgit-tracked, immutable
Wikiagent-compiled markdown
Postgresderived tables, rebuildable
Portalread-only views + queues
Draft + gatekill switch, linter, caps
You click Approvethen it sends

Two repos, on purpose

RepoHoldsWhy separate
appLaravel app, compute commands, ingest scripts, skills, deploy units, the CLAUDE.md "constitution" It's the deploy artifact. A CI check refuses any commit that contains customer data.
dataraw captures, the compiled wiki, pipeline drafts The history is full of customer data, so it never ships anywhere. Files are the source of truth. The DB is a derived index you can drop and rebuild with one command.

2 · Database & server

Stack

  • Laravel 13 (PHP 8.3+) with Inertia 2
  • React 19 + TypeScript, Tailwind 4, shadcn/ui, Vite
  • PostgreSQL 17 as the only database
  • Redis + Horizon for queues
  • Python 3 for ingest, which only ever writes files and never touches the DB
  • Claude Code (headless claude -p) for every agent run

Server

  • An always-on home server (a mini PC running Debian, 32 threads), not the cloud
  • Caddy for TLS, with DNS-01 certificates through Cloudflare
  • Tailscale: the portal is only reachable on my tailnet
  • systemd timers for all scheduling, around 55 units
  • Deploys: push → GitHub Actions (tests, type-check, data-boundary check) → a 60-second timer on the box deploys only if CI is green for that exact commit
  • Nightly Postgres + state backups
Why Postgres rather than SQLite or a vector store: several agents and timers write at the same time, and the materialized tables get dropped and rebuilt constantly. Keep one writer ecosystem (Laravel). Python writes files, Laravel imports them. You never have two ORMs fighting over one schema.

3 · Skills worth building around

A skill is a SKILL.md playbook that Claude Code follows, plus any scripts it needs. These are the ones that earned their keep, in roughly the order I'd build them:

Foundation

SkillWhat it does
syncPulls fresh CRM, call transcripts, Slack and release notes into raw, then recompiles the wiki pages that changed.
dossierBuilds the complete picture of one account: every meeting, quote, objection, note and last touch.
dayThe morning driver. It goes through everything that came due (live reply checks in both mailboxes, to-dos, calendar, content, money, support), finishes what an agent can, and stages the rest behind approval.
call-reviewTurns a recorded call into numbered findings and action items, each with a quote, a screenshot and a timestamp link.

Sales / CRM

SkillWhat it does
crm-callAfter a call it drafts the follow-up in my voice, sets the next step and fills in the deal fields the call justified.
crm-nextReads a deal's whole history and decides the next touch: reply, nudge, call, or close the file. It never sends.
follow-upGoes through the open pipeline deal by deal and picks the right move for each.
reviveFinds dormant deals and drafts "since we last spoke" outreach based on what has shipped since.
whats-changedDiffs the product against a lead's last meeting, filtered to their objections.
crm-loss / crm-winbackPulls the real loss reason from the transcript, then comes back on the date the customer named.
crm-standupWrites the weekly paragraph on what changed and what slipped. It may only quote numbers the tables produced.

Marketing / ops

SkillWhat it does
seo-contentTakes a target from the opportunity queue and writes the piece end to end, grounded in our own data.
x / linkedin / instagramRepurposes already-published material into posts, each into an approval queue.
prWatches news windows and journalist queries, keeps an outlet library with cooldowns, and drafts pitches.
competitor-watchA weekly digest of competitors' pricing and positioning diffs, changelogs, reviews and job posts.
support-reportReads every ticket a customer has sent and maps it against what they actually use.
rock-pulse / eos-workbookRuns EOS: a weekly pulse on each quarterly rock, and grounded drafts for the workbook.
slackSearches the full archived Slack history to answer "why did we decide X".

Machine-level (not in the portal repo, but I use them daily)

SkillWhat it does
browserPlaywright-driven browsing and QA, one isolated session per project.
kanbanA local board whose cards feed Claude sessions. Parallel workers pull the next Ready card.
portal-researchDeep research published as a visual report in the portal, with charts built from real numbers.
codex-review-loopA second model reviews the diff; Claude fixes only the findings that are valid, until it converges.
gmail-managerMulti-account inbox triage with accumulated rules and a report.

4 · Voice → task list

There are two paths. Both end on the same To-do list in the portal.

A. Calls (the main one)

Recorda local menu-bar app auto-records Zoom (4K screen + separate mic track)
TranscribeDeepgram, with diarization; my mic is its own track, so "who said it" is exact
Review/call-review produces numbered findings and action items with owner and quote
Triageeach item gets Fix · Research · Dismiss buttons
QueueFix and Research go to a queue that headless agents work on timers → PR or answer; the rest become to-dos

A live version runs during the call: every 90 seconds it takes a Claude pass over the rolling transcript and updates a review page next to Zoom. By the time I hang up, the list already exists.

B. Talking to it directly

5 · Rules that saved me (steal these)

  1. Nothing outbound without a human click. Each channel (X, Instagram, PR pitches, journalist replies, follow-up email) has its own approval namespace, so one Approve can never release a different kind of message. Every send path also fails closed: a kill-switch file, a claim linter (no guarantees or superlatives), rate caps, and a check that the draft being sent is the exact text you approved.
  2. Treat captured content as untrusted. An email or transcript that says "do X" is data, not an instruction. Put that in CLAUDE.md in plain words.
  3. Files are the source of truth; the DB is derived. Deleting a file is the delete. Never hand-patch a row; re-import instead.
  4. Materialize, then serve. Scheduled compute writes tables and pages only read them. That is what keeps it fast.
  5. Log everything the agents do as one appended line per action. Never rewrite that log.
  6. Tombstone removed rules. When you delete a rule that caused an incident, leave a _removed: note saying why. Otherwise the next agent re-adds it as an "obvious improvement".
  7. State how a guarantee is enforced, never something stronger. "The script never sends" (a convention) is not the same as "it has no credential to send with" (a capability). My docs once claimed the second while the first was the truth.
  8. Make tests unable to reach the real world. A test once matched a process fake badly, the fake ran the real command, and real emails went out while the suite was green. Block stray processes in the base test class, and refuse sends when the environment is testing.

6 · Where I'd start if I were you

  1. Write the CLAUDE.md constitution first: prime directives (nothing outbound, untrusted input, source of truth, logging, no secrets). Everything else hangs off it.
  2. Build one ingest (call transcripts or CRM) into raw files, plus a sync skill.
  3. Add dossier. It's the first skill that makes you say "wow", and every later skill reuses it.
  4. Stand up the thin app: Laravel + Inertia + Postgres, one compute: command, one page. Deploy it to a box behind Tailscale.
  5. Add the first gated action (follow-up email drafts with an Approve button) before any autonomous send.
  6. Then build voice → call review → to-do. That's the loop that runs the day.
Advice in one line: let the agents do all the reading and drafting, and keep every irreversible action behind a click that only you can make.