Quick answer
Keep the loader simple: land raw Stripe and CRM exports in a bronze schema unchanged. Then ask a Nori agent in Slack to write the dbt project on top. It profiles the bronze tables, writes silver models that dedupe, cast types and carry tests, builds a gold revenue mart, and opens a pull request. A data engineer reviews and merges. A nightly Nori automation reruns dbt build and posts failed tests in the channel with a proposed fix.
What the three layers mean in this example
The medallion pattern splits a warehouse into three layers with one job each. The names matter less than the rule that each layer only reads from the one below it.
| Layer | Schema | Contents | Who writes it |
|---|---|---|---|
| Bronze | bronze_stripe, bronze_crm | Raw exports, append-only, one row per export line, plus _loaded_at and _source_file | Your loader (Fivetran, Airbyte, or a copy job) |
| Silver | silver | One row per business entity, typed, deduplicated, tested | dbt models written by the agent, reviewed by you |
| Gold | gold | Marts that answer a question, for example monthly revenue per account | dbt models written by the agent, reviewed by you |
The agent never edits bronze. If a raw file is wrong, the fix is a new load, not an update in place. That keeps every silver and gold number reproducible from the raw rows.
How to set it up, step by step
- Land the raw data. Load the Stripe
chargesandcustomersexports and the CRMaccountsexport into bronze as they arrive. Do not clean anything. Add_loaded_atso duplicates from repeated exports can be resolved later. - Give the agent a scoped warehouse role. Create a role that can read bronze and write only to a development schema such as
dbt_nori. Add its credentials to Nori as an integration scoped to your data agent. Production schemas are written by your deploy job after merge, not by the session. - Write the conventions once. Put naming, layer and test rules in the team's skillset so every session follows them:
stg_models are views in silver, every primary key hasuniqueandnot_nulltests, money is stored asnumeric(18,2)with a currency column. - Ask in Slack.
@nori build silver models for bronze_stripe.charges, bronze_stripe.customers and bronze_crm.accounts, then a gold monthly revenue mart per CRM account. Follow our dbt conventions and open a PR. - The agent profiles before it writes. It counts rows and distinct keys, checks null rates, lists the values of status columns, and looks for accounts with no Stripe customer. It writes what it found into the pull request description.
- Review the pull request. The agent runs
dbt buildagainstdbt_noriand attaches the result. You review the SQL, the tests and the profile notes, then merge. Your existing deploy job builds production. - Schedule the nightly check. Create a Nori automation that runs after the loader finishes, reruns the build, and reports only when something fails.
Steps 1 to 3 are done once. From step 4 on, adding a new source is one Slack message and one review.
Worked example: the silver models
Two things go wrong with raw Stripe exports. The same charge appears in several export files, and the fields arrive in API shape: created is a Unix timestamp in seconds and amount is an integer in the smallest currency unit. The profile step found both, so the silver model handles both. This example uses Snowflake SQL.
-- models/silver/stg_stripe__charges.sql
with source as (
select * from {{ source('bronze_stripe', 'charges') }}
),
deduped as (
select *
from source
qualify row_number() over (
partition by id
order by _loaded_at desc
) = 1
)
select
id as charge_id,
customer as stripe_customer_id,
to_timestamp_ntz(created::number) as charged_at,
upper(currency) as currency,
(amount::number / 100)::numeric(18,2) as amount,
(amount_refunded::number / 100)::numeric(18,2) as amount_refunded,
status,
paid::boolean as is_paid,
_loaded_at
from deduped
The agent flags its own assumption in the pull request: dividing by 100 is only correct for two-decimal currencies. The profile showed only usd and eur, so the model adds an accepted_values test on currency. If a zero-decimal currency such as JPY ever arrives, the test fails instead of the revenue number being silently wrong by a factor of 100.
The tests live next to the model. The syntax follows the dbt data tests documentation.
# models/silver/_stripe__models.yml
version: 2
models:
- name: stg_stripe__charges
description: One row per Stripe charge, latest export wins.
columns:
- name: charge_id
data_tests: [unique, not_null]
- name: stripe_customer_id
data_tests:
- not_null
- relationships:
to: ref('stg_stripe__customers')
field: stripe_customer_id
- name: currency
data_tests:
- accepted_values:
values: ['USD', 'EUR']
- name: status
data_tests:
- accepted_values:
values: ['succeeded', 'pending', 'failed']
sources:
- name: bronze_stripe
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 26, period: hour}
error_after: {count: 50, period: hour}
tables:
- name: charges
- name: customers
stg_crm__accounts follows the same shape: dedupe on account_id, trim and lower-case the billing email, cast created_date to a date, and keep the stripe_customer_id field that links the two systems.
The gold mart and what the agent asks before writing it
A gold table encodes a business definition, so the agent asks instead of guessing. In the thread it posts: Two questions before I write the mart. 1) Is revenue net of refunds? 2) 14 CRM accounts have no Stripe customer id. Exclude them, or keep them with zero revenue? The answers in this example are net of refunds and keep them.
-- models/gold/fct_monthly_revenue.sql
with charges as (
select * from {{ ref('stg_stripe__charges') }}
where status = 'succeeded' and is_paid
),
monthly as (
select
stripe_customer_id,
date_trunc('month', charged_at)::date as revenue_month,
currency,
sum(amount) as gross_revenue,
sum(amount_refunded) as refunds,
sum(amount - amount_refunded) as net_revenue,
count(*) as charge_count
from charges
group by 1, 2, 3
)
select
a.account_id,
a.account_name,
a.segment,
m.revenue_month,
m.currency,
coalesce(m.gross_revenue, 0) as gross_revenue,
coalesce(m.refunds, 0) as refunds,
coalesce(m.net_revenue, 0) as net_revenue,
coalesce(m.charge_count, 0) as charge_count
from {{ ref('stg_crm__accounts') }} a
left join monthly m
on m.stripe_customer_id = a.stripe_customer_id
The mart gets a uniqueness test on the combination of account_id, revenue_month and currency, and a reconciliation test: total gross_revenue in gold must equal the total succeeded amount in silver for charges whose customer maps to an account. Charges with no CRM account are counted in a separate unmapped_stripe_revenue model, so money never disappears in a join.
The agent also saves the two answers to shared memory. The next session that touches revenue starts with the definition instead of asking again.
The nightly automation and its Slack report
The loader finishes at about 04:30 UTC, so the automation runs at 05:00. The schedule and prompt are all it needs.
schedule: 0 5 * * * (UTC)
prompt: Run `dbt source freshness` and `dbt build --target ci` in the
analytics repo. If everything passes, do not post. If a test or
freshness check fails, find the cause, post a short report in
#data-alerts, and open a PR if the fix is a model change. Never
change bronze and never relax a test without asking.
A failing night looks like this in the channel:
Nori 05:07
dbt build failed: 1 of 43 tests.
accepted_values_stg_stripe__charges_status: 212 rows have status = 'disputed'.
First seen in the export loaded 2026-11-10 04:28 UTC. Not in the accepted list.
Disputed charges are currently excluded from fct_monthly_revenue because the
mart filters on status = 'succeeded'. Gold totals did not change.
Proposed fix (PR #482): add 'disputed' to accepted values and add an
amount_disputed column to the mart. Needs a decision: should disputed
charges reduce net revenue? I have not merged anything.
The report states what failed, since when, what the effect on gold is, and what decision a person has to make. Run history in Nori keeps every nightly session, so you can open last Tuesday's run and read exactly what it queried.
Where to go from here
- Add column-level policy, masking and certification on top of these layers with the data governance pipeline worked example.
- Look for tables and exports that bypass the layers entirely with the shadow data scan.
- See how schedules, webhooks and review fit together across a data team in agentic data operations.
- The same sessions run Claude Code, Codex, Gemini CLI or Jiro, on your own Claude or ChatGPT subscription or API keys. See Nori for data teams.
Frequently asked questions
Does the agent write directly to production tables?
No. In this setup the agent's warehouse role can read bronze and write only to a development schema. It proposes changes as pull requests. Production is built by your existing deploy job after a person merges, so your branch protection rules decide what ships.
Do I need dbt Cloud, or does dbt Core work?
Either works. The agent runs dbt commands in its own sandbox against your repository, so dbt Core with a profiles file is enough. If you use dbt Cloud, keep it as the production deploy job and let the agent run builds against a development target.
Which warehouses does this pattern work with?
Any warehouse dbt supports and the sandbox can reach, including Snowflake, BigQuery, Databricks, Redshift and Postgres. The example uses Snowflake SQL. The agent adapts functions such as timestamp conversion and deduplication to the adapter in your project.
How does the agent know our naming and testing conventions?
You write them once in the team's skillset, which Nori installs in every session. The agent also reads existing models in the repository and follows their style. Decisions made in a thread, such as how revenue is defined, are saved to shared memory for later sessions.
What happens when the nightly build fails?
The automation investigates before it reports. It posts the failing test, the first affected load, the effect on gold tables and a proposed fix in Slack. If the fix is a model change, it opens a pull request. It does not merge or relax tests on its own.
Who pays for the model usage of the nightly runs?
You do, through the Claude or ChatGPT subscription, API keys or LLM gateway you connect. Nori charges a flat plan price for machines and never charges for tokens, so a nightly run costs whatever your own model plan charges for that session.