Poindexter Architecture
Last Updated: 2026-06-13 Version: 0.1.x (alpha) Status: Production-ready on the authorβs daily-driver setup. Public alpha.This document is the mastery-grade reference for how Poindexter is built. It is intentionally long. For running the stack locally right now, see ../operations/local-development-setup.md.
Quick links
- Purpose β what Poindexter does and, more importantly, what it doesnβt
- System architecture β high-level overview
- Technology stack β tools and platforms
- Component design β each subsystem explained
- Data architecture β database and storage
Purpose
Poindexter is an open-source AI content pipeline that researches, writes, reviews, and publishes blog content autonomously, with a human-in-the-loop approval queue. One operator, one machine. The target user is a small business owner or solo creator who wants the speed of AI (several posts per day) but refuses to publish AI slop that hurts their brand. The product is quality automated content with human oversight, not βAI content factoryβ and not βheadless CMS with AI.βNon-goals
- Not a hosted SaaS. Poindexter runs on your machine. There is no managed tier (yet).
- Not an βAI co-founderβ or general business agent. It does one thing β blog content β and does it well.
- Not multi-tenant. One operator, one site. Multi-site is possible via config rows but is not a supported deployment model.
- Not a CMS. Poindexter pushes static JSON to any S3-compatible storage; the frontend can be anything.
Architecture principles
- Local-first. Ollama inference on your GPU. Paid cloud APIs are a fallback, not the default.
- PostgreSQL as the spinal cord. All services communicate through shared DB tables, not imports or queues.
- Config in the database, not env vars. The
app_settingstable replaces most environment variables. Change settings with SQL, no redeploy. - Push-only output. Poindexter emits static JSON, RSS, and JSON Feed to S3-compatible storage. It does not serve pages.
- Three layers of anti-hallucination. Prompts tell the LLM not to fabricate, a cross-model critic reviews the output, and a deterministic Python validator catches fake people, stats, and quotes that slipped through.
- Human approval queue. Every post is reviewed before it goes live, unless it scored above the auto-publish threshold AND all gates passed.
- Self-healing. The brain daemon monitors every service, restarts failures, and alerts on regressions.
ποΈ System Architecture
High-Level Overview
Backend: FastAPI worker (port 8002)
The worker is a FastAPI service that handles all asynchronous task execution and multi-agent orchestration. Architecture conventions:- Unified task API. All task creation flows through
POST /api/taskswith atask_typediscriminator. - Async DB driver. The worker uses asyncpg directly β no SQLAlchemy ORM. Pool lifecycle is managed by the FastAPI lifespan.
- Prefect dispatch.
services/flows/content_generation.pyis the sole task dispatch path as of 2026-05-16 (Stage 4 of the Prefect cutover deleted the legacytask_executor.py). The flow claims pendingpipeline_tasksrows viaSELECT ... FOR UPDATE SKIP LOCKEDand hands them tocontent_router_service. Retry / heartbeat / stale-run sweep are Prefect-native; operator UI at http://localhost:4200.
Data Architecture
- Primary DB: PostgreSQL 16 with pgvector extension
- Driver:
asyncpg(Full Async) - Schema Management: Managed via
DatabaseServicedelegates (TasksDatabase,UsersDatabase, etc.).
Request Flow
π§ Technology Stack
Frontend Architecture
| Component | Technology | Port | Status |
|---|---|---|---|
| Public Site | Next.js 16 + React 19 + Tailwind | 3000 | β Production |
- Server-side rendering (SSR) and static generation (SSG)
- Responsive design with Tailwind CSS
- Component-based architecture
- RESTful API integration
- SEO optimization with sitemap and structured data
Backend Architecture
| Component | Technology | Port | Status |
|---|---|---|---|
| Poindexter Worker | FastAPI + Python 3.13 + Uvicorn | 8002 | β Production |
| CMS Data | PostgreSQL (Direct Access) | 5433 | β Production |
- RESTful API (~70 endpoints across tasks, posts, media, memory, pipeline, analytics, webhooks)
- WebSocket support (planned)
- LangGraph-orchestrated pipeline β
canonical_bloggraph_def (43 nodes, seeded into thepipeline_templatestable fromservices/canonical_blog_spec.py), dispatched by Prefect viaservices/flows/content_generation.py. - LLM router via LiteLLM (
services/llm_providers/litellm_provider.py) β primary on prod for all 5 cost tiers (plugin.llm_provider.primary.{free,budget,standard,premium,flagship}='litellm') as of 2026-05-16. Provider routing, cost tracking, and retries all delegated to mature OSS. Paid-vendor model prefixes (openai/,anthropic/,gemini/, β¦) refuse to dispatch unlessplugin.llm_provider.litellm.allow_paid_base_url=true(cycle-5 #251, 2026-05-27). - Semantic memory via pgvector (writer-segregated)
- Async task processing with atomic task-claim via
SELECT ... FOR UPDATE SKIP LOCKED - Domain-typed errors via
services/error_handler.py - Structured logging via
structlogand audit-log sidecar
Infrastructure & Services
| Service | Provider/Tech | Purpose | Status |
|---|---|---|---|
| Database | PostgreSQL only (no SQLite fallback) | Content and operational data | β Active |
| Embeddings | pgvector (in PostgreSQL) | Semantic search and RAG | β Active |
| Storage | File system / Cloud Storage | Media files and assets | β Active |
| Task Queue | Prefect (services/flows/content_generation.py) | Async task processing | β Active |
| Deployment | Local docker-compose (backend) / Vercel (frontend) | Self-hosted on your machine | β Active |
| Monitoring | Grafana + Prometheus (self-hosted) | 13 dashboards, ~90 panels | β Active |
AI Model Providers (Ollama-only pipeline)
| Provider | Models (production) | Cost | Status |
|---|---|---|---|
| Ollama | gemma-4-31b (writer), glm-4.7 (reviser), qwen3:8b, gemma3:27b (critic) | Free (local, GPU electricity only) | β Primary |
| LiteLLM | Anthropic / OpenAI / Groq / Gemini / OpenRouter / Bedrock | Per-token, cloud rates | π‘ Opt-in β plugin.llm_provider.litellm.allow_paid_base_url=true + cost_guard caps |
| HuggingFace | transformers direct (emergency fallback) | Free (CPU) | π‘ Fallback |
pipeline_fallback_model (also Ollama, default gemma3:27b).
Cloud LLM providers (Anthropic, OpenAI, Groq, etc.) are available via the LiteLLM provider plugin β gated off by default (allow_paid_base_url=false) and bounded by cost_guard daily/monthly caps when enabled. Operators opt in per the feedback_no_paid_apis policy. Local Ollama is the default and zero-cost path.
Use cost tiers (free/budget/standard/premium/flagship) for model selection β never hardcode model names. Cost tiers live in app_settings and map to Ollama models at runtime.
π§© Component Design
1. Public Site (Next.js)
Location:web/public-site/
Purpose: Public-facing website showcasing content and brand
Key Features:
- Homepage with featured posts and content grid
- Individual post pages with full markdown rendering
- Category and tag-based content filtering
- SEO optimization with meta tags and Open Graph
- Newsletter signup integration
- Responsive design optimized for all devices
2. CMS Data Layer (PostgreSQL)
Location:src/cofounder_agent/routes/cms_routes.py
Purpose: Database-driven content management via FastAPI routes (No separate CMS service)
Data Models (PostgreSQL Tables):
-
Posts (
poststable)- title, slug, content (markdown/rich text)
- excerpt, featured image, cover image
- category (relation), tags (relation)
- author, published date
- SEO metadata (title, description, keywords)
- Status (draft, published, archived)
-
Categories (
categoriestable)- name, slug, description
- Featured image
- Posts relation
- Meta description
-
Tags (
tagstable)- name, slug, description
- Posts relation
- Color/icon (for UI)
-
Pages (
pagestable)- title, slug, content
- Featured image
- SEO metadata
- Visibility settings
-
Tasks (
taskstable)- Title, description, type
- Status (pending, in-progress, completed, failed)
- Assigned agents
- Created/updated timestamps
- Result data
3. Pipeline Templates + TemplateRunner
Location:src/cofounder_agent/services/template_runner.py, services/pipeline_templates/__init__.py, services/canonical_blog_spec.py; atom implementations live under modules/content/stages/ + modules/content/atoms/ (the legacy services/stages/ tree was removed when the content pipeline moved into the content module, Phase 3, 2026-06-04)
Purpose: Compose and run the content pipeline as a LangGraph state machine. The agents/ tree was deleted 2026-05-09 β there are no role-based βagentsβ anymore. LLM calls live inline in the stages that need them, dispatched via services/llm_providers/dispatcher.py (which routes to the LiteLLM provider on prod).
How a pipeline is defined:
A pipeline is a template β a LangGraph StateGraph plus a PipelineState TypedDict. As of atom-cutover #355 (2026-06-02) canonical_blog ships as a static graph_def row in the pipeline_templates table (authored in services/canonical_blog_spec.py, compiled by services/pipeline_architect.py::build_graph_from_spec), preferred by TemplateRunner.run when pipeline_use_graph_def=true (the prod default). dev_diary still ships in-tree as a Python factory in services/pipeline_templates/__init__.py β the only entry left in TEMPLATES after the hand-coded canonical_blog factory was deleted:
canonical_blogβ the 42-node default for blog posts (services/canonical_blog_spec.pyis the authoritative node list; 11stage.*+ 13content.*+ 13qa.*+ 1qa.rewrite+ 2atoms.approval_gate+ 1seo.*+ 1social.generate_drafts). Six linear blocks plus a bounded QA rescue cycle: verify β writer (generate_draft β generate_title β check_title_originality β normalize_draft β optionaldraft_gateβ writer_self_review β resolve_internal_link_placeholders β reconcile_citations) β quality + images (quality_evaluation β url_validation β plan/generate/inject inline images β source_featured_image β caption_images) β the 12-atom qa.* rail block (qa.programmatic β β¦ β qa.aggregate, which replaced the deletedcross_model_qastage; rescuable rejects branch toqa.rewritefor one revision pass, bounded byqa_rewrite_max_attempts) β seo.generate_all_metadata β media scripts +review_video_shot_listβ finalize (compile_meta β persist_task β record_pipeline_version β evaluate_auto_publish)dev_diaryβ 5-node subset for the build-in-public stream (verify_task β narrate_bundle β generate_seo_metadata β source_featured_image β finalize_task)image_rebuildβ 7-node utility graph behindpoindexter tasks rebuild-images(services/image_rebuild_spec.py): load_draft β plan β generate β featured β gate β inject β persist. Rebuilds every image on anawaiting_approvaldraft by re-planning from the article text, reusing the samecontent.plan_image_markers/content.generate_images/content.inject_imagesatoms canonical_blog runs; the fail-loud gate atom rejects stock fallback unless--allow-stock, leaving the draft unchanged. Replaced the synchronous in-requestImageRebuildService(which blocked the CLI for the whole render) β the CLI/route now enqueue apipeline_tasksrow and return immediately.
pipeline_tasks.template_slug. A NULL value fails loud per feedback_no_silent_defaults.
How a run executes:
TemplateRunner.run(state, *, graph) compiles the graph (optionally with AsyncPostgresSaver for resumable runs), drives it to completion or halt, and returns a TemplateRunSummary with per-node timing + metrics. Stages are adapted onto the graph via make_stage_node(stage) so the legacy Stage.execute(context) shape still works β no rewrite required to lift a stage into a template.
Usage patterns:
- End-to-end content:
POST /api/tasksβ Prefectcontent_generation_flowclaims the row βContentRouterServicedispatches toTemplateRunner.run(template_slug, context) - Ad-hoc template use: stages are called directly in tests and scripts; not exposed via the public API.
services/template_runner.md for the runnerβs invariants.
4. Poindexter Worker (FastAPI Backend)
Location:src/cofounder_agent/
Purpose: Central orchestrator for all AI-powered operations
Core Components:
Main API (main.py)
- FastAPI application
- ~70 REST endpoints (see API reference for the inventory)
- Error handling and logging
- CORS middleware
- Request/response validation via Pydantic models
LLM Router (services/llm_providers/litellm_provider.py via dispatcher)
- LiteLLM-backed
LLMProviderplugin β primary router as of 2026-05-16 (plugin.llm_provider.primary.{free,budget,standard,premium,flagship}='litellm'on prod) - Model selection: each step reads its own
*_modelapp_settingspin (e.g.pipeline_writer_model,pipeline_critic_model); thecost_tier.<tier>.modelindirection was removed in PR #1907. The tierβprovider axis (plugin.llm_provider.primary.<tier>) remains βdispatch_complete(..., tier=)still selects the provider - Automatic provider routing + cost tracking + retries via mature OSS (LiteLLM)
- Langfuse callback auto-traces every call
- The hand-rolled
model_router.py/usage_tracker.py/model_constants.pytrio was deleted in Phase 2 cleanup (2026-05-08)
Pipeline Templates + Stages (services/pipeline_templates/__init__.py + modules/content/stages/*)
Stageprotocol:name: str,async def run(context) -> contextβ implemented per-stage inmodules/content/stages/TemplateRunner(LangGraph) orchestrates the pipeline βcanonical_blogfrom the DBgraph_def(compiled bypipeline_architect.build_graph_from_spec) whenpipeline_use_graph_def=true(the prod default since #355),dev_diaryfrom its in-treeTEMPLATESfactory. Halts naturally when a node returns a terminal state (e.g.qa.aggregaterejecting). The legacyDEFAULT_STAGE_ORDERlist +plugins/stage_runner.pywere deleted 2026-05-16 (Lane C Stage 4)- Context dict threads through every stage β the pipelineβs shared memory. Live service handles ride in
RunnableConfig.configurable["__services__"]so they donβt serialize into checkpoints (poindexter#382) - Adding a new stage = drop a file in
modules/content/stages/, register it inplugins/registry.py, then add it as a node: forcanonical_blogedit thegraph_defspec (services/canonical_blog_spec.py, re-seeded intopipeline_templates.graph_def); fordev_diaryadd it to theStateGraphfactory inservices/pipeline_templates/__init__.py
Semantic Memory (services/embedding_service.py + pgvector)
- pgvector extension in PostgreSQL 16 powers cosine-similarity search
embeddingstable stores 768-dim vectors keyed by(source_table, source_id)- Writer-segregated:
brain,audit,posts,memory,claude_sessions,issues - Accessible via
poindexter memory search "..."(CLI) orGET /api/memory/search(API) - Retention policy (stale embedding cleanup) tracked at GH-106
ποΈ Data Architecture
Key tables
The full schema lives inservices/migrations/0000_baseline.py. The most operationally important tables:
| Table | What it stores |
|---|---|
posts | Published blog posts. metadata->>'pipeline_task_id' links back to the source task. |
pipeline_tasks | Content pipeline queue β Prefect claims pending rows. |
pipeline_versions | Per-task generated content + qa_feedback snapshots. |
atom_runs | Per-atom run + outcome for the graph_def pipeline path. |
app_settings | All runtime config (~1,090 keys, ~68 secret). |
embeddings | pgvector 768-dim vectors (posts / audit / brain / memory / claude_sessions). |
audit_log | Canonical historical record for every pipeline event. |
pipeline_gate_history | HITL gate approvals + regen retries. |
qa_gates | Declarative QA gate definitions (required_to_pass, advisory). |
brain_knowledge | Self-maintained knowledge graph (entity / attribute / value). |
cost_logs | Per-call LLM cost tracking. |
docs/architecture/database-schema.md for the complete table inventory, and docs/operations/migrations.md for the migration system.
Roadmap
The roadmap is tracked via GitHub milestones at Glad-Labs/poindexter/milestones.| Milestone | Status | Description |
|---|---|---|
| M1: Stabilize | Done | Pipeline runs end-to-end, fresh clone works, tests pass |
| M3: Launch Poindexter Pro | In progress | Single subscription tier on Lemon Squeezy β 180/yr, Founding Member rate |
| Backlog | Ongoing | 30+ issues for post-revenue features |
Security
- OAuth 2.1 client credentials for all API access (JWT minted via
POST /tokenagainst a registeredoauth_clientsrow β Glad-Labs/poindexter#241 / #249) - Dev-token bypass blocked in production (
DEVELOPMENT_MODEcheck) - Secrets in DB (
is_secret=truekeys fetched viasite_config.get_secret(), filtered from in-memory cache) - No cloud keys in env β LLM API keys set via settings API, not env vars
- See SECURITY.md for the full model.
Related Documentation
- Database Schema β every table + migration system
- API Reference β REST endpoints
- Local Development β setup walkthrough