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
Frontend Features:
- 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
Backend Features:
- RESTful API (~70 endpoints across tasks, posts, media, memory, pipeline, analytics, webhooks)
- WebSocket support (planned)
- LangGraph-orchestrated pipeline —
canonical_bloggraph_def (44 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
AI Model Providers (Ollama-only pipeline)
Default chain: Ollama primary →
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 44-node default for blog posts (services/canonical_blog_spec.pyis the authoritative node list — recount from it rather than trusting this line; 11stage.*+ 14content.*+ 14qa.*+ 1qa.rewrite+ 2atoms.approval_gate+ 1seo.*+ 1social.generate_drafts). Seven linear blocks (43 nodes) plus the off-chainqa.rewriterescue node — and three bounded backward cycles, so this is not a DAG: the QA rescue loop plus twopreview_gateregen paths (preview_gate → plan_image_markersre-runs images only,preview_gate → generate_draftre-runs the writer; bothbranch+loop, from the component-scoped regen gate #1851, and both dormant whilepipeline_gate_preview_gate='off'). Blocks: verify → writer (generate_draft → generate_title → check_title_originality → normalize_draft → optionaldraft_gate→ writer_self_review → resolve_internal_link_placeholders → reconcile_citations → llm_reconcile_citations → inject_affiliate_links, dark-launched behindaffiliate_injection_enabled→ quality_evaluation → url_validation) → images (plan/generate/inject inline images → source_featured_image → caption_images) → the 14-node qa.* rail block (13 rails, qa.programmatic → … → qa.web_factcheck, then qa.aggregate — which replaced the deletedcross_model_qastage; rescuable rejects branch toqa.rewritefor one revision pass and loop back to qa.programmatic, bounded byqa_rewrite_max_attempts) → seo.generate_all_metadata → media (generate_media_scripts → generate_video_shot_list → review_video_shot_list → capture_training_data) → finalize (compile_meta → persist_task → social.generate_drafts → record_pipeline_version → optionalpreview_gate→ 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— 8-node utility graph behindpoindexter tasks rebuild-images(services/image_rebuild_spec.py): load_draft → plan → generate → featured → gate → inject → persist → finalize. 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:
See
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.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