The problem#
Writing a good LinkedIn post takes more steps than it looks: checking the idea is worth posting, picking a format, writing a hook, designing a carousel. Generic AI writers skip most of them, and it shows.
The approach#
GhostWriterAI gives each step its own agent. Five agents (validator, strategist, writer, visual designer and optimizer) pass a typed state to one another on the Gemini API, and the app streams each agent's progress to the browser as it works.
- 39+ REST endpoints behind the web app: auth with email verification, brand profiles, post generation, history and analytics.
- 50+ active users and 200+ generations since launch, self-funded.
- Each user has a brand profile (voice, audience, content pillars, sample posts) that is fed to every agent.
- Structured prompting with Pydantic output schemas cut token use by 40%.
Architecture#
A React and Vite single-page app calls a FastAPI backend. Generation is a two-phase flow: the validator and strategist run first and stop with clarifying questions, then after the user answers, the writer, optional visual designer and optimizer run. Each phase starts as an asyncio task inside the API process, and its events go into a queue that the SSE response reads from.
PostgreSQL (async SQLAlchemy, Alembic migrations) stores users, brand profiles, posts and a full per-agent history, including every event the stream emitted. Verification emails go out over SMTP through Brevo.
Hover or tap any part to trace what it talks to. 19 parts, 26 connections. Swipe sideways to see it all.
- 1The browser POSTs an idea to /generate/stream with a JWT. The rate-limit middleware passes it and the route checks the user's daily post limit in PostgreSQL.
- 2The route loads the user's brand profile, starts the first phase as an asyncio task, and returns an SSE response tied to a fresh event queue.
- 3The validator runs on Gemini. A rejection ends the run with suggestions; otherwise the strategist picks a format and writes clarifying questions.
- 4Each agent start and finish passes through the execution tracker into the queue and is saved by the history service, so the UI shows progress live.
- 5The graph pauses. The user answers, and /answers/stream starts the second phase.
- 6The writer drafts, the visual designer runs only for carousels, and the optimizer scores the draft. A REVISE sends it back to the writer, at most twice.
- 7The final post, with hooks, hashtags and a quality score, streams back and is stored in PostgreSQL.
- 8Secondary path: on sign-up, the email service sends a verification link through Brevo SMTP in a threadpool.
The pipeline#
The graph is small on purpose. The validator can reject an idea and end the run. The strategist always pauses the graph so a human can answer questions. After writing, only carousels go through the visual agent, and the optimizer can send the draft back to the writer, at most twice.
def route_after_optimizer(state: AgentState) -> str:
"""Route based on optimizer decision."""
decision = state.get("optimizer_output", {}).get("decision", "APPROVE")
revision = state.get("revision_count", 0)
max_rev = state.get("max_revisions", 2)
if decision == "REVISE" and revision < max_rev:
state["revision_count"] = revision + 1
return "write"
return "finalize"Capping revisions keeps the loop from burning tokens chasing a perfect score. Every agent also sits behind a shared base class that times the call, retries on failure with a growing delay, and returns a structured result.
Shipping it#
Backend and frontend deploy separately, with a Dockerfile for the API and GitHub Actions workflows for tests and deploys. Alembic migrations track schema changes, and pytest covers agents, the database layer and an end-to-end generation flow.
What I learned#
Splitting one big prompt into narrow agents made each step easier to test and tune, but it also made state handling and streaming the hard part. Showing users what each agent is doing turned out to matter as much as the output.