adding-inbox-sources
Add a new warehouse-backed source to the PostHog Desktop Self-driving Inbox (the feature that ships GitHub, Linear, Zendesk, pganalyze, Jira). A source syncs…
npx skills add https://github.com/posthog/posthog --skill adding-inbox-sourcesAdding a Self-driving Inbox source
The Self-driving Inbox connects a PostHog data warehouse source and syncs one table of actionable records.
After each sync, the signals pipeline reads the new rows, filters them with an LLM actionability gate, and emits signals.
The pipeline is driven by a static registry keyed on (ExternalDataSourceType, table), so each table needs an emitter config.
All work happens in this repository:
| Surface | What changes |
|---|---|
| Backend (required) | SignalSourceProduct value, emitter module, registry entry, contract variant, a test row |
| Frontend (required) | products/signals/frontend/inbox/externalSignalSources.ts entry and the signalSourcesLogic.ts unions |
| Wizard (optional) | PostHog/context-mill self-driving skill, so npx @posthog/wizard self-driving offers the source |
Do not edit the desktop app (products/desktop/) or packages/agent/packages/agent-contracts/src/inbox-types.ts for a new source.
A desktop change in the same PR as a new SignalSourceProduct value fails the desktop/backend coupling check, and the inbox does not need it.
The data warehouse source itself must already exist and be implemented.
Find candidates
- List implemented sources: iterate
SourceRegistry.get_all_sources()and keep a source whentype(src).get_schemas is not _BaseSource.get_schemasandget_source_config.unreleasedSourceis false. Run it on a freshorigin/masterworktree. - Call
get_documented_tables()for each source. It gives table names and incremental fields without credentials.get_canonical_descriptions()gives column names. - Keep tables that hold one actionable record per row: issues, tickets, conversations, incidents, alerts, findings, feedback, reviews, survey responses. Skip CRM tasks and notes, message logs, blog posts and event tickets.
- Check that each table has a human-readable text column, a stable id, and a creation timestamp. Tables with text only in nested JSON need a bespoke emitter. Tables with one row per occurrence need grouping (see below).
- Compare against
_register_all_emitters()inproducts/signals/backend/emission/registry.pyand open PRs (gh pr list --search "inbox source in:title").
Backend checklist (per source)
Code lives in products/signals/backend/. Read emission/CLAUDE.md first.
- Enum: add a
SignalSourceProductvalue and aSIGNAL_SOURCE_PRODUCT_LABELSentry inenums.py. The model's choices are callables, so a new value needs no migration. A new record kind needs aSignalSourceTypevalue, the matchingmodels.pySourceTypechoice, and a prompt pair inemission/_prompts.py. - Emitter: add
emission/<product>_<table>.pyexporting aSignalSourceTableConfig.- Flat tables use
make_flat_emitterfromemission/_common.py(seepagerduty_incidents.py). - Nested JSON: extract in HogQL with
JSONExtractString(col, 'key') AS new_alias, using an alias that differs from the column name, or parse in Python (seejira_issues.py,intercom_conversations.py,emission/_survey.py). - Warehouse columns are snake_case. The import renames API fields (
createdAtbecomescreated_at). Check the source'scanonical_descriptions.pyandsettings.py. partition_fieldis the creation timestamp. Setpartition_field_is_datetime_string=Truefor string columns. The fetcher wraps the column inparseDateTimeBestEffort(toString(...)), so a native timestamp also works. Epoch columns use an expression such asfromUnixTimestamp(intDiv(toInt(col), 1000)).- Tables with one row per occurrence (no grouped issue id) use
fetchers/grouped_warehouse.pywith aGROUP BY, so one issue emits once instead of flooding the inbox. - Use
where_clauseto skip resolved, closed or empty-text rows only when the status values are certain. A filter on a column that a vendor sometimes omits makes the whole query fail. - Do not select PII (emails, names, user objects, IPs). Use ids, urls, statuses and severities.
- Reuse the shared prompt pair for the record kind. Siblings use
max_records=200,first_sync_lookback_days=1anddescription_summarization_threshold_chars=2000.
- Flat tables use
- Register:
register_signal_source(ExternalDataSourceType.X, "<table>", CONFIG)in_register_all_emitters(). The table name must match the schema name the source returns, including casing (Issues,Cases). - Contract: in
contracts.pyadd<Name><Kind>SignalExtra(onestr | Noneper extra field,listfor list fields) and<Name><Kind>SignalInput, then add the Input to theSignalInputunion and theSIGNAL_INPUT_VARIANTStuple. - Tests: add a row to
TIER1_SOURCESinemission/tests/test_tier1_registry.py. It checks registration, the contract and the prompts. A bespoke emitter also gets a small parameterized test file for its parsing edge cases. - Generated types: a new enum value changes
products/signals/frontend/generated/and the MCP generated files. Runhogli build:openapi, or let the CI bot commit the regenerated types to the branch.
Check that the fetch query parses: build the SELECT the fetcher builds and run parse_select from posthog.hogql.parser on it.
Frontend checklist (per source)
products/signals/frontend/inbox/externalSignalSources.ts: add anEXTERNAL_SIGNAL_SOURCESentry withsource,label,category,watches,dwSourceType(the exactExternalDataSourceTypestring),requiredTablesandsourceType(the record kind). Writewatchesin sentence case and follow/writing-user-facing-copy.WAREHOUSE_SOURCE_SETUPderives from this list, so it needs no edit.products/signals/frontend/inbox/signalSourcesLogic.ts: add the product to every hand-writtenexternalSourceStates: Record<...>union, in alphabetical order. There are several, and they must all equal theEXTERNAL_SIGNAL_SOURCESlist.
The self-driving wizard (PostHog/context-mill), optional
The wizard's connected-tools list is hardcoded, so a new source is not offered during npx @posthog/wizard self-driving until you add it.
Edit context/skills/self-driving/references/5-connected-tools.md (plus the source lists in 4-sources.md, 2-read-context.md and description.md):
- Add the tool to the step-5
wizard_askoptions. - Add its responder mapping: the same
source_productand record kind as the emitter. - Add its warehouse
source_typeto the "already connected" detection list. - Credential sources are recorded as a follow-up, with no connector file. One-click OAuth sources get a connector reference (clone
5b-linear.md).
Run node scripts/build.js once to confirm the skill bundles.
created_via attribution
ExternalDataSource.created_via is set server-side from the request transport (get_event_source in posthog/event_usage.py).
Callers cannot set it, and a new source needs no wiring.
The upgrade from mcp to self_driving lives in _create_external_data_source in products/warehouse_sources/backend/presentation/views/external_data_source.py.
PR conventions
- Title:
feat(data-warehouse): add <Source or group> as self-driving inbox sources. - Group related sources (incidents, security, trackers, support, feedback) in one PR per group.
- PRs that edit the same lists (
registry.py,contracts.py,test_tier1_registry.py,externalSignalSources.ts,signalSourcesLogic.ts) conflict with each other after one merges. Keep the entries from both sides when you resolve the conflict, and keep everysignalSourcesLogic.tsunion complete.
Gotchas
source_typeon the warehouse source ("Jira"),source_product("jira") and the record kind ("issue") are three different fields.dwSourceTypemust equal the realExternalDataSourceTypestring.- A column the vendor omits for some accounts (
priority,date_closed,archived_at) may be missing from the synced table. Do not select or filter on it unless it is always present. - Every canonical actionability prompt keeps the
When in doubt, classify as ACTIONABLEline, because steering injection anchors on it.