Skip to main content

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 like Tap, 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 (LLMProvider plugin 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:
FileLinesWhat it does
services/content_router_service.py2776Pipeline orchestration
services/idle_worker.py216920+ housekeeping jobs, hardcoded
scripts/auto-embed.py11576 embedding phases, each bespoke
services/image_service.py1143Pexels + image-gen + AI-gen fused
services/topic_discovery.py955Sources dispatched via if-chain
brain/health_probes.py + business_probes.py~1000Flat functions; Protocol unused
Every new Tap (Slack, Notion, Gmail), Provider (Midjourney, Flux), Reviewer (plagiarism check), or Adapter (new social platform) requires editing a 1000+ line file instead of dropping in a file. That blocks the plugin ecosystem vision and the Pro subscription overlay.

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 buildWhat already existsChosen because
Custom Tap Protocol + pkgutil discoverySinger spec + setuptools entry_points100+ published Singer taps; pip install semantics
Custom Job schedulerapscheduler (stores in Postgres)One library, no new daemon
Custom probe frameworkPrometheus + Alertmanager (already running)Zero new services; Grafana already wired
Custom plugin installer CLIpip + pyproject.toml entry_pointsInstall, update, uninstall, version-pin all free
Custom config overlay formatpypi packagePremium overlay = private pypi + license check
Net result: ~3000 lines of god-file code deleted, Singer catalog unlocked, zero new services to run, observability consolidated in the Grafana stack.

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 — see plugins/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

Two flavors of implementation:
  • 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.
Replaces 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 legacy social_adapters/ impls were retired 2026-06-29; distribution is now publishing.<name> handlers + Postiz)
  • Provider(Stage) — generates media (Pexels, image-gen, AI-generation, future Midjourney/Flux)
No workflow-engine adoption in this refactor. Pipeline stays hand-rolled. Temporal/Dagster revisit when managing multiple customer pipelines.

5. Pack — bundled prompts + styles + configs

Not code; data. Distribution via pip:
  • Free (community): poindexter-pack-community on public pypi, Apache-2.0
  • Premium: glad-labs-pack on private pypi, license-gated
pip already handles install/update/uninstall/version-pin. No custom overlay CLI needed.

6. LLMProvider — inference backend

Current state (2026-05-04): Three core providers shipped:
  1. OpenAICompatProvider — generic HTTP client with configurable base_url + model. Reaches Ollama (/v1 endpoint), llama.cpp server, vllm, SGLang, HuggingFace TGI, LM Studio, LocalAI by config.
  2. OllamaNativeProvider — keeps Ollama-specific features (electricity cost tracking, /api/embed, model pull). Default for the out-of-box experience.
  3. LiteLLMProvider (shipped 2026-05-04, poindexter#199 phase 1) — see services/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 setting plugin.llm_provider.primary.standard='litellm'. Phase 2 (delete services/model_router.py) waits on production validation.
Future plugins (lower priority): 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 their pyproject.toml:
Poindexter loads them via 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- or importlib.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 typeWhereWhy
Plugin enable / disable / per-install settingsapp_settings under plugin.<type>.<name>Customer-editable at runtime
Alert thresholdsinfrastructure/prometheus/alerts/*.ymlProduct; CI-validated
Grafana dashboardsinfrastructure/grafana/provisioning/Product
Prometheus scrape targetsinfrastructure/prometheus/config/prometheus.ymlProduct
Prompt Packspypi package; contents loaded into prompt_templates DB table on installBulk data; user edits individual rows
Secrets (API keys)app_settings with is_secret=trueCurrent pattern; encryption-at-rest is future work
bootstrap.toml~/.poindexter/bootstrap.tomlOnly DATABASE_URL — the chicken-and-egg
This split matches Prometheus/Grafana community conventions. Customers forking the repo inherit the canonical setup. Plugin config shape in app_settings:
One row per plugin instance. Enable/disable is a toggle. Inventory-ing installed plugins is one SELECT.

Architectural invariants

  1. Everything configurable — every plugin reads from app_settings (runtime) or repo files (infra rules); no hardcoded defaults that can’t be overridden.
  2. Everything an adapter of a core functionality — six Protocols; every feature is one of them.
  3. Everything in the database — runtime config, plugin state, secrets, metrics history. Only DATABASE_URL lives outside.
  4. Drop-in additionspip install + container restart picks up the new plugin. No central switch statements, no hardcoded plugin lists.
  5. 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.
  6. 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:
PhaseIssueDependencyPurpose
A0#21SHIPPED 2026-04-19. Integration test harness with real Postgres + real Ollama. 6 smoke tests pass.
A#65A0Plugin foundation: Protocols, entry_points discovery, PluginConfig reader, apscheduler wrapper, pgcrypto secrets encryption, sample migration per type.
B#66ATaps: split auto-embed.py. Internal Python Taps + SingerTap wrapper shipping a working tap-github demo.
C#67AJobs: port idle_worker.py onto apscheduler. DIY scheduler deleted.
D#68AProbes: Prometheus metrics + Alertmanager rules, brain daemon becomes Alertmanager webhook consumer. Additive-first — 1-2 weeks parallel-run before deleting legacy.
E#69APipeline: #20 split of content_router. Stage / Reviewer / Adapter / Provider specializations.
F#70A, BTopic Sources: split topic_discovery.py.
G#71AImage Providers: split image_service.py.
J#72ALLMProvider plugin family. OpenAICompatProvider + OllamaNativeProvider as core. Exit criterion: swap Ollama → vllm/llama.cpp/TGI/LocalAI/LiteLLM by one app_settings row.
I(future)A–JPost-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–JSaaS lifecycle. Subprocess/container isolation for untrusted community plugins. Web UI CRUD. Deferred until managed SaaS is on the calendar.
Phase A must be fully shipped before any other phase merges. A half-done plugin framework is worse than the current god files because it creates ambiguity about where new code lives.

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_logs table stays — cheapest source of truth for electricity + token counts.
One pane of glass (Grafana). No new vendor UIs.

Dependency audit

DepStatusNotes
Grafana, Prometheus, Alertmanager✅ In-theme, already runningRole unchanged
Postgres + pgvector, Redis, Ollama, image-gen✅ In-themeUnchanged
apscheduler (new), Singer runtime (new), prometheus_client (new), importlib.metadata (stdlib)✅ Apache / MIT / stdlibNet-new; all tiny libs, zero new daemons
Lemon Squeezy💰 KeptPayments — intentional business-model choice
Vercel💰 Kept (free tier)Public site hosting
Cloudflare R2💰 Kept for Glad Labs; MinIO documented for self-hostersCDN for podcast/image assets
Resend💰 Kept (free tier 3K/mo)Email
Google Analytics⚠️ Consider Plausible / Matomo for pure-OSS themeOnly remaining non-OSS piece
Removed in prior sessions: Anthropic, OpenAI, Google Gemini, Railway, Woodpecker. dlvr.it was retired for Mastodon (which used a direct adapter until distribution moved to Postiz 2026-06-29, GH-36; Bluesky/atproto was also a direct adapter until it was dropped 2026-06-17 to unblock a cryptography CVE fix). The dlvr.it free tier still bridges the RSS feed to LinkedIn today; it remains the intended bridge for X/Twitter if/when the paid subscription is reactivated (the X API itself is $100/mo — not worth it). Sentry is NOT removed — it’s still active in the public-site and worker as the error-tracking layer.

Known gaps (tracked, not blocking)

Moved into core scope (Phase A):
  • Secrets encryption-at-rest — now core (pgcrypto) per 2026-04-19 lock-in.
Deferred to Phase I (future, after refactor ships):
  • Plugin config schema versioning + migrations
  • Per-plugin resource limits (CPU/memory quotas)
  • Plugin-to-plugin dependency resolution
  • Full observability stack (OTel, Tempo, OpenLLMetry, Langfuse)
Other standing debt (becomes its own issue when needed):
  • Bare except Exception: sweep (125 swallowing blocks across services + routes)
  • memory_system.py dead code (966 lines — pyproject.toml marks it superseded by pgvector)
  • unified_orchestrator.py + task_executor.py vs content_router_service.py — resolved 2026-05-16: Prefect Stage 4 deleted task_executor.py and unified_orchestrator.py; content_router_service.py is now a thin TemplateRunner dispatcher.
  • Routes decomposition (task_publishing_routes.py 1049 lines, cms_routes.py 959 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