Skip to main content
Canonical reference: this guide describes the extensibility contract as it is actually implemented. Read it in full before creating a single file.

Overview

What you get for free (the shared base)

Any module that respects the contract inherits the following without writing a single line in server/core/:

What you must write (the cost of a new connector)

Nothing in server/core/ may be modified (AD-2).

Choosing the landing kind

The manifest’s module_kind field declares the shape of the data the module produces. Decision table:

Step by step

Step 1 — manifest.json

File: server/modules/<name>/manifest.json Reference schema: server/core/schemas/manifest.schema.json Excerpt adapted from gsc (reference module):
Key points:
  • name: strict kebab-case ^[a-z0-9-]+$ (Layer 1 fails otherwise).
  • auth_type: "oauth2" | "api_key" | "none".
  • module_kind: "kpi" | "context" (absent = "kpi" by default).
  • canonical_metric_mapping: additive metrics are written "source": "canonical" (string); non-additive metrics use the object form with aggregation_rule and non_additive: true (e.g. average_position in gsc). Never sum a non-additive metric directly (AD-4).
  • quota is optional; its absence means unlimited budget (no circuit-breaker).
  • widget_ref is optional for context modules.
Verification command (Layer 1):

Step 2 — connector.py: pull() and transform()

File: server/modules/<name>/connector.py Two required public functions.

2a. pull() — fetch and land

Row shape in the raw table (long-format kpi):
This is the shape stg_<name>.sql will consume and that fact_daily_kpi aggregates via UNION. Never store metrics as wide columns in the raw table — long format is the contract.

2b. transform() — manifest-driven (AD-2)

AD-2 / AD-4 rules:
  • transform() must never contain a hardcoded source field name: always use rename_map derived from the manifest.
  • Non-additive metrics (non_additive: true) are stored raw per row with their weight (e.g. impressions). Never aggregated here.
  • Ratio metrics (ctr, conversion rate…) are never stored. They are computed at the semantic layer.
Verification command (Layer 4):

Step 3 — dbt staging (module-owned)

Files:
  • server/modules/<name>/dbt/staging/stg_<name>_daily.sql
  • server/modules/<name>/dbt/staging/schema.yml
model-paths convention: dbt discovers these files via the model-paths key of dbt_project.yml. The chosen option (external model-path) keeps the models inside the module folder, not in dbt/models/. Adapted from the gsc staging (reference):
schema.yml (grain-unique test required, AI-05):
The grain_unique test is MANDATORY (AI-05: its absence is a code-review failure).

Step 4 — reports/*.json

File: server/modules/<name>/reports/<id>.json Each file is a report pack. Conformance (Layer 3b via test_reports.py) validates that the declared metrics and dimensions are known. Full structure with R6 fields (metric_definitions, llm_commentary_guidelines):
Reference schema for flow overrides: server/core/schemas/flow.report.schema.json Base packs (in reports/) do not yet include metric_definitions; those fields are added through a flow override (flows_upsert — see Step 9).

Step 5 — Conformance fixtures

Files:
golden_pull.json — rows in the source API’s raw format. Do not pre-process:
expected_facts.json — the exact result of transform(golden_pull.json):
  • Fields renamed by canonical_metric_mapping / canonical_dimension_mapping.
  • DROP fields (ratios like ctr) are absent.
  • Pass-through fields (pull_id, connector, date, unmapped dimensions) stay unchanged.
Critical rule: never hand-edit a fixture to make a test pass. The fixture is the contract; regenerate it from the producing code (Schema Change Checklist, CONTRIBUTING.md).

Step 6 — Conformance: the 4-layer suite

Full command (all layers):
Command validated on gsc (reference):
The 4 layers (execution order): Layer 1 failing = fail-fast (layers 2-4 are skipped). Error format:
All 4 layers must pass before the module is considered conformant.

Step 7 — Live-integration pass (AI-13)

Any module that introduces a new external API requires a live-integration pass before it can be marked done (AI-13 DoD). Phase 1 — Mocked tests with respx:
Phase 2 — Live against the real service: run a real pull, verify the received columns, error codes and date format. Record the evidence in the story’s Dev Agent Record. Mocked tests encode assumptions; the live contract must be verified (Story 2.2 lesson: Nango contract violations — image tag, port, query params all differed from the mocks).

Step 8 — Backfill into datastreams + mappings

Once the module is in place, datastreams and mappings are created automatically by backfill_datastreams() in server/core/datastreams.py. How it works:
  1. Reads active app.connection_ref rows from Postgres.
  2. Cross-references the module manifest (via core.main._loaded_modules).
  3. Creates one row in app.datastreams per (connection_ref x report_profile).
  4. Seeds app.datastream_mappings from the manifest’s canonical_metric_mapping + canonical_dimension_mapping.
    • String form: source_fieldtarget_field
    • Object form: source_fieldval["canonical"]
Command:
Expected result:
“Used-by” view in Data model: after the backfill, the admin’s Data model page automatically shows the new fields and which datastreams feed them (target_fields “Used by” — join datastream_mappingsdatastreams). No extra code required. Date normalization (Story 8.10 / Refinement R3): if your source delivers dates in a format or timezone other than ISO-8601/UTC, declare date_format and source_timezone in the datastream’s config:
Schema reference: server/core/schemas/flow.datastream.schema.json ($.config.date_format, $.config.source_timezone).

Step 9 (Optional) — Edit the flow through MCP (flows_upsert)

Once the module is live, you can modify a datastream or enrich a report directly through MCP — without touching the DB. Modify an existing datastream:
flows_upsert guardrails (do not bypass):
  • validate_flow() is called before any DB access: an unknown field (e.g. token, access_token) is rejected immediately.
  • connection_ref_id is an opaque reference — never a token (AD-3).
  • Every change is audited (flow_updated in audit_log) with a before/after diff.
  • An idempotent upsert (empty diff) writes nothing and generates no audit.

Final checklist

Enforced by the conformance suite. Every box must be checked before merge.
  • 1. manifest.json: valid auth_type enum, kebab name, report_profiles ≥ 1, complete canonical_*_mapping, non-additive metrics in object form with aggregation_rule.
  • 2. connector.py: pull() returns {pull_id, row_count, date_from, date_to}. transform() is manifest-driven (no hardcoded source field name — AD-2 grep gate). Token never stored or logged (AD-3). pull_id received from the caller, never minted here (AD-7).
  • 3. (Optional but recommended) dbt staging with QUALIFY supersede (AD-7) and a grain_unique test (AI-05 mandatory if the model is created).
  • 4. Mappings auto-seeded via backfill_datastreams() from the manifest’s canonical_*_mapping — nothing to write in SQL manually.
  • 5. tests/fixtures/golden_pull.json + expected_facts.json present and consistent with transform().
  • 6. Full conformance suite: all 4 layers pass.
  • 7. Live-integration pass documented (AI-13 DoD) — evidence in the Dev Agent Record.
  • 8. uv run ruff check server passes (AI-03: ASCII-only stdout in Python scripts).
  • 9. Schema Change Checklist (CONTRIBUTING.md) walked if a persisted schema or wire format changes.

Known pitfalls (Epics 1-8 retros)

AD-2 — The grep gate on module names in core/

Module names (gsc, google-analytics…) must never appear in server/core/. CI has a gate that greps server/core/ for these strings. If your connector.py imports from core/ while passing its own name as a literal, the gate fires. Correct workaround: use provider as a variable, not a hardcoded literal.

AI-03 — ASCII stdout required

Every Python script that writes to stdout must use ASCII characters only. Non-ASCII characters cause UnicodeEncodeError on some CI runners and Windows terminals. If you need Unicode, declare the encoding explicitly:

AD-4 — Non-additive metrics: no naive aggregation

Metrics declared non_additive: true (e.g. average_position, CTR, conversion rate) must never be summed directly from fact_daily_kpi. They must be read from a semantic view (semantic_avg_position) that applies impression-weighted aggregation:

R3 — Per-datastream date normalization

Do not assume the source delivers ISO-8601/UTC dates. Explicitly declare date_format and source_timezone in the datastream’s config (Story 8.10). A wrong date-format assumption causes silent duplicates at the grain level.

Grain-unique test (AI-05)

Any dbt model that materializes a mart must declare a unique test over its entire grain key in schema.yml. The absence of this test is a code-review failure (AI-05). Silent double-counting = wrong KPIs in the widget.

Ratio metrics: never store

CTR, conversion rate, bounce rate — these are ratios (numerator / denominator). They are never stored in the raw table (example: ctr is dropped in gsc/connector.py _DROP_FIELDS). They are computed at the semantic layer from separately-stored numerators and denominators.

AI-44 — Migration naming

If your connector needs a new migration in infra/nango/migrations/, the name must follow NNN_description.sql with a 3-digit zero-padded numeric prefix. Never reuse or renumber an already-applied prefix.