Yesterday's piece prescribed building a personal AI stack instead of waiting for the enterprise plan. The natural objection — "fine, but what does that actually look like" — deserves a concrete answer. So here is mine, opened up.
This stack ships a five-surface content pipeline daily, on cron, with file-based memory, lint enforcement, and a queue-driven publish runner. None of it is exotic. All of it is small enough that one operator built it on evenings, and nothing in it depends on anyone else's roadmap.
The Directory Map
The whole thing lives in three top-level directories.
~/services-local/content-engine/ holds the active runtime — TypeScript ESM under src/, scripts under scripts/, drafts under drafts/, the SQLite DB at data/content.db, the LaunchAgent log paths under logs/. About 4,000 lines of TypeScript across roughly fifteen source files. Nothing in here is a framework. Each file does one thing.
~/.claude/ holds the Claude Code configuration that drives my interactive sessions — slash commands under commands/, hooks under hooks/, the keybindings file, the settings layers (global, project, local). The hooks are how I encode my own non-negotiables. The commands are how I encode the workflows I run every week.
~/nexus/ holds the agent context files and the memory index. MEMORY.md is a one-line-per-entry index that gets loaded into every Claude Code session via the auto-memory mechanism. The actual memory entries live next to it as one file each — feedback_*.md for behavior rules, project_*.md for ongoing work context, user_*.md for personal preferences, reference_*.md for pointers to external systems. Filesystem-backed, append-only, indexed, survives any model deprecation.
The Pipeline
Four cron jobs do the real work, scheduled via macOS LaunchAgents under ~/Library/LaunchAgents/ai.nexus.content-*.plist.
trend-scanat 7 AM PT pulls topics from TechMeme RSS, Hacker News Algolia, fifteen Reddit subreddits, and Firecrawl search queries. About 45 new topic rows land intopicseach morning, scored on a relevance weight, status set toproposed.digestat 9 AM PT, weekdays posts the top-scoring topics into a Slack channel with Approve/Reject/Tier buttons. I either approve a topic or reply with a URL of my own.draftat 10 AM PT picks up approved topics, runs Firecrawl research to pull at least three sources, generates a draft via the writer module (Sonnet for T1/T2, Opus for T3), runs lint, posts the draft into Slack with Approve/Edit/Reject buttons.publishat 11 AM PT picks up approved drafts and ships them throughpublisher.tsto Ghost (T3 blog) → Dev.to (cross-post with canonical URL from Ghost) → LinkedIn and X via Late.dev → Instagram carousel via Satori-rendered slide PNGs.
The schema is four tables: topics, content, research, publications. Status fields drive the state machine: topics flow proposed → approved → drafted → archived; content flows draft → lint_passed → pending_review → approved → published. Publications get a row per successful platform delivery with the external ID and external URL for later reconciliation.
The Manual Pattern That Coexists
The AI-driven pipeline above ships when I let it. Most days I write the piece by hand instead, in a drafts/*.md file under a structured header pattern — one second-level heading per surface (long-form blog body, LinkedIn body, X body, Instagram caption, hashtag lists, slide carousel JSON), parsed at publish time by the same script that runs the platform fanout.
Each manual draft gets a matching scripts/publish-<slug>.ts script that requires an explicit --ship flag — bare invocation exits without publishing — parses the draft into surface-specific content rows, calls the same publisher.ts functions the cron uses, and writes status updates back to the DB. Same five-surface fanout. Same lint records. Same publications rows. The difference is that the writing is mine line-by-line instead of generated.
Both paths converge at the publisher layer. The AI pipeline and the manual pattern are two front ends to the same back end.
The Lint Layer
src/lint.ts enforces voice. Roughly fifty banned words from my voice guide — the usual marketing-prose tells, the kind a reader recognizes on sight. Fifteen banned phrases. Word-count ranges per tier (T1: 50–200, T2: 150–600, T3: 600–2000). No question openers. No generic "state of the industry" openers. Concrete-example heuristic for T2+. Inline citation count minimum for T3 — at least three markdown hyperlinks.
The lint is the line that catches drift. It catches the banned word I almost shipped yesterday — the wrapper-pattern post originally used a different word in the backlink that lint refused, prompting me to rename and re-link without breaking the citation. It catches negative-parallelism title patterns I trained myself to write before I had banned them.
The taste lives in the lint file. Anyone reading it can see what I will not ship.
The Memory Loop
MEMORY.md is loaded into every Claude Code session at session start. It is an index, not a memory — one line per entry, each pointing to a separate *.md file in the same directory. The actual memories are typed: feedback_* for behavior rules, project_* for context that decays, user_* for stable preferences, reference_* for pointers to external systems.
This is the wrapper-pattern argument from May 3 in working form. Vendor memory is not durable across providers or model deprecations. Files are. Every memory in this system survives Claude version changes, model deprecations, and provider switches. The only operation that ends a memory is me deleting the file.
The Queue and the Wrapper Layer
A queue file at queue/posts-queue.json lists pre-drafted pieces with target dates and the publish script for each. A runner script reads the queue at noon PT daily, picks today's pending entry, executes its publish script with --ship, marks it shipped on success or leaves it pending with a logged error on failure. This was yesterday's compose-the-stack argument in working form — Claude Code as the writing worker, a hand-rolled cron-driven orchestrator as the durable runtime.
The whole orchestrator is about 90 lines of TypeScript. It does not need to be more.
What This Stack Does Not Do
It does not optimize for anyone but me. It does not have a UI. It does not have a settings page. It does not scale to a team of fifty without rewrites. It does not handle multi-tenant. It does not have a billing layer. None of those features would improve my daily ship rate. All of them would slow me down.
The point of a personal stack is that the operator and the user are the same person. The constraints that drive enterprise product complexity — onboarding, support, multi-tenancy, role-based access — disappear. What is left is the substrate, the pipeline, and the taste.
The Replication Cost
Most of what is in here is replicable in a weekend.
Skills, hooks, slash commands, and MCP servers ship with Claude Code. The publisher layer is platform SDKs wrapped in 488 lines of TypeScript. The lint layer is regex matching plus a banned-word list. The memory layer is a directory of markdown files and a one-line index. The queue runner is ninety lines.
The reason most engineers do not have a stack like this is not technical difficulty. It is the absence of a forcing function. Daily shipping is the forcing function. Once you commit to publishing every day, you find out within a week which parts of the workflow are friction and which parts are taste. The friction gets automated. The taste gets encoded in lint. What remains is the writing.
That is the stack. The components are boring. The discipline of having them all wired together is the asset.
Top comments (0)