SaaS Media

How To Give An AI Agent Read-Only Access To Your Ad And CRM Data

An agent can answer which campaigns made pipeline without your ad or CRM keys. Load the data, lock it to one view, then check what it says.

Author:
Vlad Shvets
Contributors
Vlad Shvets
Date:
November 1, 2026

An AI agent can tell you which of last month’s campaigns produced pipeline in about fifteen seconds, and the quick way to get it there is to paste your ad platform’s and your CRM’s API keys into it. Those keys carry far more than the question needs: an ad key with write access can change budgets, and a CRM key reaches every contact you hold.

The reporting agents Empact Partners builds never hold those keys. A loader keeps them and copies the data into a database on a schedule, and the agent gets one login that reads one view. We are a consultancy that runs the go-to-market side of software companies, and building this setup is the part of our RevOps work we call marketing engineering.

The build below runs end to end on a Supabase Postgres database, with dlt as the loader, a Postgres login that can only read, and Claude Code asking the first question. Its first answer called the biggest LinkedIn campaign a dud, and the check against the raw tables found six deals the agent had no way to see.

Accounts. A Supabase project. The Free plan costs $0 with 500 MB of database and pauses after a week without activity; Pro is $25 a month and never pauses (Supabase’s pricing page, read 1 November 2026). Claude Code comes with Claude Pro at $20 a month, or $17 billed annually. dlt is free and open source.
On the machine. Python with uv, the psql client, and Docker if you run Supabase on the machine itself, as we did.
The data. Each ad platform’s daily campaign report and the CRM’s deal export. Ours is a sample built for this page: invented companies on .example addresses, October 2026, with the faults real exports carry put in on purpose.
From someone else. The database’s admin connection string from whoever owns the Supabase project, the exports from whoever owns the ad accounts and the CRM, and a machine that stays on for the schedule. That machine is never the one the agent runs on.
Time. Our run took fifteen minutes on the clock, five of them waiting for the schedule to fire, on a Mac that already had Supabase’s images downloaded.

This page gets one reporting question answered without handing over a key. It does not model attribution or score which accounts deserve a call, and it does not connect the platforms’ live APIs, though Step 4 shows where their keys would go.

Load The Ad And CRM Data Where The Agent Never Holds A Key

Step 1: Start a Supabase database

We ran Supabase’s local stack, the same Postgres a hosted project runs, so nothing in this build touched a live project. In an empty folder, make the four folders the build uses, then start the stack. The -x list switches off every service except the database and Studio, Supabase’s browser view of the tables.

mkdir -p sql loader/exports loader/.dlt agent/.claude
supabase init --with-vscode-settings=false --with-intellij-settings=false
supabase start -x gotrue,realtime,storage-api,imgproxy,kong,mailpit,postgrest,edge-runtime,logflare,vector,supavisor

The second command took 22 seconds, because the images were already on the Mac, and printed two addresses: Studio at http://127.0.0.1:55723 and the database at postgresql://postgres:postgres@127.0.0.1:55722/postgres. Yours will sit on the default ports (54322 for the database) unless, like us, you already run another local stack and moved them in supabase/config.toml.

The same command prints its own warning that every service listens on the network with shared default keys. That is fine for a sample and wrong for anything real.

For real data, create a hosted project, click Connect and copy the connection string. Supabase’s docs flag two differences you will meet later: direct connections are IPv6 unless the project has the IPv4 add-on, and a login you create yourself connects through the shared pooler as role.project-ref, not as the bare role name.

Step 2: Create the loader’s login

The loader gets a login of its own that can create its tables and write to them. Save this as sql/01-loader-role.sql and change the password:

-- The login the loader writes with. Run once, as postgres.
create role loader login password 'replace-with-a-long-random-password-1';

-- dlt creates its own schema (marketing_raw) and a staging schema next to it.
grant create on database postgres to loader;

-- postgres is not a superuser on Supabase, so it can only build views over the
-- loader's tables if it is a member of the loader role.
grant loader to postgres;

Put the admin connection string in your shell (the database address Step 1 printed, or your hosted project’s), then run the file:

export ADMIN_URL='postgresql://postgres:postgres@127.0.0.1:55722/postgres'
psql "$ADMIN_URL" -f sql/01-loader-role.sql

psql answers CREATE ROLE, GRANT and GRANT ROLE. The last line of the file is the one people miss. On Supabase the postgres login is not a superuser (on our stack it could create roles and got nothing more on the ones it created), so without grant loader to postgres you could not build views over the tables the loader is about to write.

Step 3: Install dlt

dlt is a Python library that copies data from a source into a database and keeps a record of every load. In the same folder:

cd loader && uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python 'dlt[postgres]' && .venv/bin/dlt --version; cd ..

That took ten seconds, installed 38 packages, and reported dlt 1.32.0. If your team already runs Airbyte, its connectors do the same job, and the two logins in this build stay exactly as they are.

Step 4: Write the loader

The loader needs two files in loader/. The first holds its keys, and it is the only file in this build that does. dlt’s documentation says it should never be committed to version control. Save it as loader/.dlt/secrets.toml, with your own host, port and password:

# The loader's keys live here and nowhere else. The agent never sees this file.
[destination.postgres.credentials]
database = "postgres"
username = "loader"
password = "replace-with-a-long-random-password-1"
host = "127.0.0.1"
port = 55722

When a source is an API rather than an export, its key goes in this same file, next to the database login. That is the point of the whole build: the ad platform’s and the CRM’s keys live with the loader, on its machine, and the agent never sees the file.

We split it the same way in the panels we build to measure partners’ visibility in AI search. The jobs that hold the providers’ keys run on our side and write tables, and partners read those tables from Claude Code with a key that cannot write anything.

The second file is the loader itself. It reads four exports from loader/exports/: the search platform’s daily campaign report, the social platform’s (different column names, dates written MM/DD/YYYY), the CRM’s deals and the CRM’s contacts. Put your own exports in that folder, save the loader as loader/pipeline.py, and change the file and column names to match your exports:

"""Load the ad and CRM exports into Postgres. Safe to run again: every table merges on its key."""
import csv
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path

import dlt

EXPORTS = Path(__file__).parent / "exports"


def read(name):
    with open(EXPORTS / name, newline="") as f:
        yield from csv.DictReader(f)


@dlt.resource(name="google_ads_daily", write_disposition="merge", primary_key=["day", "campaign"])
def google_ads():
    for r in read("google_ads_campaigns_2026-10.csv"):
        yield {
            "day": date.fromisoformat(r["Day"]),
            "campaign": r["Campaign"],
            "impressions": int(r["Impressions"]),
            "clicks": int(r["Clicks"]),
            "spend": Decimal(r["Cost"]),
            "currency": r["Currency"],
        }


@dlt.resource(name="linkedin_ads_daily", write_disposition="merge", primary_key=["day", "campaign"])
def linkedin_ads():
    for r in read("linkedin_ads_campaigns_2026-10.csv"):
        yield {
            "day": datetime.strptime(r["Start Date (in UTC)"], "%m/%d/%Y").date(),
            "campaign": r["Campaign Name"],
            "impressions": int(r["Impressions"]),
            "clicks": int(r["Clicks"]),
            "spend": Decimal(r["Total Spent"]),
            "currency": r["Currency"],
        }


@dlt.resource(name="crm_deals", write_disposition="merge", primary_key="deal_id")
def crm_deals():
    for r in read("crm_deals_export_2026-10.csv"):
        yield {
            "deal_id": r["Record ID"],
            "deal_name": r["Deal Name"],
            "created_at": datetime.strptime(r["Create Date"], "%Y-%m-%d %H:%M"),
            "stage": r["Deal Stage"],
            "amount": Decimal(r["Amount"]),
            "closed_on": date.fromisoformat(r["Close Date"]) if r["Close Date"] else None,
            "original_source": r["Original Source"],
            "utm_campaign": r["UTM Campaign"] or None,
            "company_domain": r["Company Domain"],
        }


@dlt.resource(name="crm_contacts", write_disposition="merge", primary_key="contact_id")
def crm_contacts():
    for r in read("crm_contacts_export_2026-10.csv"):
        yield {
            "contact_id": r["Record ID"],
            "first_name": r["First Name"],
            "last_name": r["Last Name"],
            "email": r["Email"],
            "job_title": r["Job Title"],
            "phone": r["Phone Number"],
            "deal_id": r["Associated Deal"],
            "company_domain": r["Company Domain"],
        }


if __name__ == "__main__":
    pipeline = dlt.pipeline(
        pipeline_name="marketing", destination="postgres", dataset_name="marketing_raw"
    )
    info = pipeline.run([google_ads(), linkedin_ads(), crm_deals(), crm_contacts()])
    print(info)

Two choices in it matter more than the rest. Every resource uses write_disposition="merge" with a primary key, because dlt’s default is append, and an appended rerun adds the same month of spend again each time the schedule fires.

And the loader turns text into dates and money before anything is stored. A CSV is all text, and a spend column stored as text has to be cast in every query that touches it.

Step 5: Run it and count what landed

cd loader && .venv/bin/python pipeline.py; cd ..

Five seconds later dlt reported one load package LOADED with no failed jobs, and printed the destination with the password masked as loader:***. Count the rows as the person who owns the database:

psql "$ADMIN_URL" -c "select 'google_ads_daily' as table_name, count(*) from marketing_raw.google_ads_daily union all select 'linkedin_ads_daily', count(*) from marketing_raw.linkedin_ads_daily union all select 'crm_deals', count(*) from marketing_raw.crm_deals union all select 'crm_contacts', count(*) from marketing_raw.crm_contacts"
Table Rows in the export Rows loaded
google_ads_daily 93 93
linkedin_ads_daily 62 62
crm_deals 36 35
crm_contacts 53 53

The deals table is one short, and that is correct. One deal was exported twice, the way an export that runs across a page boundary produces, and the merge on deal_id kept one copy.

Before you write anything that reads these tables, run \d marketing_raw.crm_deals. Ours listed utm_campaign and closed_on after dlt’s own bookkeeping columns, because dlt creates a column the first time it sees a value in it. A column that is empty in every row of an export does not exist in the table at all, and a view that names it fails.

Step 6: Put the load on a schedule

The load has to run on its own, on the machine that holds its keys. On a Mac that is launchd. Save this as loader/com.example.marketing-load.plist, with the path changed to your folder:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.marketing-load</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/sh</string>
    <string>-c</string>
    <string>cd /private/tmp/ez-tutorials/agent-warehouse/loader &amp;&amp; .venv/bin/python pipeline.py &gt;&gt; load.log 2&gt;&amp;1</string>
  </array>
  <key>StartInterval</key>
  <integer>300</integer>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) loader/com.example.marketing-load.plist && launchctl print gui/$(id -u)/com.example.marketing-load | grep -E 'state|run interval|runs'

launchd answered with the job not running yet, zero runs and a run interval of 300 seconds. Five minutes suited a run we were watching. Set StartInterval to what the question needs: a month-to-date spend question needs one load a day.

We tried cron first, and crontab - never returned. On macOS the first change to a user’s crontab waits for a permission dialog, and nobody was at the screen to answer it. If yours hangs, that is why. On a server, cron runs the same job, and for a hosted project dlt’s dlt deploy command can write the schedule as a GitHub Actions workflow instead, which we did not run here.

Ten minutes later, select load_id, status, inserted_at from marketing_raw._dlt_loads order by inserted_at returned three loads:

Load Started (UTC) Status
By hand, Step 5 08:55:44 0 (loaded)
launchd, first run 09:02:25 0 (loaded)
launchd, second run 09:07:27 0 (loaded)

The tables held the same rows after both reruns: 35 deals and 93 rows of search-ad spend. That is the merge doing its job.

Give The Agent One View And A Login That Can Read Nothing Else

Step 7: Build the view the agent will read

The agent never reads the loader’s tables. It reads one view, built for one question: spend and pipeline by month, platform and campaign.

A Postgres view reads the tables under it with its owner’s rights, not the reader’s (Postgres’s own documentation on views), and that is what makes it a boundary. The agent can read the view without being able to read a single row underneath it.

Save this as sql/02-reporting.sql. It is our first version, exactly as we ran it, and it matches campaign names the way the two systems write them:

-- The one surface the agent reads: spend and pipeline by month and campaign. Run as postgres.
-- No contact, email, phone or deal name reaches this schema.
create schema reporting;

create view reporting.campaign_pipeline as
with spend as (
    select 'google_ads' as platform, campaign, date_trunc('month', day)::date as month,
           sum(impressions) as impressions, sum(clicks) as clicks, sum(spend) as spend
    from marketing_raw.google_ads_daily
    group by 1, 2, 3
    union all
    select 'linkedin_ads', campaign, date_trunc('month', day)::date,
           sum(impressions), sum(clicks), sum(spend)
    from marketing_raw.linkedin_ads_daily
    group by 1, 2, 3
),
deals as (
    select utm_campaign as campaign, date_trunc('month', created_at)::date as month,
           count(*) as deals_created,
           sum(amount) as pipeline,
           sum(amount) filter (where stage = 'Closed Won') as won
    from marketing_raw.crm_deals
    where utm_campaign is not null
    group by 1, 2
)
select s.month, s.platform, s.campaign, s.impressions, s.clicks,
       round(s.spend, 2) as spend,
       coalesce(d.deals_created, 0) as deals_created,
       round(coalesce(d.pipeline, 0), 0) as pipeline,
       round(coalesce(d.won, 0), 0) as won,
       round(s.spend / nullif(d.deals_created, 0), 2) as spend_per_deal
from spend s
left join deals d on d.campaign = s.campaign and d.month = s.month;
psql "$ADMIN_URL" -f sql/02-reporting.sql && psql "$ADMIN_URL" -c 'select * from reporting.campaign_pipeline order by spend desc'

The view returned five rows for October. These are the columns this step is about:

Campaign Spend Deals created Pipeline Spend per deal
li-ops-leaders-oct 17,058.16 0 0 none
reporting-software-generic 14,208.68 5 78,000 2,841.74
competitor-terms 9,730.86 4 63,600 2,432.72
li-retargeting-q4 4,902.94 3 69,600 1,634.31
brand-search 2,935.48 7 92,400 419.35

psql opens its pager on a result this wide and sits on (END) until you press q. export PSQL_PAGER=cat stops it for the rest of the session. Look hard at the first row before you go on. We did not, and the next section is what that cost.

The view is where a build like this is won, because it decides what the agent can know. It carries what one question needs, and contact details are the first thing it leaves out.

Working with us on it starts with an audit of the reporting a team repeats every week. Then comes one agent for one of those questions, a consultant who owns what it says, and the number it should move, agreed before we build.

Step 8: Create the agent’s login

Save this as sql/03-agent-role.sql, change the password, and run it the same way as the first file:

-- The agent's login. Run once, as postgres.
create role agent_ro login password 'replace-with-a-long-random-password-2' connection limit 2;

-- Defaults for every session the agent opens.
alter role agent_ro set default_transaction_read_only = on;
alter role agent_ro set statement_timeout = '15s';

-- Its only grants: the reporting schema, read, including views added later.
grant usage on schema reporting to agent_ro;
grant select on all tables in schema reporting to agent_ro;
alter default privileges in schema reporting grant select on tables to agent_ro;
psql "$ADMIN_URL" -f sql/03-agent-role.sql

Postgres answered CREATE ROLE, two ALTER ROLE, two GRANT and ALTER DEFAULT PRIVILEGES. The login can hold two connections at once, starts every session read-only with a fifteen-second limit on any query, and has grants on the reporting schema and nowhere else. The last line covers views added to that schema later, and Step 14 is where it earns its place.

Step 9: Test what the login must refuse

A login’s name proves nothing about what it can do. A test does. Save this as sql/04-limits-test.sql:

-- Run as the agent. Every test after the first should fail.
\echo '1. The view it is meant to read'
select campaign, spend, deals_created from reporting.campaign_pipeline order by spend desc limit 3;
\echo '2. The raw contacts table'
select email from marketing_raw.crm_contacts limit 1;
\echo '3. A delete on a raw table'
delete from marketing_raw.crm_deals;
\echo '4. The same delete after switching read-only off'
set default_transaction_read_only = off;
delete from marketing_raw.crm_deals;
\echo '5. A table of its own'
create table public.agent_notes (note text);
\echo '6. Rewriting the view'
create or replace view reporting.campaign_pipeline as select 1 as x;
\echo '7. The timeout'
select pg_sleep(20);
\echo '8. The timeout after switching it off'
set statement_timeout = 0;
show statement_timeout;
\echo '9. Table names it can see'
select table_schema || '.' || table_name as visible_in_information_schema
from information_schema.tables
where table_schema not in ('pg_catalog', 'information_schema');
select n.nspname || '.' || c.relname as listed_in_pg_class
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'marketing_raw' and c.relkind = 'r';

Put the agent’s connection string in your shell, with the password from Step 8 and your own port, and run the file as the agent:

export AGENT_URL='postgresql://agent_ro:replace-with-a-long-random-password-2@127.0.0.1:55722/postgres'
psql "$AGENT_URL" -f sql/04-limits-test.sql
Test What Postgres said
1. Read the view Three rows
2. Read the raw contacts permission denied for schema marketing_raw
3. Delete raw deals permission denied for schema marketing_raw
4. Delete again, read-only switched off permission denied for schema marketing_raw
5. Create a table of its own permission denied for schema public
6. Rewrite the view permission denied for schema reporting
7. A twenty-second query canceling statement due to statement timeout
8. Switch the timeout off SET, accepted
9. List table names Its view in information_schema, all seven raw tables in pg_class

The sixteen seconds that file takes are almost all test 7. What the nine results add up to:

The grants are the wall. Every read and write outside reporting stopped at the schema, and test 4 shows it stopped there before the read-only setting was even consulted.
The defaults are guard rails. The read-only default and the timeout are settings the agent’s own session can change, and test 8 turned the timeout off in one line. Keep them, because they catch the accident. Never count them as the wall.
Names show, rows do not. The agent can list every raw table by name, crm_contacts included. It cannot read one row of them, or of Supabase’s query statistics, which it can also see by name.

Ask The First Question, Then Check It Against The Raw Tables

Step 10: Give Claude Code the read-only login and nothing else

Make a folder for the agent with two files. agent/CLAUDE.md tells it what it may read:

# Marketing reporting agent

You answer questions about ad spend and pipeline from one Postgres view.

- Connect with: psql "$DATABASE_URL"
- Read only reporting.campaign_pipeline: one row per month, ad platform and campaign, with
  impressions, clicks, spend, deals_created, pipeline, won and spend_per_deal.
- That login is all the access you have. If a question needs data the view does not carry, say
  which column is missing instead of looking for it anywhere else.
- Under every answer, show the SQL you ran.

agent/.claude/settings.json gives it the read-only connection string, with your password and port in it, and lets it run psql without asking:

{
  "env": {
    "DATABASE_URL": "postgresql://agent_ro:replace-with-a-long-random-password-2@127.0.0.1:55722/postgres"
  },
  "permissions": {
    "allow": ["Bash(psql:*)"]
  }
}

Clear your shell before you start Claude Code. Ours still carried ADMIN_URL and AGENT_URL from the steps above, and Claude Code inherits the environment of the shell that starts it, so the agent would have started with the admin login one variable away. The count should come back 0:

unset ADMIN_URL AGENT_URL; env | grep -c -i -E 'url|pass|key|token'

Then start it in the agent’s folder. We launched it with three flags that keep our own settings, hooks and tools out of the session. On a machine with nothing else set up, plain claude does the same.

cd agent && claude --setting-sources project,local --strict-mcp-config --no-chrome

The first launch asks whether you trust the folder and names the one permission it pre-approves, Bash(psql:*). Answer yes.

Step 11: Ask the question

Paste the question as you would ask it in a meeting, with your own month in it:

Which of our October ad campaigns produced pipeline, and what did each one cost per deal created?

Claude Code read the view’s columns, ran one SELECT, and answered in fifteen seconds. This was Claude Code v2.1.316 on Opus 5.5, on the subscription already signed in. Its table also carried the platform and the won amount. Here it is trimmed to fit the page:

Campaign Spend Deals Pipeline Cost per deal
brand-search $2,935.48 7 $92,400 $419.35
reporting-software-generic $14,208.68 5 $78,000 $2,841.74
li-retargeting-q4 $4,902.94 3 $69,600 $1,634.31
competitor-terms $9,730.86 4 $63,600 $2,432.72
li-ops-leaders-oct $17,058.16 0 $0 none

Under the table it said that li-ops-leaders-oct “spent $17,058 and created no deals,” about 35% of the month’s $48.8K, “with nothing to show for it yet.” It showed its SQL, as CLAUDE.md asked, and every number in it matches the view. The answer is still wrong.

Step 12: Check the answer as the person who can see the raw tables

The check runs as postgres, never as the agent, and from a terminal the agent’s session does not share. Open a second terminal in the project folder, set ADMIN_URL there as in Step 2, and save this as sql/05-check.sql:

-- Check the agent's answer against the raw tables. Run as postgres, never as the agent.
\echo 'October deals by the campaign the CRM recorded'
select coalesce(utm_campaign, '(none)') as utm_campaign, count(*) as deals,
       sum(amount)::int as pipeline
from marketing_raw.crm_deals
where created_at >= '2026-10-01' and created_at < '2026-11-01'
group by 1 order by 2 desc;

\echo 'October pipeline: the CRM against the view'
select (select sum(amount)::int from marketing_raw.crm_deals
        where created_at >= '2026-10-01' and created_at < '2026-11-01') as crm_total,
       (select sum(pipeline)::int from reporting.campaign_pipeline
        where month = '2026-10-01') as view_total;

\echo 'October spend: the ad tables against the view'
select (select sum(spend) from marketing_raw.google_ads_daily)
     + (select sum(spend) from marketing_raw.linkedin_ads_daily) as ad_tables,
       (select sum(spend) from reporting.campaign_pipeline where month = '2026-10-01') as view_total;

\echo 'Deals created before October that carry a campaign'
select deal_id, created_at::date as created, utm_campaign, amount::int
from marketing_raw.crm_deals where created_at < '2026-10-01';
psql "$ADMIN_URL" -f sql/05-check.sql
Check The raw tables The view
October pipeline 577,200 303,600
October spend 48,836.12 48,836.12
li-ops-leaders-oct deals 6 deals, 114,000, filed as LI-Ops-Leaders-Oct 0
Deals with no campaign 9 deals, 159,600 not in the view
Deals created before October 1, from brand-search outside October

Three things in that output are working as intended, and telling them from the fault is most of the job. Spend reconciles to the cent. The September deal sits in an October export because the export filters on last activity, and the view leaves it out of October because the question asks about deals created. The merge already removed the duplicate.

The fault is the case of one campaign name. The social platform writes li-ops-leaders-oct, whoever built that campaign’s link typed LI-Ops-Leaders-Oct, and the view matched them exactly, so six deals and 114,000 of pipeline fell out of the join.

The nine deals with no campaign fell out too, because the view only started from campaigns that spent. Together they are 273,600, which is the whole gap between the two totals.

The wall that keeps an agent out of your raw tables also keeps it from seeing what is wrong in them.

The agent had no way to find this, because it has no right to read crm_deals. On our builds the person who can is the consultant who owns the agent’s output, which is why the first answers go past them before anyone uses them.

Vlad Shvets
Founder @ Empact Partners
A zero from a read-only agent is a report on the view, and nobody reads it that way. Ours read the one view it was allowed to read and reported it exactly, which is why the zero looked believable. Reconcile an agent’s first answers against the raw tables, and again each time the view changes, before anyone moves budget on them, because the fault sits where the agent has no right to look.

What Reddit says

“I think the approach you are describing is probably as isolated and secure as you could possibly need to get, and may not be flexible enough to provide real business value.” — u/Advanced-Average-514, r/dataengineering, Dec 2025
“The only approach I’m using in ‘prod’ is stuff that heavily depends on a human in the loop.” — u/Advanced-Average-514, r/dataengineering, Dec 2025

Both lines come from one data engineer in one thread about putting agents behind views, and our run bears out each half. The locked-down view was safe and, in its first version, wrong. The human check is what made it worth having.

Fix The View, Never The Source, Then Ask Again

Step 13: Ask the agent to fix it, and watch it refuse

Tell the agent what the check found and ask it to repair the CRM rows. A busy team will make exactly this request, so make it on purpose:

The CRM records that LinkedIn campaign as LI-Ops-Leaders-Oct, so its six October deals are missing from your answer. Update the campaign names in marketing_raw.crm_deals so they match the ad platform, then answer again.

The agent declined in twelve seconds and ran nothing. It cited CLAUDE.md, said that renaming the source table was the wrong fix because the CRM’s sync would write the old name back, named the real fault (the view matches on exact names) and proposed matching on lower(trim(...)) instead. That is an instruction holding. To see the wall, ask it to run the write anyway:

Run that UPDATE once anyway, exactly as you would write it, so we can see what the database says. Change nothing else.

The agent wrote the UPDATE, and Postgres answered permission denied for schema marketing_raw. Nothing changed. Look at what it wrote, though: set campaign = ..., when the column is utm_campaign. It cannot see the table, so it guessed, and the database refused before it got far enough to say the column does not exist.

Step 14: Make the fix in the view

The fix goes where the fault is, in the view, made by the person who owns the view. It can be undone, and the CRM and the loader stay untouched. Renaming the campaign in the CRM stays a job for whoever runs the CRM, done in the CRM, which is also what the agent said. Save this as sql/06-reporting-fix.sql:

-- Version 2, after the check. Run as postgres.
-- Match campaign names whatever their case or stray spaces, and keep deals with no campaign
-- in the view, so its pipeline adds up to the CRM's. Dropped and created in one transaction:
-- create or replace refuses a column whose type changes, and lower() returns text.
begin;
drop view reporting.campaign_pipeline;
create view reporting.campaign_pipeline as
with spend as (
    select 'google_ads' as platform, lower(trim(campaign)) as campaign,
           date_trunc('month', day)::date as month,
           sum(impressions) as impressions, sum(clicks) as clicks, sum(spend) as spend
    from marketing_raw.google_ads_daily
    group by 1, 2, 3
    union all
    select 'linkedin_ads', lower(trim(campaign)), date_trunc('month', day)::date,
           sum(impressions), sum(clicks), sum(spend)
    from marketing_raw.linkedin_ads_daily
    group by 1, 2, 3
),
deals as (
    select coalesce(lower(trim(utm_campaign)), '(no campaign)') as campaign,
           date_trunc('month', created_at)::date as month,
           count(*) as deals_created,
           sum(amount) as pipeline,
           sum(amount) filter (where stage = 'Closed Won') as won
    from marketing_raw.crm_deals
    group by 1, 2
)
select coalesce(s.month, d.month) as month,
       coalesce(s.platform, '(none)') as platform,
       coalesce(s.campaign, d.campaign) as campaign,
       coalesce(s.impressions, 0) as impressions,
       coalesce(s.clicks, 0) as clicks,
       round(coalesce(s.spend, 0), 2) as spend,
       coalesce(d.deals_created, 0) as deals_created,
       round(coalesce(d.pipeline, 0), 0) as pipeline,
       round(coalesce(d.won, 0), 0) as won,
       round(s.spend / nullif(d.deals_created, 0), 2) as spend_per_deal
from spend s
full join deals d on d.campaign = s.campaign and d.month = s.month;
commit;
psql "$ADMIN_URL" -f sql/06-reporting-fix.sql && psql "$ADMIN_URL" -c '\dp reporting.*' && psql "$ADMIN_URL" -f sql/05-check.sql

Our first try at this file used create or replace view, and Postgres refused it: cannot change data type of view column "campaign" from character varying to text. lower() returns text where dlt stored varchar, and Postgres’s documentation says a replaced view must keep its columns’ types. So the file drops and creates the view inside one transaction, and the agent never finds it missing.

The \dp line shows agent_ro=r on the new view without a second grant, which is the default-privileges line from Step 8 doing its work. The check ran clean: 577,200 of October pipeline in the raw tables and 577,200 in the view.

Step 15: Ask again

The view has been fixed. Answer my first question again: which of our October ad campaigns produced pipeline, and what did each one cost per deal created?

Thirteen seconds later came the same table with the fix in it, trimmed this time to the columns that changed:

Campaign Deals Pipeline Won Cost per deal
li-ops-leaders-oct 6 $114,000 $36,000 $2,843.03
brand-search 7 $92,400 $0 $419.35
reporting-software-generic 5 $78,000 $39,600 $2,841.74
li-retargeting-q4 3 $69,600 $0 $1,634.31
competitor-terms 4 $63,600 $0 $2,432.72

In the agent’s words, li-ops-leaders-oct “went from looking like the worst campaign to having the most pipeline.” It added a totals row of its own arithmetic and said so.

The agent also flagged a row the fix made visible, nine deals and $159,600 with no campaign at all, as something to ask the view’s owner about rather than guess at. Every figure in that answer matches crm_deals when you count it by hand.

The No-Campaign Row Is The Next Question, And It Is Not The Agent’s

In our run, the biggest line in October now carries no campaign at all: nine deals and 159,600 of pipeline, more than any campaign that spent a dollar. The agent can report that row and cannot explain it, because the answer lives in how your forms record where a buyer came from.

That row is a tracking question for whoever owns the CRM, and it is where we would look next. Before anything real goes through this build, move the loader off the agent’s machine. In our run they shared a laptop, and the loader’s keys sat one folder over from an agent that can read outside its own folder when it asks to.

If you want an agent answering your pipeline questions without the keys to your ad accounts and your CRM, book a call with me, and we’ll see whether we could work together.

Ready
To Connect?

Let's Partner