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
Capability plugins vs business modules. Every row above the last is a
capability plugin — a discrete piece (one tap, one provider, one stage) the
substrate consumes directly. The last row is a business module — a
manifest+lifecycle bundle that composes several capability plugins into a
self-contained business function. Most extensions are capability plugins; you
only need a Module when you’re adding a new business surface (finance,
customer support, ops/security, HR), not just a new step inside an existing
business surface. See Module v1 for the full
contract; section 9 below walks through adding one.
Each column below describes the full “how” per extension type.
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, 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
Settings-driven internal budgets — resolve_timeout_seconds
A static timeout_seconds cannot describe work whose own budget comes from
app_settings (retry counts, per-request timeouts). If your stage retries
internally, expose the optional hook and the runner will use it as a floor
on the configured node timeout:
make_stage_node takes max(configured, hook) — an operator may raise the node
timeout, but cannot cut it below what the stage needs. Stages without the hook
are unaffected.
This exists because the reverse combination loses work silently. With
halts_on_failure=False, a node timeout shorter than the stage’s own retry
budget means the wrapper cancels the stage mid-flight, swallows the
TimeoutError, and returns an empty state update — work that already completed
is discarded. source_featured_image shipped a post with no hero exactly that
way (RCA 2026-07-31): a hardcoded 300s node timeout against a 483s configured
render budget, with the image already rendered and uploaded to R2 two seconds
after the node was killed. Anything that waits on a shared resource inside the
stage — a GPU lock, a queue — counts toward the node timeout but not toward any
per-request timeout, so include headroom for it.
What halts_on_failure=False does and does not mean
It means “don’t abort the chain”. It does not mean “don’t report”. A
swallowed stage failure now emits a stage_failure_swallowed finding (warn,
Discord, deduped per stage name) and records status=error in atom_runs. The
run still completes — run-level ok keys off halted, not off the node’s ok —
so this is an observability change, not a control-flow one.
Note the corollary for stage authors: any diagnostic your stage emits from
inside execute is lost when the stage is cancelled. Detection of “this stage
produced nothing” belongs in the wrapper, not in the stage body.
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:
If your reviewer answers a binary question (URL resolves / fact
verified / layout passes), use a gate provider and let
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