services.integrations has a dedicated doc
explaining what external system it talks to, the config fields on the
integration row, the secrets it reads from app_settings, the expected
payload contract, and the operator runbook (add, test, rotate, disable).
The shared design is summarized below: one source-of-truth table per
surface, handlers registered as <surface>.<name>, encrypted secrets
resolved through a single audited path, and enabled=false as the safe
default for every row.
Surfaces
The integrations registry namespaces handlers by surface so the same short name can be reused across surfaces. Five surfaces are in flight today.| Surface | Source-of-truth table | Dispatcher | Purpose |
|---|---|---|---|
webhook | webhook_endpoints (direction=inbound) | routes/external_webhooks.py + routes/alertmanager_webhook_routes.py | External SaaS sends an event in (Lemon Squeezy, Resend, Alertmanager) via dedicated routes. |
outbound | webhook_endpoints (direction=outbound) | services/integrations/outbound_dispatcher.py | We send an event out (Discord, Telegram, Vercel ISR purge). |
tap | external_taps | services/integrations/tap_runner.py | Pull external data on a schedule (HackerNews, Dev.to, GA4, GSC, Singer subprocesses). |
retention | retention_policies | services/integrations/retention_runner.py | Sweep aging data — TTL-prune, downsample, summarize-to-table. |
publishing | publishing_adapters | services/jobs/media_distribute.py | Distribute rendered media (podcast / video) to platforms (YouTube, Postiz). |
Handler catalog
webhook surface (inbound)
webhook surface (inbound)
No first-party inbound handlers ship today. The live inbound webhooks are
served by dedicated FastAPI routes that verify the signature, normalize the
payload, and write to the landing table directly:
- Alertmanager →
alert_events—routes/alertmanager_webhook_routes.py(POST /api/webhooks/alertmanager) - Lemon Squeezy →
revenue_events—routes/external_webhooks.py(POST /api/webhooks/lemon-squeezy) - Resend →
subscriber_events—routes/external_webhooks.py(POST /api/webhooks/resend)
webhook surface stays available for third-party plugin handlers, which
register via services.integrations.register_handler.outbound surface
outbound surface
Poindexter sends events to external services. Outbound rows route
through a single dispatcher; the per-handler doc covers retry and
rate-limit behavior.
outbound.apprise_notify— generic push delivery (Discord, Telegram, ntfy, Slack, …) via Appriseoutbound.vercel_isr— Vercel ISR cache revalidation
tap surface
tap surface
Scheduled pulls of external data into Postgres. Taps are scheduled by
PluginScheduler from rows in
external_taps.tap.builtin_topic_source— first-party TopicSources (HackerNews, Dev.to, codebase, web_search, knowledge, dev_diary)tap.external_metrics_writer— write Singer-tap output intoexternal_metricstap.singer_subprocess— generic Singer-protocol subprocess runner (GA4, GSC)
retention surface
retention surface
Sweep aging data. Each policy is a row in
retention_policies keyed
on table + TTL + handler.retention.ttl_prune— drop rows older than the row’sttl_daysretention.downsample— aggregate raw rows into a coarser rollup table, then delete the originalsretention.summarize_to_table— LLM-based summarization of aging rows (handler implemented; doc pending)
publishing surface
publishing surface
Distribute rendered media to platforms. Adapters are rows in
publishing_adapters dispatched by services/jobs/media_distribute.py;
adding a new platform is an insert plus a publishing.<name> handler
registration.publishing.youtube— upload rendered video to YouTube (per-handler doc pending)publishing.postiz_video— push rendered video to platforms via Postiz (per-handler doc pending)
social.generate_drafts atom writes social_post_drafts rows that are
approved (poindexter social approve) and posted through Postiz — not the
publishing surface.Setup guides
Setup guides
External service setup that doesn’t fit any single handler doc.
- Google Search Console + Analytics 4 setup — provision the credentials the tap subprocesses need.
Adding a new platform
The point of the declarative-data-plane is that adding a new social platform equals an insert plus a<surface>.<name> handler registration. No edit to
the dispatcher.
Write the handler module
Drop a new module under
services/integrations/handlers/
(e.g. publishing_linkedin.py) and register it.Import at startup
Add the module to
services/integrations/handlers/__init__.py:load_all()
so it’s registered when the worker boots.Seed the adapter row
Add a migration that inserts the
publishing_adapters row — copy the
INSERT INTO publishing_adapters shape from the rows seeded in
services/migrations/0000_baseline.py.Document the handler
Add
docs/integrations/publishing_linkedin.md mirroring the existing
per-handler doc structure (config, secrets, contract, runbook).