Marketing agencies · Data delivery

Client portal

The agency's own dashboard receives from Nekt one ready table per screen, isolated by client and with no raw personal data, and it does not break when someone touches a column.

UsageSpecific
EffortMedium
ForAgencies that give clients their own portal or app instead of Looker, spreadsheets or a reporting tool.
Source

Final media, funnel, GA4 and support tables

Rule

One table per screen, filtered by client and pseudonymized

Outcome

Upsert load into the app's landing schema

Activation

Client portal and per-client AI agent

What it solves

  • Each portal screen reads a ready table with an agreed name, columns and key.
  • No client sees another client's data, even when the source is shared.
  • Phone numbers, emails and tax IDs reach the app pseudonymized, never raw.

Business questions it answers

Ask your agent these questions once the case is live.

Is client X's portal up to date as of yesterday?
Which table feeds the ads screen of the portal?
Is any client receiving data from another account?
Which portal screens still depend on a manual load?
Does the portal number match the report we sent?

What you need

  • The final media and funnel tables already modeled in Nekt
  • A configured destination (the app's Postgres, Supabase or an HTTP endpoint) with its own landing schema
  • The registry linking each client to their accounts and their layer

What you get

  • One delivery table per screen, with a declared grain and upsert key
  • Loads triggered when the source finishes, with the last update date on every row
  • Client isolation and pseudonymization done before the data leaves Nekt
  • One table per client when each client has its own AI agent

Where to activate

Client portal or app

Each screen reads its own table in the app database, refreshed when the source finishes.

Per-client AI agent

One table per client in that client's folder, so each client's agent only sees its own data.

Freshness alert

Warning when a delivery table exceeds its source's acceptable lag.

Ready to build?

The modeling already exists in the other cases. The work is agreeing each screen's contract with whoever builds the app and keeping the isolation filters in place. The detailed view has the business rules, the pitfalls and the acceptance test. Or send everything straight to your agent with the Nekt MCP.

The problem

The client app does not query Nekt in real time: Nekt pushes ready data into the app's database. Without a contract per screen, any column change leaves the screen empty, an upsert with an incomplete key overwrites rows, and a missing filter on a shared source hands the client another business's data. None of this raises an error: the destination keeps running green.

How it is usually done, and the rule

Each row is a mistake that already happened in practice.

The common wayThe rule
01
Renaming a column in the table the app reads and finding out from the client's complaint.
The delivery table is a contract

Each screen reads a consumption table with a fixed name (for example, a mart_ prefix) and explicit, typed columns. The name and the columns are the contract with the app. New columns are added; existing columns are never renamed or retyped; a change of grain becomes a new table. Source normalization lives in stg_ tables, with no business rules.

02
Upsert by date and ad, which overwrites the same ad in another group.
The upsert key is the whole grain

Declare the upsert key with every column of the grain. On the ads screen, the key is date, client, platform, ad set and ad: the ad set is included because in Google the same ad can sit in more than one ad group. Run a uniqueness check on the key before each load.

03
Pointing the destination at the public schema of the app database.
Never write to the app's public schema

The destination writes to a landing schema (staging or landing). The app's triggers and access rules (RLS) handle deduplication, derivation and exposure from there. Writing straight to the public schema bypasses that logic and leaves raw data visible to the front end.

04
Trusting the app to filter by client after it receives the whole account's table.
Client isolation inside the transformation

A shared source (a Google Business Profile account with many locations, a Business Manager with many ad accounts) needs a filter by the client's official identifier (location_id, account_id from the registry) inside the delivery transformation itself. A forgotten filter raises no error: it delivers dozens of businesses' data to one client. When each client has its own AI agent, write one table per client in that client's folder, so the agent only sees its own dataset.

05
Sending the full phone number and hiding it on screen.
Personal data leaves pseudonymized

Pseudonymization happens in Nekt, not in the app. A phone number becomes a hash of the normalized number (digits only) plus the last 4 digits; email and tax ID become hashes; addresses and fields the dashboard does not use are not sent. If a screen needs the raw value, the discussion is about app permissions, not about the query.

06
Matching the portal client to the Nekt layer by name.
The client comes from the registry, not the layer name

Link client and layer through an identity column in the client registry (for example, client_slug or the Nekt layer recorded in the portal), with at most one client per layer. Layer names cannot be renamed and drift from the client's commercial name.

07
Scheduling the load at 7 a.m. and hoping the source has finished.
Load on event, not on the clock

The delivery transformation fires when the run of the source it reads finishes, so it never runs before extraction is done. A fixed schedule only where the destination has its own window. A manual trigger is not a live pipeline and must be flagged as such.

08
Showing the number without saying how recent it is.
Freshness inside the table

Every delivery table carries the load timestamp and the latest data date. The copy of the portal's client registry in Nekt can lag up to 24 hours: before calling a mismatch between portal and Nekt, check the source's last run.

09
One column per platform with different names, or zero where the metric does not exist.
Same metric, same name on every platform

Metric columns use the same names as the portfolio media table (spend, clicks, leads, conversions), so the app can reuse its mapping. Each platform fills its own columns and leaves the others null, never zero. Values are already in reais, converted before delivery.

10
Saving the temporary thumbnail and watching the image break a few days later.
Thumbnails with links that do not expire

The ad image comes from adimages.permalink_url in Meta, described as a permanent link. Other thumbnail links expire. Flag on each row whether the thumbnail is permanent, so the app knows when to fetch again.

From warehouse to screen

Layers that separate business rules from what the app reads.

stg_<source>Normalization of one source, one grain per row, no business rules
mart_<screen>The exact shape of the screen: the contract with the app
filter and pseudonymizationOnly the screen's client, no raw personal data
destination in staging or landingUpsert by the grain keydate, client, platform, adset_id, ad_id
app triggers and RLSThe app deduplicates and exposes
public schemaNever write here directly

Definitions

Load contract
delivery table name, typed column list, grain and upsert key of a screen, agreed with whoever builds the app
Upsert key
set of columns that identifies a row of the grain; no combination may repeat in the delivery table
Portal client
client identifier in the portal registry, linked to a single Nekt layer
Client-safe row
row that only holds data for the screen's client and no raw personal data
Pseudonymized phone
hash of the digits-only number, plus the last 4 digits
Freshness
last load timestamp and latest data date, per delivery table
Acceptable lag
interval between source runs plus an agreed margin (e.g. 24 hours for a daily source)

Pitfalls that already happened

Blank screen after a tweak

A column was renamed in the delivery table. The destination kept delivering and the app started reading a null field, with no error at all.

Treat the table as a contract: add columns, never rename.

Other businesses' reviews on the dashboard

The agency's Google Business Profile account covered dozens of locations. Without the location_id filter, one client's dashboard would show every location's reviews.

Filter by the client's official id inside the delivery transformation.

Ad overwritten by the upsert

In Google, the same ad appeared in two ad groups. With the key missing the group, the second row erased the first.

Include the ad set or ad group in the upsert key.

Portal and Nekt with different portfolios

Edits made in the portal after the daily extraction only showed up in Nekt the next day, and client count and MRR diverged for a day.

Check the source's last run before calling a mismatch.

Dashboard stalled by a source in another layer

One client's dashboard depended on a connector registered in the agency's own layer. When it failed, the investigation only looked at the client's layer.

Document the source layer of each delivery table.

Two destinations for the same screen

Two active destinations delivered the same data to the same app, one newer than the other. Changing the wrong one had no effect.

One destination per delivery table, with the old one switched off.

Acceptance test

The case is done when every item passes.

Agent package

Paste into Claude, ChatGPT or any client with the Nekt MCP connected.

Full Markdown
Client portal
- The portal does not read Nekt in real time. Nekt delivers consumption tables (mart_<screen>) into the landing schema of the app database, by upsert.
- Each delivery table is a contract: fixed name, columns, grain and key. Do not rename columns; a grain change is a new table.
- Upsert key for the ads screen: date, client, platform, adset_id, ad_id.
- The destination never writes to the app's public schema.
- Every delivery table is filtered by the client's official id (account_id, location_id) inside the transformation. A shared source without a filter is a leak.
- Phone numbers leave as a hash of the normalized number plus the last 4 digits. Email and tax ID leave as hashes. Addresses do not leave.
- The client is linked to the layer by the registry's identity column, never by name.
- To answer whether the portal is up to date, read the last load date and the latest data date of the delivery table.
- One metric name across platforms; null where the platform does not measure it.
Client exceptions:
- <client>: destination <destination>, schema <schema>, screens <list of mart_ tables>.
- <client>: reviews filtered by location_id <id> from the agency's shared account.

Variations

HTTP endpoint

App without a reachable database: send the table in batches (e.g. 1,000 rows per call), with the token kept in Nekt's secrets vault, never in the code.

Old tool's template

To reuse a ready Looker dashboard, deliver the table with the same field names as the connector that used to feed the template.

The client's business data

The same pattern works for screens beyond marketing, such as reviews, support or renewals, always with one table per screen.