Extending Poindexter
Last Updated: 2026-06-13 How to add new capabilities to Poindexter without forking the monorepo or touching 1,000-line files. Every extension point below corresponds to a Protocol insrc/cofounder_agent/plugins/; the plugin architecture
umbrella (GH-64)
covers the long-range roadmap.
This guide is prescriptive. If you want design rationale, read
architecture/plugin-architecture.md.
Quick picker
| I want to… | Add a… | Protocol | Example |
|---|---|---|---|
| Run a new step in the content pipeline | Stage | plugins/stage.py::Stage | modules/content/stages/writer_self_review.py |
| Score a draft against a new quality rule | Reviewer | plugins/stage.py::Reviewer | modules/content/content_validator.py |
| Publish rendered media to a new platform | Publishing handler | services/integrations/registry.py (@register_handler("publishing", …)) | services/integrations/handlers/publishing_youtube.py |
| Generate media (image / audio / video) from a new engine | Provider | plugins/stage.py::Provider | services/image_providers/image_gen.py (in-progress) |
| Ingest content ideas from a new source (API, file, queue) | Tap | plugins/tap.py::Tap | services/topic_sources/hackernews.py |
| Run a background probe for health / business metrics | Probe | plugins/probe.py::Probe | brain/health_probes.py |
| Schedule a recurring background task | Job | plugins/job.py::Job | services/jobs/reload_site_config.py |
| Swap the LLM backend (Ollama → vLLM / OpenAI / Claude) | Provider | plugins/llm_provider.py::LLMProvider | Phase J, tracked at GH-104 |
| Add an entire business function (finance, HR, …) | Module | plugins/module.py::Module | src/cofounder_agent/modules/content/ |
1. Adding a Stage
A Stage is a pipeline step that runs on a single content task. Stages are wired into LangGraph pipelines — thecanonical_blog
graph_def (services/canonical_blog_spec.py, 39 nodes, seeded into the
pipeline_templates table) and the dev_diary factory in
services/pipeline_templates/__init__.py; TemplateRunner orchestrates
them via the spec’s edge list.
1a. Minimum viable Stage
Createsrc/cofounder_agent/modules/content/stages/my_stage.py:
1b. Register it
Add tosrc/cofounder_agent/plugins/registry.py — the stage must be
importable by name. The registry is the gateway; stages not registered
are invisible to TemplateRunner.
1c. Slot it into the pipeline order
As of atom-cutover #355,canonical_blog runs as a static graph_def
stored in the pipeline_templates table and authored in
services/canonical_blog_spec.py::CANONICAL_BLOG_GRAPH_DEF. Add your
node to the nodes list and wire its edges — e.g. insert my_stage
after the qa.* rail block, before finalize_task:
pipeline_templates.version for slug canonical_blog (model it on
20260602_023250_seed_canonical_blog_graph_def.py). TemplateRunner
picks up the new spec on the next run when pipeline_use_graph_def=true
(the prod default); build_graph_from_spec validates requires/produces
reachability at seed time, so a node whose inputs no upstream node
produces fails loud.
(dev_diary is still a Python factory — add the node to its StateGraph
in services/pipeline_templates/__init__.py instead.)
1d. Stage-specific config
Each stage reads its config fromplugin.stage.<name> in app_settings.
Standard fields honored by the runner:
enabled(bool, default true) — runtime off-switchtimeout_seconds(int) — per-invocation deadlinehalts_on_failure(bool) — whether to abort the chain on error
1e. Test it
Every stage ships with a unit test that builds a fake context + config and asserts the returnedStageResult. Model the test on
tests/unit/modules/content/stages/test_*.py. Test that halts_on_failure
and timeout_seconds are honored under the failure paths you care about.
2. Adding a Reviewer
A Reviewer produces a score (0-100) and a pass/fail judgment on a draft. Reviewers run as theqa.* rail atoms (each wraps a MultiModelQA
rail method) and contribute to the weighted final score that qa.aggregate
computes.
How your score is weighted
Weights live inapp_settings:
qa_validator_weight(default 0.4) — programmatic reviewersqa_critic_weight(default 0.6) — LLM criticsqa_gate_weight(default 0, was 0.3) — binary pass/fail gates
provider string. The aggregator maps provider → weight:
| Provider string | Weight source |
|---|---|
programmatic | qa_validator_weight |
anthropic, google, ollama | qa_critic_weight |
consistency_gate, url_verifier, vision_gate, web_factcheck | qa_gate_weight |
qa_gate_weight=0
keep it as pure veto. If it produces a meaningful graded score, use
programmatic.
Register
Add tomodules/content/multi_model_qa.py::MultiModelQA.review() in the
reviewer-assembly section. Future plugin-architecture work (Phase E)
will move this to an entry-point discovery.
3. Adding an Adapter (new publishing platform)
Adapters publish finished posts to external platforms. They sit after the approval gate, in thepublish_post flow rather than the pipeline
itself.
Existing publishing handlers live in services/integrations/handlers/:
publishing_youtube.py— upload rendered video to YouTubepublishing_postiz_video.py— push rendered video to platforms via Postiz
social.generate_drafts atom writes social_post_drafts
rows that are approved and posted via services.integrations.postiz_client,
not an in-process adapter.
Minimum shape
Config
Store credentials in app_settings withis_secret=true so they’re
redacted from logs and lists:
publishing_adapters row and wiring a
publishing.<name> handler (poindexter#112) — distribution is row-driven, see
services/integrations/handlers/.
4. Adding a new LLM Provider (Phase J)
Tracked at GH-104. TheLLMProvider Protocol abstracts the inference backend. Today
there’s effectively one implementation (Ollama); the Phase J refactor
exposes it as a registry so operators can choose between Ollama,
vLLM, llama.cpp server, OpenAI-compatible endpoints, and paid cloud
providers (Anthropic, Google, OpenRouter) via one app_setting.
Until Phase J ships, pipeline_writer_model accepts the ollama/<name>
prefix and routes to Ollama. Other provider prefixes will be valid once
the registry lands.
5. Adding a Tap (new topic source)
A Tap pulls content ideas from an external source and emits them as candidate topics. Taps run on a schedule (driven by topic discovery) and their output feeds the content pipeline. Existing taps live inservices/topic_sources/:
hackernews.py— HN top storiesdevto.py— Dev.to top posts by tagknowledge.py— internal brain knowledge
.extract(pool, config) → list[DiscoveredTopic].
Minimum shape
6. Adding a Job (scheduled background task)
A Job is a recurring task (cron-like) that runs independently of the content pipeline. Examples: reload app_settings cache, prune stale embeddings, re-embed posts, scrape HackerNews. Jobs live inservices/jobs/.
plugins/scheduler.py. The scheduler auto-picks up jobs
based on trigger + schedule.
7. Adding a Probe (health / business metric)
A Probe answers a question about current system state (health, business metric, capacity). Probes run on the brain daemon side and emit Prometheus metrics consumed by Grafana + Alertmanager.plugins/probe_registry.py.
8. Adding a HITL approval gate (#145)
A HITL gate is a configurable pause-and-wait point in any pipeline where the worker stops and asks a human to approve / reject before moving on. Gates are config-driven — the sameApprovalGateStage
ships with the worker; you “add a gate” by registering the Stage in
the chain with a new gate_name and an artifact_fn.
The single source of truth for the operator interface is
services/approval_service.py. The CLI is the canonical surface;
MCP and any future REST endpoints are thin wrappers.
Wire the Stage into your chain
Wherever your pipeline registers Stages (declarativeqa_gates table
for QA chains, or the LangGraph template registry at
services/pipeline_templates/__init__.py — content_router_service
is now a thin TemplateRunner dispatcher and the legacy hardcoded list
is gone as of the Lane C Stage 4 cutover, 2026-05-16), drop in
ApprovalGateStage with config:
- Reads
pipeline_gate_<gate_name>fromapp_settings. If unset oroff, returnsStageResult(ok=True)and is a no-op — adding the Stage to the chain doesn’t accidentally start blocking until the operator opts in. - Calls
artifact_fn(context)to build the JSON the operator will review. - Persists
awaiting_gate,gate_artifact,gate_paused_aton thepipeline_tasksrow. - Fires a Discord + Telegram notification through the existing
_notify_alertplumbing. - Returns
StageResult(ok=True, continue_workflow=False)— the runner halts.
Enable the gate
Default-off. Flip it on with the CLI:Operator workflow
When the gate trips, the operator sees a Telegram / Discord message with the artifact summary and the exact CLI command to approve / reject. Day-to-day flow:poindexter approve / reject / list-pending /
show-pending still work as hidden, deprecated aliases — #1652.)
Every command takes --json for piping. MCP tools exposed by the
gladlabs server (approve, reject, list_pending, show_pending,
gates_list, gates_set) wrap the same service module so an
operator can drive everything from a Claude Code session too.
Per-gate reject status
Default reject status isrejected. Override per gate by setting
approval_gate_<gate_name>_reject_status in app_settings — useful
when a gate’s “reject” should be a soft dismissed (so retry logic
doesn’t kick in) instead of a hard veto.
Why default-off
A new gate ships inert so registering it in the chain doesn’t break existing pipelines. The operator opts in by flipping thepipeline_gate_<gate_name> setting. Add a row to
bootstrap_defaults if you want the gate enabled out of the box for
fresh installs.
Tests
Every new gate gets a unit test that drivesApprovalGateStage with
its specific artifact_fn. See
tests/unit/test_approval_gate_stage.py for the canonical patterns:
gate-disabled passthrough, skip_if_setting passthrough, enabled-halt
- artifact persistence.
Anti-patterns — please don’t
- Don’t import across stages. Stages communicate through the context dict, never by importing each other. If stage B needs data from stage A, stage A writes it to context; stage B reads from context.
- Don’t bypass
site_config. Services should not callos.getenv()directly. Read config throughsite_config.get()so DB values win over env, and post-Phase-H dependency injection works. - Don’t hardcode model names. Writer / critic / research model
identifiers live in
app_settings(pipeline_writer_model, etc.). Even for experiments, use the DB. - Don’t write secrets to stdout / audit log. Use the
is_secret=trueflag on the setting row —SiteConfig.get_secret()redacts values from the in-memory cache and logs. - Don’t skip tests. Every Stage / Reviewer / Adapter / Tap / Job / Probe has a unit test. The repo’s 10,000+ tests are the moat against drift between what the docs promise and what the code does.
Adding a database migration
Database migrations live insrc/cofounder_agent/services/migrations/
and run on every worker startup. The naming convention changed in
Glad-Labs/poindexter#378 — new migrations use a UTC timestamp
prefix (YYYYMMDD_HHMMSS_<slug>.py) instead of the legacy 4-digit
integer prefix. Eliminates the parallel-PR collision class of bug.
Generate one with:
migrations.md for the full convention,
runner mechanics, common patterns, and anti-patterns. The fresh-DB
verification walkthrough lives in fresh-db-setup.md.
9. Adding a Module (Module v1)
A Module is the unit of install / version / OSS visibility for a whole business function. The substrate (brain, Prefect, Langfuse, cost_guard, prompt_manager, …) keeps running unchanged; a Module bundles the lower-level plugin contributions (stages, reviewers, probes, jobs, taps, adapters, providers, packs) + things the existing plugin registry doesn’t track (DB migrations, Grafana panels, HTTP routes, CLI subcommands) and gives them a manifest. When to add a Module vs a single plugin: a Module makes sense when you’re adding a new business function with its own DB tables, jobs, HTTP routes, and operator surface — finance, customer support, ops/security, HR. For one new pipeline step, one new image provider, one new probe — just use the capability-plugin patterns above. The reference Module iscontent at
src/cofounder_agent/modules/content/.
Private operator-overlay Modules (visibility="private") live only in
forks/operator overlays; the public mirror’s sync filter strips them.
Step 1 — Scaffold the package
modules/content/. Public modules use
visibility="public"; operator-overlay modules use visibility="private"
(those get filtered out of the public OSS mirror — see the sync script).
Step 2 — Define the Module class
Step 3 — Register the Module in _SAMPLES
Add one line to plugins/registry.py’s _SAMPLES list:
Step 4 — Add a migration if your module owns DB tables
module_schema_migrations
keyed on (module_name, migration_name) — so two modules can both have
an init.py migration without colliding.
Step 5 — Add module-owned jobs (optional)
Module-owned scheduled jobs live atmodules/<name>/jobs/<job>.py
implementing the JobResult-returning run(pool, config) contract.
Register in _SAMPLES:
Step 6 — Verify end-to-end
Restart the worker. The boot log should show:What’s deferred
- Phase 3.5 — shipped 2026-06-04. The content pipeline code physically
moved from
services/intomodules/content/(content_validator,stages/,atoms/,multi_model_qa, …) over a chain of squash-merged PRs, enabled by the registry’s presence-based discovery. The generic pipeline engine (template_runner,pipeline_architect,prompt_manager,llm_text,atom_registry) intentionally stays in substrate —contentrents it through the DBgraph_defseam, so the engine never imports content. - Phase 4.5 —
register_dashboards(Grafana folder per module),register_cli(subparser auto-discovery),register_probes(brain daemon integration). Today these are no-op stubs; wire them inline in your module’s package until the substrate generalizes them. - Phase 5 —
visibility="private"drivesscripts/sync-to-github.shautomatically. Today private modules are stripped via explicit pattern list in the sync script.
See also
- Plugin architecture — full roadmap + rationale
- Module v1 spec — design rationale, shipped status, deferred phases
- Services reference — catalog of every service in the worker
- Template Runner — how the
Stage chain fits together as the
canonical_blogtemplate - App settings reference — every DB-backed config key
- Database migrations convention — Glad-Labs/poindexter#378
- Fresh DB setup walkthrough — verified end-to-end