Skip to content

Nori Use cases

How to set up bronze, silver, and gold tables with Nori agents: a worked example

Raw Stripe and CRM exports land in bronze; an agent writes dbt silver models with dedupe, typing, and tests, then a gold revenue mart. A nightly automation reruns the build and reports failed tests in Slack.

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.

The three layers used in this worked example.
LayerSchemaContentsWho writes it
Bronzebronze_stripe, bronze_crmRaw exports, append-only, one row per export line, plus _loaded_at and _source_fileYour loader (Fivetran, Airbyte, or a copy job)
SilversilverOne row per business entity, typed, deduplicated, testeddbt models written by the agent, reviewed by you
GoldgoldMarts that answer a question, for example monthly revenue per accountdbt 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

  1. Land the raw data. Load the Stripe charges and customers exports and the CRM accounts export into bronze as they arrive. Do not clean anything. Add _loaded_at so duplicates from repeated exports can be resolved later.
  2. 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.
  3. 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 has unique and not_null tests, money is stored as numeric(18,2) with a currency column.
  4. 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.
  5. 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.
  6. Review the pull request. The agent runs dbt build against dbt_nori and attaches the result. You review the SQL, the tests and the profile notes, then merge. Your existing deploy job builds production.
  7. 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

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.