Plugin Architecture
⚠️ This is a design specification, not a shipping status document. It describes how Poindexter is evolving architecturally. Some sections describe shipped work; others describe future phases. For current deployment state, see CLAUDE.md.Last Updated: 2026-05-13 Status: 📐 Design — umbrella #64 v3 locked; child phases #65-#72 rewritten to match. Layered on top of the plugin substrate is Module v1 (#490, spec at
module-v1.md) — the unit of business-function composition (one Module = one business: content / finance / customer-support / ops). The first four Module v1 phases shipped 2026-05-13.
Scope: Canonical in-repo reference for how Poindexter is evolving from a handful of god-files into a plugin-shaped system.
Capability plugins vs business modules. The Phases A–J on this page describe capability plugins — atoms likeTap,Stage,Provider,LLMProvider. Module v1 is the next layer up: a Module bundles several capability plugins into a manifested, install-shaped business function. They’re orthogonal axes, not competing decompositions. Most extensions slot into the capability layer; you only reach for a Module when you’re adding a new business surface that needs its own DB migrations, jobs, routes, dashboards.
v3 locked decisions (2026-04-19):
- Secrets encryption-at-rest is core scope (Phase A delivers via pgcrypto), not “known gap.”
- Tracing + observability instrumentation (OTel, Tempo, OpenLLMetry) is deferred to a post-refactor Phase I. We’ll know what to monitor after the refactor is done.
- Phase J (
LLMProviderplugin family) confirmed as core scope. Swap Ollama → vllm/llama.cpp/TGI/LocalAI by one app_settings row.- Phase A0 (integration harness, #21) shipped 2026-04-19. Phase A is unblocked.
- Other v2 gaps (config schema versioning, resource limits, plugin-to-plugin deps) → Phase I.
Problem
Poindexter keeps inventing “feature parent + sources as children” informally in config (enabled_topic_sources, image_primary_source, qa_workflow_*) but the code doesn’t match. The result is god files that make every new integration a painful edit:
| File | Lines | What it does |
|---|---|---|
services/content_router_service.py | 2776 | Pipeline orchestration |
services/idle_worker.py | 2169 | 20+ housekeeping jobs, hardcoded |
scripts/auto-embed.py | 1157 | 6 embedding phases, each bespoke |
services/image_service.py | 1143 | Pexels + image-gen + AI-gen fused |
services/topic_discovery.py | 955 | Sources dispatched via if-chain |
brain/health_probes.py + business_probes.py | ~1000 | Flat functions; Protocol unused |
Core insight: adopt standards, don’t invent
Every custom system originally proposed has a mature, free, OSS equivalent already in-stack or trivially added. The refactor does not build a custom plugin framework. Each Protocol wraps a standard.| What we could build | What already exists | Chosen because |
|---|---|---|
| Custom Tap Protocol + pkgutil discovery | Singer spec + setuptools entry_points | 100+ published Singer taps; pip install semantics |
| Custom Job scheduler | apscheduler (stores in Postgres) | One library, no new daemon |
| Custom probe framework | Prometheus + Alertmanager (already running) | Zero new services; Grafana already wired |
| Custom plugin installer CLI | pip + pyproject.toml entry_points | Install, update, uninstall, version-pin all free |
| Custom config overlay format | pypi package | Premium overlay = private pypi + license check |
The original six plugin Protocols
The Phase A–J design described below locks in the original six plugin Protocols. Since this design landed, the substrate has grown beyond that initial set — seeplugins/registry.py::ENTRY_POINT_GROUPS
for the canonical list (currently 18 groups, including specialised
Provider variants like image_providers, video_providers,
audio_gen_providers, tts_providers, caption_providers,
media_compositors, publish_adapters, and the Module v1 group
that bundles capability plugins into business-function units).
All live under src/cofounder_agent/plugins/ as the canonical contracts. All discovered via importlib.metadata.entry_points.
1. Tap — data ingestion
- Native Python Taps — for internal sources (our own posts, our own memory files, audit log). Fast, no subprocess overhead.
- SingerTap wrapper — wraps any Singer binary (tap-github, tap-slack, tap-gmail, tap-google-analytics). Subprocess + stdout JSON-lines. Unlocks the public Singer catalog with zero per-source code.
scripts/auto-embed.py’s six hardcoded phases.
2. Probe — state checking
Probes emit Prometheus metrics. Alerting moves to Alertmanager rules (YAML in infrastructure/prometheus/alerts/). The brain daemon becomes an Alertmanager webhook consumer — it interprets alerts and decides on auto-remediation versus human escalation; it no longer runs the check loop itself.
This is the biggest conceptual shift in the refactor. Phase D handles it additive-first: expose metrics alongside existing probes, verify Alertmanager fires identically, then delete the legacy.
3. Job — scheduled maintenance
PluginScheduler uses inspect.signature to forward site_config
only to jobs that declare it in their run() kwargs — jobs that
don’t need app_settings can keep the two-arg shape (Phase H, GH#95).
apscheduler is the runner. Each Job registers via entry_points. idle_worker.py becomes a thin bootstrap that hands jobs to apscheduler’s async scheduler. State (last_run_at) persisted in Postgres — survival across restarts is free.
4. Stage — pipeline transformer
Promotes the existing services/phases/base_phase.py contract. Stage specializations already partly shipped:
Reviewer(Stage)— scores content (programmatic_validator, llm_critic, seo_checker, url_verifier)Adapter(Stage)— publishes to a platform (the legacysocial_adapters/impls were retired 2026-06-29; distribution is nowpublishing.<name>handlers + Postiz)Provider(Stage)— generates media (Pexels, image-gen, AI-generation, future Midjourney/Flux)
5. Pack — bundled prompts + styles + configs
Not code; data. Distribution via pip:
- Free (community):
poindexter-pack-communityon public pypi, Apache-2.0 - Premium:
glad-labs-packon private pypi, license-gated
pip already handles install/update/uninstall/version-pin. No custom overlay CLI needed.
6. LLMProvider — inference backend
OpenAICompatProvider— generic HTTP client with configurablebase_url+model. Reaches Ollama (/v1endpoint), llama.cpp server, vllm, SGLang, HuggingFace TGI, LM Studio, LocalAI by config.OllamaNativeProvider— keeps Ollama-specific features (electricity cost tracking,/api/embed, model pull). Default for the out-of-box experience.LiteLLMProvider(shipped 2026-05-04, poindexter#199 phase 1) — seeservices/litellm_provider.md. Wraps the LiteLLM SDK so one plugin covers 100+ providers (Ollama, OpenAI, Anthropic, Gemini, Bedrock, Vertex, OpenRouter, etc.) with authoritative cost tracking + retries-with-backoff via mature OSS. Activates by settingplugin.llm_provider.primary.standard='litellm'. Phase 2 (deleteservices/model_router.py) waits on production validation.
HuggingFaceProvider for on-machine transformers hosting without a separate server.
Core ships OSS-only. Community plugins can wrap paid providers (Anthropic, OpenAI, Google Gemini, Groq, OpenRouter, etc.) and distribute via pypi. The OpenAICompatProvider already reaches some paid vendors by config (OpenRouter, Groq, Together, Fireworks; Anthropic has an OpenAI-compat mode). “No paid APIs” is a default shipping policy, not a contract constraint.
Plugin discovery: setuptools entry_points
Plugins declare themselves in theirpyproject.toml:
importlib.metadata.entry_points(group="poindexter.taps"). No pkgutil scan, no custom registry, no decorators. This is the same pattern pytest, click, flask use.
- Install:
pip install poindexter-tap-slack - Uninstall:
pip uninstall poindexter-tap-slack - Update:
pip install -U poindexter-tap-slack - List:
pip list | grep poindexter-orimportlib.metadata.entry_points()
Config boundary: DB vs file
Poindexter’s rule is “everything in the database,” with a practical exception for long-standing infra rules.| Config type | Where | Why |
|---|---|---|
| Plugin enable / disable / per-install settings | app_settings under plugin.<type>.<name> | Customer-editable at runtime |
| Alert thresholds | infrastructure/prometheus/alerts/*.yml | Product; CI-validated |
| Grafana dashboards | infrastructure/grafana/provisioning/ | Product |
| Prometheus scrape targets | infrastructure/prometheus/config/prometheus.yml | Product |
| Prompt Packs | pypi package; contents loaded into prompt_templates DB table on install | Bulk data; user edits individual rows |
| Secrets (API keys) | app_settings with is_secret=true | Current pattern; encryption-at-rest is future work |
bootstrap.toml | ~/.poindexter/bootstrap.toml | Only DATABASE_URL — the chicken-and-egg |
app_settings:
Architectural invariants
- Everything configurable — every plugin reads from
app_settings(runtime) or repo files (infra rules); no hardcoded defaults that can’t be overridden. - Everything an adapter of a core functionality — six Protocols; every feature is one of them.
- Everything in the database — runtime config, plugin state, secrets, metrics history. Only
DATABASE_URLlives outside. - Drop-in additions —
pip install+ container restart picks up the new plugin. No central switch statements, no hardcoded plugin lists. - Core is OSS-only, community can extend — default shipping policy ships free/OSS backends (Ollama, Singer community taps, Prometheus). Community plugins can wrap paid providers and ship on their own pypi packages.
- Standards over inventions — when an OSS standard exists (Singer, OpenAI-compat, apscheduler, Prometheus, entry_points), we wrap it, not reinvent.
Migration phases
Refer to GitHub issues for the actionable scope. Suggested execution order:| Phase | Issue | Dependency | Purpose |
|---|---|---|---|
| A0 | #21 | — | ✅ SHIPPED 2026-04-19. Integration test harness with real Postgres + real Ollama. 6 smoke tests pass. |
| A | #65 | A0 | Plugin foundation: Protocols, entry_points discovery, PluginConfig reader, apscheduler wrapper, pgcrypto secrets encryption, sample migration per type. |
| B | #66 | A | Taps: split auto-embed.py. Internal Python Taps + SingerTap wrapper shipping a working tap-github demo. |
| C | #67 | A | Jobs: port idle_worker.py onto apscheduler. DIY scheduler deleted. |
| D | #68 | A | Probes: Prometheus metrics + Alertmanager rules, brain daemon becomes Alertmanager webhook consumer. Additive-first — 1-2 weeks parallel-run before deleting legacy. |
| E | #69 | A | Pipeline: #20 split of content_router. Stage / Reviewer / Adapter / Provider specializations. |
| F | #70 | A, B | Topic Sources: split topic_discovery.py. |
| G | #71 | A | Image Providers: split image_service.py. |
| J | #72 | A | LLMProvider plugin family. OpenAICompatProvider + OllamaNativeProvider as core. Exit criterion: swap Ollama → vllm/llama.cpp/TGI/LocalAI/LiteLLM by one app_settings row. |
| I | (future) | A–J | Post-refactor observability upgrade (OTel, Tempo, OpenLLMetry, Langfuse eval) + plugin config schema versioning + per-plugin resource limits + plugin-to-plugin deps. Deferred — we’ll know what to monitor after the refactor. |
| H | (future) | A–J | SaaS lifecycle. Subprocess/container isolation for untrusted community plugins. Web UI CRUD. Deferred until managed SaaS is on the calendar. |
Observability strategy
In-scope for the refactor (Phase D): Prometheus metrics + Alertmanager rules + existing Grafana dashboards. Brain daemon pivots from running a probe loop to consuming Alertmanager webhooks. Zero new services to run — everything leans on infra we already have. Deferred to post-refactor Phase I: OpenTelemetry instrumentation, Tempo for distributed traces, OpenLLMetry conventions for LLM-specific spans, Langfuse for prompt playground + eval tooling. Rationale (Matt’s 2026-04-19 decision): we’ll know what to trace after the refactor is done and we can see where the pain actually lives. Adding tracing before that risks building dashboards for pre-refactor shapes. In the meantime:- Logs: structlog (already in stack). Request-ID propagation added as part of Phase D.
- LLM cost: native
cost_logstable stays — cheapest source of truth for electricity + token counts.
Dependency audit
| Dep | Status | Notes |
|---|---|---|
| Grafana, Prometheus, Alertmanager | ✅ In-theme, already running | Role unchanged |
| Postgres + pgvector, Redis, Ollama, image-gen | ✅ In-theme | Unchanged |
| apscheduler (new), Singer runtime (new), prometheus_client (new), importlib.metadata (stdlib) | ✅ Apache / MIT / stdlib | Net-new; all tiny libs, zero new daemons |
| Lemon Squeezy | 💰 Kept | Payments — intentional business-model choice |
| Vercel | 💰 Kept (free tier) | Public site hosting |
| Cloudflare R2 | 💰 Kept for Glad Labs; MinIO documented for self-hosters | CDN for podcast/image assets |
| Resend | 💰 Kept (free tier 3K/mo) | |
| Google Analytics | ⚠️ Consider Plausible / Matomo for pure-OSS theme | Only remaining non-OSS piece |
Known gaps (tracked, not blocking)
Moved into core scope (Phase A):Secrets encryption-at-rest— now core (pgcrypto) per 2026-04-19 lock-in.
- Plugin config schema versioning + migrations
- Per-plugin resource limits (CPU/memory quotas)
- Plugin-to-plugin dependency resolution
- Full observability stack (OTel, Tempo, OpenLLMetry, Langfuse)
- Bare
except Exception:sweep (125 swallowing blocks across services + routes) memory_system.pydead code (966 lines — pyproject.toml marks it superseded by pgvector)— resolved 2026-05-16: Prefect Stage 4 deletedunified_orchestrator.py+task_executor.pyvscontent_router_service.pytask_executor.pyandunified_orchestrator.py;content_router_service.pyis now a thin TemplateRunner dispatcher.- Routes decomposition (
task_publishing_routes.py1049 lines,cms_routes.py959 lines) - 50-migration consolidation pass
- Dependency license audit
- Swap Google Analytics for Plausible/Matomo (only remaining non-OSS surface)
- Dependency license audit
- Swap GA for Plausible/Matomo
Further reading
- GitHub #64 — umbrella (v2)
- GitHub #53 — original plugin ecosystem vision (Taps / Probes / Prompt Packs)
- GitHub #20 — content_router split
- GitHub #56 — brain daemon pluggable watchdog
- Brain memory under
plugin refactor v2tag —mcp__poindexter__search_memory