# API Keys
Source: https://docs.narrative.io/account-settings/api-keys
Create, manage, and secure API keys for authenticating requests to the Narrative API
API keys authenticate requests to the Narrative API. You can use them when calling APIs directly or when configuring other systems to make requests on your behalf.
## Viewing your API keys
Navigate to **Settings > API Keys** to view your existing API keys. You'll see a list of your keys along with their expiration dates and assigned permissions.
## API key permissions
Each API key permission is a combination of an **access level** (Read or Write) and a **resource** (a functional area of the platform like Datasets, Connections, or Jobs). A key can hold multiple permissions, and they are additive—each one grants additional capabilities.
For a deeper explanation of how permissions work, see [API Key Permissions](/concepts/security/api-key-permissions). For a complete list of all resources and access levels, see the [Permissions Reference](/reference/security/permissions).
**Use minimum permissions**
When creating an API key, grant only the permissions necessary for its intended function. This security best practice helps protect your data and account from unauthorized access.
### Choosing permissions for common use cases
Use the table below to determine which permissions to assign based on what your key needs to do.
| Use case | Recommended permissions |
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Read-only analytics** | Read on Datasets, Queries, and Subscriptions |
| **Data ingestion** | Write on Datasets and DataStreams |
| **Workflow automation** | Read and Write on Workflows and Jobs |
| **Connector delivery** | Read and Write on Connections and Destinations |
| **SDK development** | Read and Write on the resources your application uses (see [SDK Authentication](/guides/sdk/authentication)) |
## API key expiration
API keys can have an expiration date, after which they're no longer valid.
**Set expiration dates and rotate keys regularly**
We highly recommend setting an expiration date for all API keys and rotating them on a regular schedule.
## Creating a new API key
Navigate to **Settings > API Keys**.
Click **New API Key**.
Enter a name for the key, select the permissions you want to grant, and set an expiration date. See [common use cases](#choosing-permissions-for-common-use-cases) for guidance on which permissions to select.
Copy and securely store the API key. You won't be able to view the full key again after this step.
## Revoking an API key
To revoke an API key:
1. Navigate to **Settings > API Keys**.
2. Click the **...** menu next to the key you want to revoke.
3. Select **Delete**.
The key is immediately revoked and can no longer be used to make requests to the Narrative API.
## Related content
Understand how scoped permissions protect your resources
All available permission resources and access levels
Configure API key authentication for the TypeScript SDK
How Narrative protects your data
## Responding to a security incident
If you suspect your API keys have been compromised:
1. **Revoke all affected API keys immediately** using the steps above.
2. **Create new API keys** with appropriate permissions.
3. **Report the incident** to Narrative security so we can help you investigate and take appropriate action.
# SSO Configuration
Source: https://docs.narrative.io/account-settings/sso
Configure SAML 2.0 single sign-on for your Narrative I/O organization
This guide walks enterprise administrators through configuring SSO for their organization. Once configured, your team members can authenticate to Narrative using your corporate identity provider.
To understand how SSO works and why organizations use it, see [Single Sign-On (SSO)](/concepts/security/sso).
## Prerequisites
Before you begin, ensure:
* Your organization has an enterprise contract that includes SSO
* Narrative has provisioned your organization and provided your **Organization Slug** (e.g., `yourcompany`)
* You have received an email invitation from Narrative and completed initial registration
* Your Narrative account has the **Stytch Admin** role (contact Narrative support if unsure)
* You have administrative access to your organization's identity provider
## First-time login
Before SSO is configured, you'll use a magic link to access the admin portal.
Navigate to [app.narrative.io/platform/login](https://app.narrative.io/platform/login) and click **Enterprise Login** at the bottom of the login page.
Enter your organization's slug (provided by Narrative) to identify which organization you're authenticating against.
Enter your email address and click **Send Magic Link**. Check your email for the login link and click it to complete authentication.
Once SSO is configured, you'll authenticate directly through your organization's identity provider instead.
## Configure your SSO connection
### Gather IdP information
From your identity provider (Okta, Azure AD, etc.), collect:
| Information | Description |
| --------------------- | --------------------------------------------- |
| **IdP SSO URL** | The endpoint where SAML requests are sent |
| **IdP Entity ID** | Unique identifier for your IdP |
| **X.509 Certificate** | Public certificate for signature verification |
### Create the connection in Narrative
In Narrative, go to **Settings > Enterprise Login Settings**.
Click **New SSO Connection**.
Enter the IdP SSO URL, Entity ID, and X.509 certificate you collected.
## Configure your identity provider
In your identity provider, create a new SAML application with the following settings from Narrative:
| Field | Description | Where to find |
| -------------------------------- | ------------------------------------------------------- | -------------------------------------------- |
| **ACS URL / Single Sign-On URL** | The endpoint where your IdP sends SAML assertions | Narrative UI (SSO Settings > New Connection) |
| **Entity ID / Audience URI** | Unique identifier for Narrative as the service provider | Narrative UI (SSO Settings > New Connection) |
| **Name ID Format** | Must be set to `EmailAddress` | — |
### Okta configuration
In Okta, when creating the SAML application:
1. Check **Use this for Recipient URL and Destination URL** when entering the Single sign-on URL
2. Set **Application username** to `Email`
3. Set **Update application username on** to `Create and update`
Configure these attribute statements with **Name format** set to `Basic`:
| Attribute name | Value |
| -------------- | ---------------- |
| `first_name` | `user.firstName` |
| `last_name` | `user.lastName` |
| `id` | `user.id` |
### Azure AD (Microsoft Entra ID) configuration
Configure these attribute statements:
| Attribute name | Value |
| -------------- | ---------------- |
| `first_name` | `user.givenname` |
| `last_name` | `user.surname` |
| `id` | `user.objectid` |
### OneLogin configuration
Configure these attribute statements:
| Attribute name | Value |
| -------------- | ------------- |
| `first_name` | `{firstname}` |
| `last_name` | `{lastname}` |
| `id` | `{id}` |
**Attribute names must match exactly**
The attribute names sent to Narrative (`first_name`, `last_name`, `id`) must match exactly as shown. Attribute value syntax varies by identity provider—consult your IdP's documentation if you encounter issues.
## Complete the SSO connection
After creating the SAML application in your IdP:
Find the **Metadata URL** (or download the metadata XML) from your identity provider.
* **Okta**: Find the Metadata URL in the **Sign On** tab. Click **Actions** under SAML Signing Certificates and select **View IdP metadata**.
* **Azure AD**: Download the **Federation Metadata XML** from the SAML configuration page, or use the **App Federation Metadata Url**.
Return to Narrative's SSO Settings tab, paste the Metadata URL, and click **Create**.
Your SSO connection should appear in the list with status **Active**.
## Assign users in your IdP
Users must be explicitly assigned to the Narrative SAML application in your IdP before they can authenticate.
**Okta**: Go to **Applications > Your Narrative App > Assignments**. Click **Assign > Assign to People** (or **Assign to Groups** for bulk assignment).
**Azure AD**: Go to **Enterprise Applications > Your Narrative App > Users and groups**. Click **Add user/group** and select users or groups.
Users not assigned to the application will see an error: "User is not assigned to this application."
## Enforce SSO-only authentication
By default, Narrative allows multiple authentication methods (email magic links and SSO). To require SSO for all users:
Go to **Settings > Enterprise Login Settings > Organization Settings**.
Under **Authentication settings**, click **Edit**.
Uncheck **Allow all primary auth methods** to reveal individual method checkboxes. Check only **Single Sign-On** and leave **Email Magic Links** unchecked.
Click **Save**.
**SSO-only mode**
All users in your organization will be required to authenticate via SSO. Email magic links will no longer work for standard users.
## Just-in-time (JIT) provisioning
JIT provisioning is enabled by default. When enabled, any user assigned to your IdP's SAML application can automatically create a Narrative account on their first login—no invitation required.
To disable JIT provisioning:
1. Go to **Organization Settings > User onboarding**
2. Click **Edit**
3. Under **JIT Provisioning**, uncheck SSO connections
4. Click **Save**
When JIT is disabled, users must be explicitly invited via the Member Management tab before they can access Narrative.
## Managing members
### Inviting members
To invite a new user (when JIT is disabled, or to pre-provision users):
1. Go to **Member Management**
2. Click **Invite**
3. Enter the user's name and email address
4. Optionally assign a role (leave blank for standard user access)
5. Click **Invite**
The user will receive an email invitation. When they click the link and authenticate via SSO, their account will be activated.
The invited email address must match the email configured in your identity provider.
### Revoking access
To remove a user's access:
1. In your identity provider, remove the user from the Narrative SAML application (or remove them from an assigned group)
2. The user will immediately lose access and see an error on their next login attempt
You can also archive the member in Narrative's **Member Management** tab to fully remove their account from the platform.
## API token security
**API tokens are not affected by SSO**
Long-lived API tokens (provisioned under **Settings > API Keys**) are not tied to an individual user and are not automatically revoked when a user's SSO access is revoked.
If a user leaves your company or should no longer have access to a long-lived API token, you must manually revoke that API token. See [API Keys](/account-settings/api-keys) for instructions.
## Troubleshooting
| Issue | Solution |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| "User is not assigned to this application" | Add the user to the Narrative SAML application in your identity provider |
| "This project is not authorized to call this endpoint" | Contact Narrative support—SSO products may not be enabled for your organization |
| User prompted to register after clicking invite link | This is expected for first-time users. They'll complete a brief registration flow before accessing the platform |
## Support
For assistance with SSO configuration, contact Narrative support at [support@narrative.io](mailto:support@narrative.io) or reach out to your account representative.
If you use an identity provider not covered in this guide, our team can provide configuration assistance specific to your IdP.
## Related content
Understand how SSO works and why organizations use it
Create and manage API keys for programmatic access
# Create a new Access Rule
Source: https://docs.narrative.io/api-reference/access-rules/create-a-new-access-rule
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /v2/access-rules
Creates a new access rule based on the provided NQL query.
# Delete an access rule
Source: https://docs.narrative.io/api-reference/access-rules/delete-an-access-rule
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /v2/access-rules/{access_rule_id}
Access rule deletion cannot be undone.
# Get all accessible Access Rules (V2)
Source: https://docs.narrative.io/api-reference/access-rules/get-all-accessible-access-rules-v2
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/access-rules
Get all accessible Access Rules.
'owned_only' and 'owned_only' are flags, they don't require values.
'tag', 'dataset_id', and 'company_id' are multi-params.
Query like 'tag=tag1&tag=tag2&company_id=1&company_id=2' is interpreted as "access rules with tags that comprise 'tag1' or 'tag2' AND belonging to companies with id '1' or '2'.
# Get an Access Rule by id (V2)
Source: https://docs.narrative.io/api-reference/access-rules/get-an-access-rule-by-id-v2
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/access-rules/{access_rule_id}
Get an access rule.
# Get an Access Rule by name (V2)
Source: https://docs.narrative.io/api-reference/access-rules/get-an-access-rule-by-name-v2
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/access-rules/{company_slug}/{access_rule_name}
Get an access rule by name and company slug.
# Update an Access Rule by id (V2)
Source: https://docs.narrative.io/api-reference/access-rules/update-an-access-rule-by-id-v2
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /v2/access-rules/{access_rule_id}
Update an access rule.
# Create a System Access Token
Source: https://docs.narrative.io/api-reference/access-tokens/create-a-system-access-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /access-tokens/tokens
Create a System Access Token
# Delete a System Access Token
Source: https://docs.narrative.io/api-reference/access-tokens/delete-a-system-access-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /access-tokens/tokens/{access_token_id}
Delete a System Access Token
# Get a System Access Token by id
Source: https://docs.narrative.io/api-reference/access-tokens/get-a-system-access-token-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /access-tokens/tokens/{access_token_id}
Get a System Access Token by id
# List System Access Tokens
Source: https://docs.narrative.io/api-reference/access-tokens/list-system-access-tokens
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /access-tokens/tokens
List System Access Tokens
# Update a System Access Token
Source: https://docs.narrative.io/api-reference/access-tokens/update-a-system-access-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /access-tokens/tokens/{access_token_id}
Update a System Access Token
# Create an agent conversation
Source: https://docs.narrative.io/api-reference/agent-conversations/create-an-agent-conversation
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /agents/conversations
Creates a new agent conversation with a pinned `system_prompt` and a `defaults` block
that every subsequent run inherits. The conversation starts at `version: 0` — your
first run's `expected_version` will be `0`.
## What this endpoint actually does
1. Validates the `defaults` block. Failures here (alias regex, duplicate aliases,
wire-name length, missing required fields) return 400 immediately — the
conversation row is **not** created on validation failure.
2. Inserts a row in `agent_conversations` with `version = 0`, `company_id` from your
bearer token, and the validated `defaults` serialized as JSONB.
3. Returns the persisted conversation echo.
No workflows are started here. The conversation is a long-lived container; the
actual model execution happens later, in `POST /agents/conversations/{id}/runs`.
## What you cannot change later
- **`system_prompt`** — pinned for the conversation's lifetime. If you need a
different system prompt, create a new conversation.
- **`company_id`** / **`user_id`** — derived from the bearer token at creation time;
not exposed in the request body. Both gate every subsequent read endpoint on this
conversation: peers in the same company **cannot** see each other's conversations,
runs, or messages. Cross-user access surfaces as 404 — the API does not
distinguish "doesn't exist" from "owned by another user".
Everything else in `defaults` (model, max_iterations, tools, etc.) is just a default
— any individual run can override any of those fields via `config_override` on its
`POST .../runs` body. See [Agent Conversations
Reference](https://docs.narrative.io/reference/architecture/agent-conversations#run)
for the override semantics.
## Permission
`agent_conversations` resource with `write` verb. The same permission gates
`POST .../runs`; `read` is required for the GET endpoints.
## Example: minimal "hello world" conversation (no tools)
```json
{
"name": "hello world",
"system_prompt": "You are a helpful assistant. Answer concisely.",
"defaults": {
"model": "anthropic.claude-opus-4.6",
"data_plane_id": "f79cbdae-4848-47ca-95e8-69588364d185",
"execution_cluster": "shared",
"max_iterations": 3,
"max_tokens": 1024,
"temperature": 0.0,
"output_format_schema": {
"type": "object",
"additionalProperties": false,
"required": ["text"],
"properties": { "text": { "type": "string" } }
}
}
}
```
## Example: conversation that can search the Narrative docs
The `mcp_servers` entry registers two tools the model can call server-side. The
platform handles the JSON-RPC calls to `https://docs.narrative.io/mcp` transparently.
```json
{
"name": "docs search",
"system_prompt": "Use the available tools to look things up before answering.",
"defaults": {
"model": "anthropic.claude-opus-4.6",
"data_plane_id": "f79cbdae-4848-47ca-95e8-69588364d185",
"execution_cluster": "shared",
"max_iterations": 8,
"max_tokens": 2048,
"temperature": 0.0,
"output_format_schema": {
"type": "object",
"additionalProperties": false,
"required": ["text"],
"properties": { "text": { "type": "string" } }
},
"mcp_servers": [{
"alias": "docs",
"url": "https://docs.narrative.io/mcp",
"description": "Narrative docs MCP server"
}]
}
}
```
## Example: conversation with a caller-declared tool (human in the loop)
Caller-declared `tools[]` entries cause the model's call to **pause** the run with
`status: requires_action`. The caller answers by posting a follow-up run with
`payload.kind: tool_outputs`. These tools have no alias — the model sees the bare
`name`, and the platform routes any dash-free tool call to caller-declared resolution.
```json
{
"name": "booking flow",
"system_prompt": "You are a booking assistant.",
"defaults": {
"model": "anthropic.claude-opus-4.6",
"data_plane_id": "f79cbdae-4848-47ca-95e8-69588364d185",
"execution_cluster": "shared",
"max_iterations": 5,
"output_format_schema": {
"type": "object",
"additionalProperties": false,
"required": ["text"],
"properties": { "text": { "type": "string" } }
},
"tools": [{
"name": "confirm_booking",
"description": "Ask the user to confirm a proposed booking slot.",
"input_schema": {
"type": "object",
"additionalProperties": false,
"required": ["proposed_slot"],
"properties": { "proposed_slot": { "type": "string" } }
}
}]
}
}
```
## Common 400 errors
| `type` URL slug | Cause |
|---|---|
| `/errors/invalid-tool-alias` | MCP alias doesn't match `^[a-zA-Z][a-zA-Z0-9]{0,7}$` |
| `/errors/duplicate-tool-alias` | Same alias appears twice across `mcp_servers` |
| `/errors/invalid-caller-tool-name` | Caller-declared `tools[].name` is empty, contains a dash, or exceeds 64 chars |
| `/errors/duplicate-caller-tool-name` | Same `tools[].name` appears twice |
| `/errors/tool-name-too-long` | Some MCP wire name `{alias}-{tool_name}` > 64 chars |
See [Agent Conversations error
catalog](https://docs.narrative.io/reference/architecture/agent-conversations/errors/conversation-not-found)
for the full list.
# Get a run's current state
Source: https://docs.narrative.io/api-reference/agent-conversations/get-a-runs-current-state
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /agents/runs/{id}
Returns the run's current state — status, iterations used so far, token usage,
submitted inference job IDs, the model's final answer (if completed), pending tool
calls (if requires_action), or the error (if failed).
## Polling
Runs are asynchronous. After `POST /agents/conversations/{id}/runs` you poll this
endpoint until you see a terminal status (`completed`, `requires_action`, or
`failed`). A reasonable polling cadence:
- Start with 2-second polls — most simple runs complete in a single iteration
(5–15 seconds wall-clock).
- Back off to 4–8 seconds for longer-running runs (multi-iteration tool loops).
- Hard-cap at maybe 5 minutes — if a run hasn't terminated by then either something
is wrong (check `submitted_inference_job_ids` in the data plane's `jobs` table) or
the workflow timed out and will land `failed` shortly.
## Which fields are populated when
| `status` | Populated fields |
|---|---|
| `pending` | `id`, `conversation_id`, `client_op_id`, `effective_config`, `started_at` |
| `running` | above + `submitted_inference_job_ids` (one per iteration completed) |
| `completed` | above + `iterations_used`, `usage`, exactly one of `final_text` / `final_structured_output`, `completed_at` |
| `requires_action` | above + `iterations_used`, `usage`, `pending_tool_calls`, `completed_at` |
| `failed` | above + `error`, possibly partial `iterations_used`/`usage`, `completed_at` |
`effective_config` is **always** populated — it's the fully-merged config used for
this run (conversation defaults + this run's `config_override`). Helpful for
confirming the model saw what you expected.
## Run vs. inference iteration
Each run runs N iterations, where N ≤ `effective_config.max_iterations`. Each
iteration is one inference job submitted to the data plane (one Bedrock or Cortex
API call). `submitted_inference_job_ids` carries the platform's `jobs.id` for each
iteration — use those to drill into the raw model request/response in the data
plane's logs / job table.
`iterations_used` and `submitted_inference_job_ids.length` match for any terminal
state.
## Error structure
On `status: "failed"` the `error` field carries:
- `type` — opaque incident code (e.g. `AgentLoopMaxIterationsExceeded`,
`AgentLoopInvalidEffectiveConfig`, `UnknownTool`). Stable across versions, used
for log correlation.
- `message` — human-readable detail.
- `title` and `docs_url` — caller-facing presentation looked up from the platform's
[error catalog](https://docs.narrative.io/reference/architecture/agent-conversations/errors/unexpected).
Absent if the API doesn't recognize the `type` (usually because the workflow
workers have been upgraded ahead of the API); in that case rely on `type` and
`message`.
Note: this endpoint always returns 200 for a known run — even one with `status:
"failed"`. The HTTP-level error path (RFC 7807) only applies when the call itself
fails (token invalid, run not found, etc.).
## Scope: per-user, not per-company
Runs are scoped to the **`(company_id, user_id)`** pair that created them. Returns
404 for "run does not exist", "run belongs to a different company", and "run was
created by a different user in the same company" alike — the API does not
distinguish them to avoid leaking existence.
## Permission
`agent_conversations` resource with `read` verb.
## Example: completed run
```json
{
"id": "82eb9cb7-619e-46bb-ac1c-8a32b0112c1c",
"conversation_id": "bc2505b7-068d-44ed-8055-a6f6ffe54ab1",
"company_id": 1,
"user_id": 407,
"client_op_id": "696b022c-e86d-4b3e-b6d7-ceb7eb1a495e",
"status": "completed",
"tool_choice": null,
"effective_config": { "...": "..." },
"iterations_used": 1,
"usage": { "prompt_tokens": 169, "completion_tokens": 8, "total_tokens": 177 },
"submitted_inference_job_ids": ["e94c6eab-019f-4e96-bb47-049fa60e7988"],
"pending_tool_calls": [],
"final_text": "4",
"final_structured_output": null,
"error": null,
"started_at": "2026-05-18T13:21:30.355180Z",
"completed_at": "2026-05-18T13:21:51.983952Z"
}
```
## Example: completed structured-output run
```json
{
"id": "8dda70ab-9f81-4613-a3c9-68b3b584e82d",
"status": "completed",
"iterations_used": 1,
"final_text": null,
"final_structured_output": {
"event": {
"type": "login",
"user_id": "u-123",
"session_duration_seconds": 1800
}
},
...
}
```
## Example: requires_action run
```json
{
"id": "19082f49-0280-4e32-a8b4-7df8dea9dbed",
"status": "requires_action",
"iterations_used": 1,
"pending_tool_calls": [{
"tool_use_id": "tooluse_DWXPKZ50JDGib5GmShyUgJ",
"name": "confirm_booking",
"arguments": { "proposed_slot": "tomorrow afternoon" }
}],
"final_text": null,
"final_structured_output": null,
...
}
```
Resume by posting a new run with `payload.kind: "tool_outputs"` carrying the same
`tool_use_id`.
## Example: failed run
```json
{
"id": "...",
"status": "failed",
"iterations_used": null,
"usage": null,
"final_text": null,
"final_structured_output": null,
"error": {
"type": "AgentLoopMaxIterationsExceeded",
"message": "max iterations exceeded after 5 iterations",
"title": "Max Iterations Exceeded",
"docs_url": "https://docs.narrative.io/reference/architecture/agent-conversations/errors/max-iterations-exceeded"
},
...
}
```
# Get an agent conversation
Source: https://docs.narrative.io/api-reference/agent-conversations/get-an-agent-conversation
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /agents/conversations/{id}
Fetches the conversation's metadata plus its current `version`. Use the `version`
immediately before posting a run as `expected_version` — that's how the platform's
compare-and-swap guard knows whether the conversation has advanced since you last
looked.
## When to call this
- Before starting a new run: read `version`, post it as `expected_version`. If you
cache the value across long delays you'll hit
[Version Conflict](https://docs.narrative.io/reference/architecture/agent-conversations/errors/version-conflict)
more often.
- When resuming a `requires_action` run: the conversation's version has advanced
because the assistant turn was persisted on the run row's finalize. Re-read it
before posting `tool_outputs`.
- To inspect a conversation's `defaults` if you've forgotten what was pinned.
## What you cannot do here
There is no `PATCH` / `PUT` for conversations. The `system_prompt` and `defaults` are
immutable at the conversation level. To change behavior, override per-run via
`config_override`, or create a new conversation.
## Scope: per-user, not per-company
Conversations are scoped to the **`(company_id, user_id)`** pair that created them.
Peers in the same company cannot see each other's conversations — the endpoint
returns 404 for "does not exist", "belongs to a different company", and "belongs to
a different user in the same company" alike. This is deliberate: distinguishing them
would leak existence.
## Permission
`agent_conversations` resource with `read` verb.
# List conversation messages (delta read by version cursor)
Source: https://docs.narrative.io/api-reference/agent-conversations/list-conversation-messages-delta-read-by-version-cursor
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /agents/conversations/{id}/messages
Returns the messages in this conversation with `sequence_no > since`, plus the
conversation's `current_version` at the moment the response was generated. The
`messages` list is ordered by `sequence_no` ascending and is gap-free — if the
response contains messages with `sequence_no` 3, 4, 5, you have all three.
## Polling pattern
```text
since = 0
loop forever:
r = GET /agents/conversations/{id}/messages?since={since}
process r.messages # may be empty
since = r.current_version # use the head, not r.messages.last.sequence_no
wait briefly
```
`current_version` is the conversation's head at the moment the response was generated
— using it as the next request's `since` is the only way to ensure gap-free reads
even when new messages land between the time the response was generated and the time
your client processed it.
## When messages get added
Messages are persisted in a single transaction by the workflow's finalize step (when
a run reaches a terminal state). You won't see partial reads — a run that produces 4
messages either lands all 4 or none.
A run's `id` appears in `messages[].run_id`, so you can correlate messages to the
runs that produced them.
## What's in `content_blocks`
Three discriminated variants:
- `text` — plain text. The dominant variant.
- `tool_use` — the model requesting a tool call. Carries `tool_use_id`, the aliased
`name`, and the model's `arguments`.
- `tool_result` — the result of a tool call, nested as `content_blocks` inside the
`tool_result` block. Carries the original `tool_use_id` plus an `is_error` flag.
The same `tool_use_id` appears on the assistant's `tool_use` block and on the
subsequent `tool` (or `user`, for resume payloads) turn's `tool_result` block —
that's how a UI matches them up.
## Permission
`agent_conversations` resource with `read` verb.
## Example: paging from scratch
```bash
curl "$API/agents/conversations/{id}/messages?since=0" \
-H "Authorization: Bearer $TOKEN"
# → { "current_version": 4, "messages": [ <4 entries> ] }
```
Then later:
```bash
curl "$API/agents/conversations/{id}/messages?since=4" \
-H "Authorization: Bearer $TOKEN"
# → { "current_version": 4, "messages": [] } # nothing new
```
# List the calling user's agent conversations
Source: https://docs.narrative.io/api-reference/agent-conversations/list-the-calling-users-agent-conversations
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /agents/conversations
Returns a paginated list of conversations created by the bearer-token's user, newest
first (by `created_at`).
## Scope: per-user, not per-company
Results are filtered by **both** `company_id` and `user_id` from the bearer token —
peers in the same company are **not** visible. The endpoint never returns 404; the
natural empty state is `records: []` with `total_records: 0`.
## Pagination
Uses the standard `page` / `per_page` query parameters. Default `per_page` is 50.
The response envelope carries `prev_page`, `current_page`, `next_page`,
`total_records`, `total_pages`, and the `records` array — same shape every other
paginated Narrative endpoint uses.
## Permission
`agent_conversations` resource with `read` verb.
## Example
```bash
curl "$API/agents/conversations?page=1&per_page=20" \
-H "Authorization: Bearer $TOKEN"
```
# Start a new run on the conversation
Source: https://docs.narrative.io/api-reference/agent-conversations/start-a-new-run-on-the-conversation
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /agents/conversations/{id}/runs
Inserts a row in `agent_runs`, kicks off the workflow that drives the agent loop, and
returns immediately. The response has `status: "pending"` — poll
`GET /agents/runs/{id}` to track progress.
Runs are **asynchronous**. This endpoint does not wait for the model to reply.
Typical end-to-end latency is 3–10 seconds for a single-iteration run, longer for
multi-iteration tool-use loops (60–120 seconds is common for 6–8 iterations against
an MCP server).
## Required fields
- **`client_op_id`** — UUID you generate. Used as the idempotency key, scoped to
`(conversation_id, client_op_id)`. Re-sending the same value returns the original
run row unchanged. Always generate a fresh UUID for a fresh logical request;
reusing one is only safe if the second call is a retry of the first.
- **`expected_version`** — the conversation's current version, read from
`GET /agents/conversations/{id}` just before this call. If it doesn't match at
run-creation time, you get a 409
[Version Conflict](https://docs.narrative.io/reference/architecture/agent-conversations/errors/version-conflict).
Recovery: refetch the conversation, re-read `version`, retry the run with the
fresh value.
- **`payload`** — `kind: "user_message"` to start a fresh user turn, or
`kind: "tool_outputs"` to resume a previously-paused (`requires_action`) run.
## Optional fields
- **`tool_choice`** — bias the model toward (or away from) using tools on iteration 1
**of this run**. Three values: `auto`, `any`, `specific_tool` (see schema). Applies
only to iteration 1 of this run; iteration 2+ are back on `auto`. To re-force a
specific tool on the next run, set `tool_choice` again on its request body.
- **`config_override`** — sparse override of the conversation's `defaults`. Any field
you set replaces the conversation-level value for this single run. The two list
fields (`mcp_servers`, `tools`) are **wholesale-replaced**, not merged. The
`system_prompt` cannot be overridden.
## Run lifecycle (state diagram)
```text
pending → running → completed ← model produced a final answer
↘ requires_action ← model called a caller-declared tool
↘ failed ← non-recoverable error
```
You stop polling on any of the three terminal states. For `requires_action`, the run
can be **resumed** by posting a new run on the same conversation with
`payload.kind: "tool_outputs"`. For `completed` and `failed`, the run is done.
## Examples
### Example 1 — fresh user message, no tools
```json
{
"client_op_id": "696b022c-e86d-4b3e-b6d7-ceb7eb1a495e",
"expected_version": 0,
"payload": { "kind": "user_message", "text": "What is 2 + 2?" }
}
```
Returns:
```json
{
"id": "82eb9cb7-619e-46bb-ac1c-8a32b0112c1c",
"status": "pending",
"started_at": "2026-05-18T13:21:30.355180Z",
"submitted_inference_job_ids": [],
"pending_tool_calls": [],
"effective_config": { ... },
...
}
```
Then poll `GET /agents/runs/{id}` until `status: completed`. Final
`final_text: "4"`.
### Example 2 — force a specific tool, override max_iterations
```json
{
"client_op_id": "c8aef867-eca6-46f7-8a99-e22dd0ee6062",
"expected_version": 0,
"payload": { "kind": "user_message", "text": "Find me a meeting time for tomorrow afternoon." },
"tool_choice": { "kind": "specific_tool", "name": "confirm_booking" },
"config_override": { "max_iterations": 10 }
}
```
The model is forced to call `confirm_booking` on iteration 1. Since `confirm_booking`
is a caller-declared tool (dash-free name, listed in `tools[]`), the run terminates
with `status: requires_action` after one iteration; `pending_tool_calls` contains the
model's proposed arguments.
To pin an MCP-resolved tool instead, include the `mcp_alias` field:
```json
"tool_choice": { "kind": "specific_tool", "mcp_alias": "docs", "name": "search_kb" }
```
### Example 3 — resume a paused run
First, refetch the conversation to get the new version (it has advanced because the
paused run's assistant turn was persisted):
```bash
curl "$API/agents/conversations/{id}" -H "Authorization: Bearer $TOKEN"
# → { "version": 2, ... }
```
Then post the resume:
```json
{
"client_op_id": "7fb6795c-60a2-4cf9-a0db-0cc645279704",
"expected_version": 2,
"payload": {
"kind": "tool_outputs",
"outputs": [{
"tool_use_id": "tooluse_DWXPKZ50JDGib5GmShyUgJ",
"content": "User confirmed the proposed slot.",
"is_error": false
}]
}
}
```
The model sees the tool result and continues; this run usually terminates
`completed` after one iteration with a contextual final answer.
## Idempotency
Re-sending the same body with the same `client_op_id` returns the original
`agent_runs` row verbatim. Idempotency is on `(conversation_id, client_op_id)`:
- **Same `client_op_id`, same conversation** → same run row returned, no new
workflow started.
- **Same `client_op_id`, different conversation** → new run, new workflow.
Use a fresh UUID per logical request, never per retry of an in-flight request.
## Common error responses
| Status | `type` slug | Cause | Recovery |
|---|---|---|---|
| 400 | `/invalid-tool-alias` | `config_override.mcp_servers[]` has a bad alias | Fix the alias |
| 400 | `/tool-name-too-long` | `config_override` introduced a wire name > 64 chars | Shorten alias or tool name |
| 400 | `/unknown-tool-use-id` | `tool_outputs` references an id not on the latest assistant turn | Refetch the prior run's `pending_tool_calls` |
| 400 | `/not-a-client-tool-call` | `tool_outputs` references a server-resolved call | Filter to client-side ids only |
| 400 | `/incomplete-tool-outputs` | Missing or extra `outputs[]` entries | Match the pending set exactly |
| 400 | `/no-assistant-turn` | `tool_outputs` payload on a conversation with no assistant turn yet | Use `user_message` payload instead |
| 404 | `/conversation-not-found` | Conversation doesn't exist, is in another company, or was created by a different user | Verify the id; conversations are scoped to the bearer-token's `(company_id, user_id)` |
| 409 | `/version-conflict` | `expected_version` mismatch | Refetch the conversation, retry |
| 500 | `/workflow-start-failed` | Platform couldn't reach the workflow engine | Retry with the same `client_op_id` |
## Permission
`agent_conversations` resource with `write` verb.
# Delivery Commands
Source: https://docs.narrative.io/api-reference/app-api/delivery-commands
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v1/app/commands/delivery
Lists delivery commands this app has access to.
# Delivery Files
Source: https://docs.narrative.io/api-reference/app-api/delivery-files
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v1/app/commands/delivery/{delivery_id}/files
Lists delivery files for a particular delivery command.
# Download file
Source: https://docs.narrative.io/api-reference/app-api/download-file
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /v1/app/commands/delivery/{delivery_id}/download/{filename}
Generates a download url for a particular delivery file
# Activate an app invite
Source: https://docs.narrative.io/api-reference/app-invites/activate-an-app-invite
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /app-invites/{app_invite_id}/activate
Activates an app invite, changing its status from `pending` to `active`.
Requires an app client credentials token.
# Archive an app invite
Source: https://docs.narrative.io/api-reference/app-invites/archive-an-app-invite
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /app-invites/{app_invite_id}
Archives an app invite by its ID.
This is a soft delete that sets the invite status to `archived`.
# Create an app invite
Source: https://docs.narrative.io/api-reference/app-invites/create-an-app-invite
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /app-invites
Creates a new app invite for a company.
Requires an app client credentials token. The invite is created in `pending` status
and includes a generated invite code.
# Get an app invite
Source: https://docs.narrative.io/api-reference/app-invites/get-an-app-invite
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /app-invites/{app_invite_id}
Returns a specific app invite by its ID.
# Get app invite by code
Source: https://docs.narrative.io/api-reference/app-invites/get-app-invite-by-code
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /app-invites/codes/{code}
Returns a public view of an app invite by its invite code.
This endpoint does not require authentication and returns a stripped-down response
that omits fields like timestamps and tags from the response.
# List app invites
Source: https://docs.narrative.io/api-reference/app-invites/list-app-invites
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /app-invites
Returns all app invites for the authenticated company and app.
Results are paginated using `page` (1-based, default `1`) and `per_page`
(default `100`) query parameters. Optionally filter results to a specific
installation using `installation_id`.
# Get Data instance
Source: https://docs.narrative.io/api-reference/apps/get-data-instance
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /apps/{app_slug}/profile-store/${profile_id}
# Get Schema
Source: https://docs.narrative.io/api-reference/apps/get-schema
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /apps/{app_slug}/manifest/profile-store-schema
# Get Schema
Source: https://docs.narrative.io/api-reference/apps/get-schema-1
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /apps/{app_slug}/manifest/profile-store-schema/{schema_version_id}
# Lists apps
Source: https://docs.narrative.io/api-reference/apps/lists-apps
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /apps
# Lists profiles
Source: https://docs.narrative.io/api-reference/apps/lists-profiles
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /apps/{app_slug}/profiles
# Post Data instance
Source: https://docs.narrative.io/api-reference/apps/post-data-instance
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /apps/{app_slug}/profile-store/${profile_id}
Submitting (create or update) data for a profile.
# Publish app event
Source: https://docs.narrative.io/api-reference/apps/publish-app-event
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /apps/events
Create a new webhook subscription for the authenticated company.
# Put Schema
Source: https://docs.narrative.io/api-reference/apps/put-schema
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /apps/{app_slug}/manifest/profile-store-schema
Submitting (create or update) JSON Schema (2019-09 version) for an app.
# Update a slug
Source: https://docs.narrative.io/api-reference/apps/update-a-slug
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /admin/apps/{app_id}/slug
Slug can be changed (from NULL) just once.
# Create an attribute
Source: https://docs.narrative.io/api-reference/attributes/create-an-attribute
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /attributes
Create a new attribute.
# Delete an attribute
Source: https://docs.narrative.io/api-reference/attributes/delete-an-attribute
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /attributes/{attribute_name_or_id}
Permanently removes an attribute from the platform.
Attribute deletion will fail if the given attribute is referenced by another attribute.
Attribute deletions cannot be undone.
# Get an attribute
Source: https://docs.narrative.io/api-reference/attributes/get-an-attribute
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /attributes/{attribute_name_or_id}
# Get attribute references.
Source: https://docs.narrative.io/api-reference/attributes/get-attribute-references
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /attributes/{attribute_name_or_id}/references
# Get attributes
Source: https://docs.narrative.io/api-reference/attributes/get-attributes
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /attributes
Return all attributes.
# Request or refresh an OAuth token
Source: https://docs.narrative.io/api-reference/auth/request-or-refresh-an-oauth-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /oauth/token
# /oauth/token
#
Requests or refreshes the OAuth Token.
# Authenticate a user based on a Stytch session token
Source: https://docs.narrative.io/api-reference/authentication/authenticate-a-user-based-on-a-stytch-session-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/login
Authenticate the user based on the Stytch session token, and return the user information, and API Access Token.
The Stych session token can be obtained from a Magic Links or OAuth token thanks to the registration status API.
# Authenticate a user based on a Stytch session token (B2B version)
Source: https://docs.narrative.io/api-reference/authentication/authenticate-a-user-based-on-a-stytch-session-token-b2b-version
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/b2b/login
Authenticate the user based on the Stytch session token, and return the user information, and API Access Token.
# Exchange a user token for a token scoped to a different company
Source: https://docs.narrative.io/api-reference/authentication/exchange-a-user-token-for-a-token-scoped-to-a-different-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/exchange-token
Exchange the current user access token for a new token scoped to the specified company.
The user can only exchange their token to be scoped to another company to which they belong.
# Register a user based on a Stytch session token
Source: https://docs.narrative.io/api-reference/authentication/register-a-user-based-on-a-stytch-session-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/register
Register the user based on the Stytch session token and name/company name, and return the the created user id.
The Stych session token can be obtained from a Magic Links or OAuth token thanks to the registration status API.
# Register a user based on a Stytch session token (B2B)
Source: https://docs.narrative.io/api-reference/authentication/register-a-user-based-on-a-stytch-session-token-b2b
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/b2b/register
Register the user based on the Stytch session token and name/company name, and return the the created user id.
The Stych session token can be obtained from a Magic Links or OAuth token thanks to the registration status API.
# Report whether the user associatd to a Stytch OAuth or Magic Links token has been fully registered on Narrative side
Source: https://docs.narrative.io/api-reference/authentication/report-whether-the-user-associatd-to-a-stytch-oauth-or-magic-links-token-has-been-fully-registered-on-narrative-side
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/registration-status
Report whether the user associatd to a Stytch OAuth or Magic Links token has been fully registered on Narrative side.
Provides a Stytch session token that can be used to access the login and register APIs.
# Report whether the user associatd to a Stytch OAuth or Magic Links token has been fully registered on Narrative side (B2B)
Source: https://docs.narrative.io/api-reference/authentication/report-whether-the-user-associatd-to-a-stytch-oauth-or-magic-links-token-has-been-fully-registered-on-narrative-side-b2b
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /authentication/b2b/registration-status
Report whether the user associatd to a Stytch OAuth or Magic Links token has been fully registered on Narrative side.
Provides a Stytch session token that can be used to access the login and register APIs.
# Get dataset storage billing
Source: https://docs.narrative.io/api-reference/billings/get-dataset-storage-billing
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/billings/dataset-storage-billing
Get dataset storage billings paginated.
# Get dataset storage billing for company
Source: https://docs.narrative.io/api-reference/billings/get-dataset-storage-billing-for-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /billings/dataset-storage-billing
Get dataset storage billings for a company id paginated.
Company id is extracted from token.
# Get a presigned URL to upload company logo
Source: https://docs.narrative.io/api-reference/companies/get-a-presigned-url-to-upload-company-logo
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/companies/logo-upload-url
Sending a file to Narrative is a two step process.
This endpoint allows you to request an `upload_url` which can be used in a `PUT` request to upload the file.
For example, once you have received an `upload_url` you could use it as follows:
```bash
curl -v --upload-file logo.png {upload_url}
```
The generated `upload_url` is only valid for 30 minutes after issuance.
Response also contains `permanent_url`, `expiry`, and `content_type`.
`permanent_url` should be used as long-term reference.
`content_type` should be used as Content-Type header since it is part of URL signature.
# Lists Company Domains
Source: https://docs.narrative.io/api-reference/companies/lists-company-domains
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /companies/{company_id}/domains
# /companies/{company_id}/domains
Returns all domains registered by a specific company
# Get all data-streams provider companies
Source: https://docs.narrative.io/api-reference/company-marketing-information/get-all-data-streams-provider-companies
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /company-info/data-stream-markets
Returns all the companies providing data-stream market offers.
# Get marketing details on a company
Source: https://docs.narrative.io/api-reference/company-marketing-information/get-marketing-details-on-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /company-info/{company_id}
Returns the marketing detailed information on a company.
# Returns the company and user associated with the token
Source: https://docs.narrative.io/api-reference/company-marketing-information/returns-the-company-and-user-associated-with-the-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /company-info/whoami
Returns the company and user associated with the token.
# Clear the company's default compute pool for a data plane
Source: https://docs.narrative.io/api-reference/company/clear-the-companys-default-compute-pool-for-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /company/{company_id}/data-planes/{data_plane_id}/default-compute-pool
Clears the company-level default compute pool for the given data plane. After this call, jobs whose pool is
resolved through the company-level default (level 3 of the resolution chain) on `data_plane_id` will fall through
to the data-plane default (level 4).
Idempotent: clearing when no default is set is a no-op and still returns `204 No Content`.
# Delete (archive) a company
Source: https://docs.narrative.io/api-reference/company/delete-archive-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /company/{company_id}
# Get a company
Source: https://docs.narrative.io/api-reference/company/get-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /companies/{company_id}
# Get a company
Source: https://docs.narrative.io/api-reference/company/get-a-company-1
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /company/{company_id}
# Set the company's default compute pool for a data plane
Source: https://docs.narrative.io/api-reference/company/set-the-companys-default-compute-pool-for-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /company/{company_id}/data-planes/{data_plane_id}/default-compute-pool/{compute_pool_id}
Sets the company-level default compute pool for the given data plane. This is level 3 of the compute-pool
resolution chain — when a job doesn't pin a pool explicitly (level 1) and the dataset has no default (level 2),
this default is used before falling through to the data-plane default (level 4).
The pool must be active, owned by the company or shared with it via `using` collaborators, and live on
`data_plane_id`. Validation mirrors the rule applied to dataset-level defaults.
Replaces any existing default for `data_plane_id`. Other per-data-plane entries on the company are preserved.
# Update a company
Source: https://docs.narrative.io/api-reference/company/update-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /company/{company_id}
Update the company.
Please note that "slug" could be changed only once (from null to some value).
Lack of slug (in the request) considered as change to null and passes validation if current value is null too.
Status can be changed from pending to active but not in the opposite direction.
# Update a user
Source: https://docs.narrative.io/api-reference/company/update-a-user
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /users/{user_id}
# Archive a compute pool
Source: https://docs.narrative.io/api-reference/compute-pools/archive-a-compute-pool
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /data-planes/{data_plane_id}/compute-pools/{compute_pool_id}
Archive the specified compute pool owned by the authenticated company.
# Create a compute pool
Source: https://docs.narrative.io/api-reference/compute-pools/create-a-compute-pool
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-planes/{data_plane_id}/compute-pools
Create a new compute pool owned by the authenticated company.
# Get a compute pool by ID
Source: https://docs.narrative.io/api-reference/compute-pools/get-a-compute-pool-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-planes/{data_plane_id}/compute-pools/{compute_pool_id}
Retrieve a specific compute pool accessible to the authenticated company.
# List compute pools
Source: https://docs.narrative.io/api-reference/compute-pools/list-compute-pools
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-planes/{data_plane_id}/compute-pools
Retrieve all compute pools accessible to the authenticated company.
# Update a compute pool
Source: https://docs.narrative.io/api-reference/compute-pools/update-a-compute-pool
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-planes/{data_plane_id}/compute-pools/{compute_pool_id}
Update an existing compute pool owned by the authenticated company.
# Archive a connection
Source: https://docs.narrative.io/api-reference/connections/archive-a-connection
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /connections/{connection_id}
# Creates a connection
Source: https://docs.narrative.io/api-reference/connections/creates-a-connection
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /connections
# Returns a connection
Source: https://docs.narrative.io/api-reference/connections/returns-a-connection
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /connections/{connection_id}
# Searches for connections from a given dataset id
Source: https://docs.narrative.io/api-reference/connections/searches-for-connections-from-a-given-dataset-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /connections
# Create contract
Source: https://docs.narrative.io/api-reference/contracts/create-contract
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/contracts
Create a new contract with rates
# Get active contract
Source: https://docs.narrative.io/api-reference/contracts/get-active-contract
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/contracts/active/{company_id}
Return active contract with details for a company id.
# Get active contract for company
Source: https://docs.narrative.io/api-reference/contracts/get-active-contract-for-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /contracts/active
Return active contract with details for a company id.
Company id is extracted from token.
# Get contract
Source: https://docs.narrative.io/api-reference/contracts/get-contract
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/contracts/{contract_id}/{revision}
Return single contract with details by id and revision.
# Get contracts
Source: https://docs.narrative.io/api-reference/contracts/get-contracts
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/contracts
Get contracts with details paginated.
# Get contracts for company
Source: https://docs.narrative.io/api-reference/contracts/get-contracts-for-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /contracts
Return all contract with details for a company id.
Company id is extracted from token.
# Get rates for active contract
Source: https://docs.narrative.io/api-reference/contracts/get-rates-for-active-contract
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/contracts/rate/{company_id}
Return all rates for a active contract for company_id
# Update Contract
Source: https://docs.narrative.io/api-reference/contracts/update-contract
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /admin/contracts/{company_id}
Update active contract for a company
# Add a collaborator to a data plane
Source: https://docs.narrative.io/api-reference/data-planes/add-a-collaborator-to-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-planes/{data_plane_id}/collaborators/{collaborator_company_id}
Grants the specified company access to the given data plane.
# Archive a data plane
Source: https://docs.narrative.io/api-reference/data-planes/archive-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /data-planes/{data_plane_id}
Archive the specified data plane owned by the authenticated company.
# Clear the default compute pool for a data plane
Source: https://docs.narrative.io/api-reference/data-planes/clear-the-default-compute-pool-for-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /data-planes/{data_plane_id}/default-compute-pool
Removes the default compute pool setting from this data plane.
# Create a data plane
Source: https://docs.narrative.io/api-reference/data-planes/create-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-planes
Create a new data plane owned by the authenticated company.
# Data plane heartbeat
Source: https://docs.narrative.io/api-reference/data-planes/data-plane-heartbeat
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-planes/{data_plane_id}/heartbeat
Send a heartbeat to update the last active timestamp of the data plane.
# Get a data plane by ID
Source: https://docs.narrative.io/api-reference/data-planes/get-a-data-plane-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-planes/{data_plane_id}
Retrieve a specific data plane owned or shared with the authenticated company.
# List data planes
Source: https://docs.narrative.io/api-reference/data-planes/list-data-planes
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-planes
Retrieve all data planes accessible to the authenticated company.
# Remove a collaborator from a data plane
Source: https://docs.narrative.io/api-reference/data-planes/remove-a-collaborator-from-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /data-planes/{data_plane_id}/collaborators/{collaborator_company_id}
Revokes the specified company's access to the given data plane.
# Replace the collaborators on a data plane
Source: https://docs.narrative.io/api-reference/data-planes/replace-the-collaborators-on-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-planes/{data_plane_id}/collaborators
Overwrites the entire `collaborators` object on the data plane, setting both `participants` (who may
use the data plane) and `manage_compute_pools` (which companies may create / edit / archive compute
pools on it). Mirrors the whole-object PUT pattern used by attributes.
The granular per-company `PUT/DELETE /data-planes/{data_plane_id}/collaborators/{collaborator_company_id}`
endpoints continue to work for incremental mutation of `participants`; this endpoint is the only way
to set `manage_compute_pools`.
# Set the default compute pool for a data plane
Source: https://docs.narrative.io/api-reference/data-planes/set-the-default-compute-pool-for-a-data-plane
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-planes/{data_plane_id}/default-compute-pool/{compute_pool_id}
Sets the specified compute pool as the default for this data plane.
The compute pool must exist, be active, belong to the same company, and be associated with this data plane.
# Trigger a health check
Source: https://docs.narrative.io/api-reference/data-planes/trigger-a-health-check
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-planes/{data_plane_id}/health-check
Enqueue a health check job for the specified data plane. Optionally target a specific compute pool. Returns the created job.
# Create a data-shop.
Source: https://docs.narrative.io/api-reference/data-shops/create-a-data-shop
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shop
Create a new data-shop.
# Fetch many data-shops
Source: https://docs.narrative.io/api-reference/data-shops/fetch-many-data-shops
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shop/ids
Fetch many data-shops, given their `ids`.
Allows only upto 200 data-shops to be fetched in a call. If no `ids` are requested, empty array result is returned.
# Get a data-shop by its host name.
Source: https://docs.narrative.io/api-reference/data-shops/get-a-data-shop-by-its-host-name
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shop/hostname/{host_name}
Gets the data-shop given its host name.
# Get a data-shop by its id.
Source: https://docs.narrative.io/api-reference/data-shops/get-a-data-shop-by-its-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shop/{data_shop_id}
Gets the data-shop given its uuid.
# Get all data-shops for a company.
Source: https://docs.narrative.io/api-reference/data-shops/get-all-data-shops-for-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shop/company/{company_id}
Gets all data-shops belonging to a company or empty if non exist.
# Update a data-shop.
Source: https://docs.narrative.io/api-reference/data-shops/update-a-data-shop
https://docs-cdn.narrative.io/api-reference/main/openapi.json patch /data-shop/{data_shop_id}
Update a new data-shop.
**Note**: Not passing a `fe_manifest` will remove the `fe_manifest` from the data-shop `manifest`.
Only a user belonging to the company can update a data-shop.
# Create a data-stream.
Source: https://docs.narrative.io/api-reference/data-streams/create-a-data-stream
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-stream
Create a new data-stream.
Any datasets involved in the data-stream should exist and be active. Any fields referred
# Fetch many data-streams
Source: https://docs.narrative.io/api-reference/data-streams/fetch-many-data-streams
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-stream/ids
Fetch many data-streams, given their `ids`.
Allows only upto 50 data-streams to be fetched in a call. If no `ids` are requested, empty array result is returned.
**Note**: If `n` `ids` are requested this might return any `m` `(0 <= m <= n)` number of data-streams depending on
how many actually do exist. It is the clients duty to find this discrepancy.
# Find data-streams available on the data-streams-market.
Source: https://docs.narrative.io/api-reference/data-streams/find-data-streams-available-on-the-data-streams-market
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/sales-channel/data-streams-market/search
Gets all data-streams available on the data-streams-market matching the given search expression.
Search expressions follow the postgres 'websearch_to_tsquery' specification:
1. logical operators : `tshirt and apparel or accessories -leather`
2. phrases : `"find this" -"but not this"`
3. multiple words : `goods services` (this is the same as `goods and services`)
Reference: https://www.postgresql.org/docs/11/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES search for 'websearch_to_tsquery'
# Find data-streams for the given company and a sales-channel and the given search expression.
Source: https://docs.narrative.io/api-reference/data-streams/find-data-streams-for-the-given-company-and-a-sales-channel-and-the-given-search-expression
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/sales-channel/{sales_channel}/search
Gets all data-streams belonging to a company, filtered for the sales-channel and the given search expression.
Search expressions follow the postgres 'websearch_to_tsquery' specification:
1. logical operators : `tshirt and apparel or accessories -leather`
2. phrases : `"find this" -"but not this"`
3. multiple words : `goods services` (this is the same as `goods and services`)
Reference: https://www.postgresql.org/docs/11/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES search for 'websearch_to_tsquery'
# Find data-streams for the given company and its data-shop hostname and the given search expression.
Source: https://docs.narrative.io/api-reference/data-streams/find-data-streams-for-the-given-company-and-its-data-shop-hostname-and-the-given-search-expression
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/host/{host_name}/search
Gets all data-streams belonging to a company, filtered for the hostname and the given search expression.
Search expressions follow the postgres 'websearch_to_tsquery' specification:
1. logical operators : `tshirt and apparel or accessories -leather`
2. phrases : `"find this" -"but not this"`
3. multiple words : `goods services` (this is the same as `goods and services`)
Reference: https://www.postgresql.org/docs/11/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES search for 'websearch_to_tsquery'
# Get a data-stream by its id.
Source: https://docs.narrative.io/api-reference/data-streams/get-a-data-stream-by-its-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/{data_stream_id}
Gets the data-stream given its uuid.
# Get all data-streams for a company.
Source: https://docs.narrative.io/api-reference/data-streams/get-all-data-streams-for-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}
Gets all data-streams belonging to a company or empty if non exist.
# Get all data-streams for a company given a sales channel
Source: https://docs.narrative.io/api-reference/data-streams/get-all-data-streams-for-a-company-given-a-sales-channel
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/sales-channel/{sales_channel}
Gets all data-streams belonging to a company, given a sales channel.
Sales channels:
1. data-streams-market
2. data-shop
# Get all data-streams for a company given a search expression.
Source: https://docs.narrative.io/api-reference/data-streams/get-all-data-streams-for-a-company-given-a-search-expression
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/search
Gets all data-streams belonging to a company and the given search expression.
Search expressions follow the postgres 'websearch_to_tsquery' specification:
1. logical operators : `tshirt and apparel or accessories -leather`
2. phrases : `"find this" -"but not this"`
3. multiple words : `goods services` (this is the same as `goods and services`)
Reference: https://www.postgresql.org/docs/11/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES search for 'websearch_to_tsquery'
# Get all data-streams for the given company and its data-shop hostname.
Source: https://docs.narrative.io/api-reference/data-streams/get-all-data-streams-for-the-given-company-and-its-data-shop-hostname
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/host/{host_name}
Gets all data-streams belonging to a company, filtered for the hostname.
# Get schema
Source: https://docs.narrative.io/api-reference/data-streams/get-schema
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/slug/{slug}/schema
Retrieve schema for deliveries from this data stream.
# Gets the data-stream for a given company and slug.
Source: https://docs.narrative.io/api-reference/data-streams/gets-the-data-stream-for-a-given-company-and-slug
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-stream/company/{company_id}/slug/{slug}
Gets the data-stream for a given company and slug
# Update a data-stream.
Source: https://docs.narrative.io/api-reference/data-streams/update-a-data-stream
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-stream/{data_stream_id}
Update a new data-stream.
The main idea: get a data-stream document/resource, after creation or accessing by id,
then update the document and PUT it as it is.
Of course certain things aren't updateable and are ignored,
for instance identifiers already established (like DS id) and
metadata like creation/update dates and who created/updated the entity.
Things that can be updated
- name
- slug
- content
- tags
- category
- offers
- data_rules
- auth_rules
**Note**: nested structures like content etc. are changed as complete structures,
hence care needs to be taken to include unchanged data at as the same time as the data being changed so as not to
over-write the rest of it.
Any validations applicable on creation time also apply on updates.
Only a user belonging to the company can update a data-stream.
# Preview snapshot expirations
Source: https://docs.narrative.io/api-reference/dataset-configuration/preview-snapshot-expirations
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/retention-policy/preview
Preview snapshot expirations given a retention policy.
# Upsert retention policies for a dataset
Source: https://docs.narrative.io/api-reference/dataset-configuration/upsert-retention-policies-for-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/retention-policy
Accepts either a legacy payload (snapshot-only) or the unified v1 policy object. The response is always returned in the unified v1 shape with a `compat.mode` indicating whether the server interpreted the request as legacy or v1.
**Row TTL clock default:** If `row_ttl.enabled=true` and `row_ttl.clock` is omitted, the server will default to the configured Rosetta Stone attribute (nio_last_modified) **if that mapping exists**; otherwise it returns HTTP 400.
# Files added
Source: https://docs.narrative.io/api-reference/dataset-files/files-added
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/snapshots/{snapshot_id}/files-added
Lists files added by a snapshot
# Find files
Source: https://docs.narrative.io/api-reference/dataset-files/find-files
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/find-files
Lists snapshots and associated files
# Find snapshots
Source: https://docs.narrative.io/api-reference/dataset-files/find-snapshots
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/find-snapshots
Lists snapshots and associated files
# Issue file download url
Source: https://docs.narrative.io/api-reference/dataset-files/issue-file-download-url
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/snapshots/{snapshot_id}/files-added/{file_id}/download
Retrieve the download URL for a file
# Suggest mappings
Source: https://docs.narrative.io/api-reference/dataset-mappings/suggest-mappings
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/suggest-mappings
Schedule the execution of a mappings suggestion job for a given dataset, using the Rosetta Stone API (Snowflake data plane only).
# Archive the materialized view refresh configuration
Source: https://docs.narrative.io/api-reference/dataset-refresh/archive-the-materialized-view-refresh-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /datasets/{dataset_id}/refresh-schedule
# Create a refresh schedule for an existing materialized view
Source: https://docs.narrative.io/api-reference/dataset-refresh/create-a-refresh-schedule-for-an-existing-materialized-view
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/refresh-schedule
Creates new request for request derivation
# Run a materialized view refresh now
Source: https://docs.narrative.io/api-reference/dataset-refresh/run-a-materialized-view-refresh-now
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/refresh-materialized-view
# Update the materialized view refresh configuration
Source: https://docs.narrative.io/api-reference/dataset-refresh/update-the-materialized-view-refresh-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/refresh-schedule
Creates new request for request derivation
# Delete a sample
Source: https://docs.narrative.io/api-reference/dataset-samples/delete-a-sample
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /datasets/{dataset_id}/sample
Deletes a sample of rows from a dataset.
# Request Dataset Sample
Source: https://docs.narrative.io/api-reference/dataset-samples/request-dataset-sample
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/request-sample
Request a dataset sample job with pre-compiled SQL (for non-Narrative Data Planes only)
# Sample a dataset
Source: https://docs.narrative.io/api-reference/dataset-samples/sample-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/sample
Get a sample of rows from a dataset as JSON.
# Sample a dataset (Admin)
Source: https://docs.narrative.io/api-reference/dataset-samples/sample-a-dataset-admin
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/admin/sample
Get a sample of rows from a dataset as JSON.
# Sampled Mappings
Source: https://docs.narrative.io/api-reference/dataset-samples/sampled-mappings
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/sample/mappings
Returns sampled dataset after attribute mapping for a given `mapping_id`
This endpoint should be used when mapping exist.
A response is a list of JSON objects, where every object will have an `input` field, that represents the original key/value pairs from the dataset, and the `output` filed that will contain mapped fields where the key will be `attribute name`, and value will be JSON object of key/value pairs.
# Disable Statistics Configuration
Source: https://docs.narrative.io/api-reference/dataset-statistics/disable-statistics-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /datasets/{dataset_id}/statistics-configuration
Disable the statistics configuration. Statistics will no longer be computed.
# Get Statistics Configuration
Source: https://docs.narrative.io/api-reference/dataset-statistics/get-statistics-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/statistics-configuration
Retrieve the current statistics configuration for a dataset.
# Put Statistics Configuration
Source: https://docs.narrative.io/api-reference/dataset-statistics/put-statistics-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/statistics-configuration
Create or update the statistics configuration for a dataset. Replaces any existing configuration.
# Activate a dataset
Source: https://docs.narrative.io/api-reference/datasets/activate-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/activate
Activates a dataset.
This locks in the schema and allows Narrative to begin ingesting data.
# Activate a dataset
Source: https://docs.narrative.io/api-reference/datasets/activate-a-dataset-1
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/activate
Activates a dataset.
This locks in the schema and allows Narrative to begin ingesting data.
# Create a dataset
Source: https://docs.narrative.io/api-reference/datasets/create-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets
Create or register a new dataset.
Newly created datasets are in the `pending` status and require activation.
To register an empty dataset in Snowflake Dataplane:
If the request has `data_plane_id` defined but `external_id` is not provided, it will create an empty dataset with the specified schema in `pending` state.
To trigger creation of such dataset in Snowflake Dataplane, it should call `PUT datasets//activate`.
# Create or Update detailed statistics
Source: https://docs.narrative.io/api-reference/datasets/create-or-update-detailed-statistics
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/columns-stats
Create or Update detailed statistics. Detailed statistics are collected for datasets belonging to the Narrative Data Plane, but
uploading these stats is necessary for non-Narrative data planes, since Narrative does not have access to the data to compute these statistics.
# Delete a dataset
Source: https://docs.narrative.io/api-reference/datasets/delete-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /datasets/{dataset_id}
Permanently removes a dataset from the platform.
Dataset deletion cannot be undone.
# Derive the configuration
Source: https://docs.narrative.io/api-reference/datasets/derive-the-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/derive-stats-config
Creates new request for request derivation
# Disable configuration
Source: https://docs.narrative.io/api-reference/datasets/disable-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /datasets/{dataset_id}/stats-config
Disable configuration, metrics won't be calculated
# Enable configuration
Source: https://docs.narrative.io/api-reference/datasets/enable-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/enable-stats-config
Enable configuration if it exists
# Get a dataset
Source: https://docs.narrative.io/api-reference/datasets/get-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}
# Get admin info for a dataset
Source: https://docs.narrative.io/api-reference/datasets/get-admin-info-for-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/admin
Get a dataset including information about the backing [Iceberg table](http://iceberg.apache.org/).
# Get dataset statistics
Source: https://docs.narrative.io/api-reference/datasets/get-dataset-statistics
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/stats
Return summary statistics for all ingestion snapshots for a given dataset.
For datasets with "incremental" updates, each snapshot defines a new incremental upload of data.
For datasets with "overwrite" updates, each snapshot overwrites the previous data.
`all_records` query parameter is here because of backward compatibility.
By default API is paginated, so if you don't provide both `per_page` and `offset` parameters, you will get only the latest summary.
# Get datasets
Source: https://docs.narrative.io/api-reference/datasets/get-datasets
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets
Return all datasets belonging to the authenticated company.
# Get detailed statistics
Source: https://docs.narrative.io/api-reference/datasets/get-detailed-statistics
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/columns-stats
Get statistics for each column.
# Get Metrics Configuration
Source: https://docs.narrative.io/api-reference/datasets/get-metrics-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/stats-config
Put new version of configuration.
# Ingest a file
Source: https://docs.narrative.io/api-reference/datasets/ingest-a-file
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/upload
After uploading a file to Narrative via the `upload` API, you can then have the file can then be ingested into a dataset.
Related guides:
- [How do I create a dataset?](https://kb.narrative.io/how-do-i-create-a-dataset): see the section titled **Upload Files for Ingestion**.
# List connector interfaces compatible with a dataset
Source: https://docs.narrative.io/api-reference/datasets/list-connector-interfaces-compatible-with-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/interfaces
Evaluates the dataset's schema against every connector interface exposed by apps installed for
the caller's company, and reports which interfaces accept the schema and which reject it.
Each installed app may expose one or more interfaces, each with its own JSON-Schema-based
policy. An interface with no policy accepts any dataset schema.
The optional `tags` query parameter restricts evaluation to interfaces whose metadata tags
intersect the supplied set (case-insensitive, OR semantics). When omitted, every interface is
evaluated.
# Metrics
Source: https://docs.narrative.io/api-reference/datasets/metrics
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/metrics
Lists metrics for a dataset
# Metrics
Source: https://docs.narrative.io/api-reference/datasets/metrics-1
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /datasets/{dataset_id}/snapshots/{snapshot_id}/column-stats
Metrics for a snapshot
# Put Metrics Configuration
Source: https://docs.narrative.io/api-reference/datasets/put-metrics-configuration
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}/stats-config
Put new version of configuration.
# Submit recalculation
Source: https://docs.narrative.io/api-reference/datasets/submit-recalculation
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/columns-stats/recalculate
Run recalculation for the dataset.
# Truncate a dataset
Source: https://docs.narrative.io/api-reference/datasets/truncate-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/truncate
Truncates a dataset.
Removes all rows from the dataset but leaves the dataset intact.
# Update a dataset
Source: https://docs.narrative.io/api-reference/datasets/update-a-dataset
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets
Update existing dataset.
# Update a dataset
Source: https://docs.narrative.io/api-reference/datasets/update-a-dataset-1
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /datasets/{dataset_id}
Update a pending or active dataset.
All fields can be updated on a pending dataset.
Schema property metadata (`sensitive`, `required`, `unsellable`) and enumeration (append-only), `description`, `display_name`
can be updated on an active dataset.
Archived datasets cannot be updated.
# Update a dataset's partition spec
Source: https://docs.narrative.io/api-reference/datasets/update-a-datasets-partition-spec
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /datasets/{dataset_id}/admin/partition-spec
Update the partition specification of the [Iceberg table](http://iceberg.apache.org/) backing the dataset.
See:
- [What is partitioning?](http://iceberg.apache.org/partitioning/)
- [Iceberg Specification: Partitioning](http://iceberg.apache.org/spec/#partitioning)
# Creates a destination
Source: https://docs.narrative.io/api-reference/destinations/creates-a-destination
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /destinations
# Get a destination by id
Source: https://docs.narrative.io/api-reference/destinations/get-a-destination-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /destinations/{destination_id}
# Removes the destination from a subscription
Source: https://docs.narrative.io/api-reference/destinations/removes-the-destination-from-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /destinations/{destination_id}
# Search destinations for a profile
Source: https://docs.narrative.io/api-reference/destinations/search-destinations-for-a-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /destinations/profile_id={profile_id}
# Search destinations for a subscription
Source: https://docs.narrative.io/api-reference/destinations/search-destinations-for-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /destinations/subscription_id={subscription_id}
# Updates the values of the quick settings
Source: https://docs.narrative.io/api-reference/destinations/updates-the-values-of-the-quick-settings
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /destinations/{destination_id}
# Archive a connection
Source: https://docs.narrative.io/api-reference/encryption-materials/archive-a-connection
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /encryption-materials/{encryption_material_id}
# Create an encryption material
Source: https://docs.narrative.io/api-reference/encryption-materials/create-an-encryption-material
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /encryption-materials
Create an encryption material
# List encryption materials
Source: https://docs.narrative.io/api-reference/encryption-materials/list-encryption-materials
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /encryption-materials
List encryption materials
# Create an installation
Source: https://docs.narrative.io/api-reference/installations/create-an-installation
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations
Creates a new installation for an app.
The installation associates an app with the authenticated company using a specific tier from the app's manifest.
The tier determines which permissions are granted to the installation.
# Create an installation-scoped access token
Source: https://docs.narrative.io/api-reference/installations/create-an-installation-scoped-access-token
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations/{installation_id}/token
Returns a Narrative API access token scoped to the given installation, carrying the
permissions granted to the application at installation.
With a user-bound bearer token, the access token is created on behalf of the
authenticated user for an installation belonging to the user's company. The token is
used with an application (e.g., the S3 connector) to allow the application to call
Narrative's API on the user's behalf.
With an app client-credentials bearer token (obtained via `POST /oauth/token` with
`grant_type=client_credentials`), the access token is created by the application itself
for one of its own installations, allowing the application to act on behalf of a
company that has installed it. Use `GET /installations` with the app token to discover
installation ids.
# Delete an installation
Source: https://docs.narrative.io/api-reference/installations/delete-an-installation
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /installations/{installation_id}
Deletes an installation by its ID.
The installation must belong to the authenticated company. An installation cannot be deleted
if it has active profiles associated with it. Remove all profiles before uninstalling the app.
# Get an installation
Source: https://docs.narrative.io/api-reference/installations/get-an-installation
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /installations/{installation_id}
Returns a single installation by its ID.
With a company-scoped token (e.g. a user token), the installation must belong to the
authenticated company.
With an app client-credentials token, the installation must belong to the authenticated app.
# Get current installation info
Source: https://docs.narrative.io/api-reference/installations/get-current-installation-info
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /installations/whoami
Returns information about the installation associated with the current authentication token.
This endpoint is useful for apps to identify which installation they are operating under.
The token must be associated with an installation (e.g., an app token).
# List installations
Source: https://docs.narrative.io/api-reference/installations/list-installations
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /installations
Returns a paginated list of installations.
With a company-scoped token (e.g. a user token), lists the installations of the
authenticated company, optionally filtered by app category using the `app_category`
query parameter.
With an app client-credentials token, lists the installations of the authenticated
app across all companies, optionally narrowed to a single company using the
`company_id` query parameter. Use this to look up the installation id for a company
before creating an installation-scoped access token.
Results are paginated using `page` (1-based, default `1`) and `per_page`
(default `100`) query parameters.
# Cancel a job
Source: https://docs.narrative.io/api-reference/jobs/cancel-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/cancel
Finalizes cancellation of a job, moving it from `pending_cancellation` to `cancelled`. It is a no-op for a job
already in a terminal state (`completed`, `cancelled`, `failed`); any other non-terminal state (e.g. `pending` or
`running`) is rejected. Use `request-cancellation` first to move a job into `pending_cancellation`.
Requires write access to jobs.
# Complete a job
Source: https://docs.narrative.io/api-reference/jobs/complete-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/complete
Marks a job as `completed` along with its result. The result shape is selected by the `type` field in the body
and must match the job's type. It is a no-op for a job already in a terminal state (`completed`, `cancelled`,
`failed`); a `pending_cancellation` job can still be completed if it finishes before the cancellation is processed.
Requires write access to jobs.
# Fail a job
Source: https://docs.narrative.io/api-reference/jobs/fail-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/fail
Marks a job as `failed` with a message and optional diagnostic value. It is a no-op for a job already in a
terminal state (`completed`, `cancelled`, `failed`); a `pending_cancellation` job can still be failed if it fails
before the cancellation is processed.
Requires write access to jobs.
# Get a job
Source: https://docs.narrative.io/api-reference/jobs/get-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /jobs/{job_id}
This endpoint retrieves all details associated with a specific job ID from the jobs table.
# Get jobs
Source: https://docs.narrative.io/api-reference/jobs/get-jobs
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /jobs
Returns a list of jobs associated with the specified company, including job ID, job type, timestamp information, and results.
It returns a maximum of 500 jobs at a time to manage response size.
# Request cancellation of a job
Source: https://docs.narrative.io/api-reference/jobs/request-cancellation-of-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/request-cancellation
Requests cancellation of a `pending` or `running` job, moving it to `pending_cancellation` so the data plane can
stop the work. It is a no-op for a job already in a terminal state (`completed`, `cancelled`, `failed`) or already
in `pending_cancellation`.
Cancellation is only supported for specific job types on the Narrative data plane, and on Snowflake data planes.
Use `cancel` to finalize a job once it has reached `pending_cancellation`.
Requires write access to jobs.
# Reschedule a job
Source: https://docs.narrative.io/api-reference/jobs/reschedule-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/reschedule
Puts a job back on the queue: resets it to `pending` and clears its executor so the operator re-dispatches it
onto a fresh cluster. This is typically used when a job's cluster terminated with its step still in flight.
By default this is a no-op for jobs already in a terminal state (`completed`, `cancelled`, `failed`). Supply a
body with `force: true` to reschedule a terminal job anyway (e.g. to re-run one that already finished). The body
is optional — an empty body keeps the default behavior.
The response's `outcome` reports whether the job was actually `rescheduled` or `skipped_terminal` (left untouched
because it was terminal and `force` was not set).
Requires write access to jobs.
# Run a job
Source: https://docs.narrative.io/api-reference/jobs/run-a-job
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /jobs/{job_id}/run
Starts a job by assigning it an executor and moving it to `running`. Applies to jobs in the `pending` or `failed`
state; it is a no-op for a job that is already `running` or in a non-retryable terminal state (`completed`,
`cancelled`). Idempotent.
Requires write access to jobs.
# Accept a mapping
Source: https://docs.narrative.io/api-reference/mappings/accept-a-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /mappings/{mapping_id}/accept
Accept a `system` proposed mapping, i.e. move it from the `status` of `pending` to `active`.
The mapping has to be `system` proposed and not `archived`.
Both owners (users belonging to the company the mapping belongs to) and admins can promote a mapping.
# Create a private mapping
Source: https://docs.narrative.io/api-reference/mappings/create-a-private-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /mappings/companies/{company_id}
Create a private mapping.
Supports mapping types: `cached_mapping`, `object_mapping`, and `value_mapping`.
# Create a system mapping
Source: https://docs.narrative.io/api-reference/mappings/create-a-system-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /mappings/system
Create a `system` mapping.
Only admins can call this endpoint.
# Delete a mapping
Source: https://docs.narrative.io/api-reference/mappings/delete-a-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /mappings/{mapping_id}
Mapping deletion cannot be undone.
If the mapping is still being used then this request fails.
Both owners (users belonging to the company the mapping belongs to) and admins can delete a mapping.
# Get mapping
Source: https://docs.narrative.io/api-reference/mappings/get-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /mappings/{mapping_id}
Return a specific mapping based on its id.
# Get mapping preview
Source: https://docs.narrative.io/api-reference/mappings/get-mapping-preview
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /mappings/{mapping_id}/sample
Returns sampled dataset after attribute mapping for a given `mapping_id`
This endpoint should be used when mapping exist.
A response is a list of JSON objects, where every object will have an `input` field, that represents the original key/value pairs from the dataset, and the `output` filed that will contain mapped fields where the key will be `attribute name`, and value will be JSON object of key/value pairs.
# Get mappings
Source: https://docs.narrative.io/api-reference/mappings/get-mappings
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /mappings
Return the mappings owned by the caller's company. Optionally filter by attribute and/or dataset.
# Get mappings for a company
Source: https://docs.narrative.io/api-reference/mappings/get-mappings-for-a-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /mappings/companies/{company_id}
Return the mappings owned by a specific company based on its id. Optionally filter by attribute
and/or dataset.
# Get system mappings
Source: https://docs.narrative.io/api-reference/mappings/get-system-mappings
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /mappings/system
Return all `system` mappings.
Only admins can call this endpoint.
# Test Mapping
Source: https://docs.narrative.io/api-reference/mappings/test-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /mappings/test
Returns sampled dataset after attribute mapping for a given mapping.
This endpoint should be used when mapping does not exist.
A response is a list of JSON objects, where every object will have an `input` field, that represents the original key/value pairs from the dataset, and the `output` filed that will contain mapped fields where the key will be `attribute name`, and value will be JSON object of key/value pairs.
A `cached_mapping` is resolved as a join at query time and has no per-row preview, so its response is always an empty list of records.
# Update a mapping
Source: https://docs.narrative.io/api-reference/mappings/update-a-mapping
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /mappings/{mapping_id}
Update the definition of a mapping between an attribute and a dataset.
Note: the attribute and dataset with which a mapping is associated cannot be updated.
# Run model inference
Source: https://docs.narrative.io/api-reference/model-inference/run-model-inference
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /model-inference/run
Create a job to perform an LLM call. Supported on both AWS and Snowflake data planes.
# Archive a model
Source: https://docs.narrative.io/api-reference/models/archive-a-model
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /models/{model_id}
# Get a model
Source: https://docs.narrative.io/api-reference/models/get-a-model
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /models/{model_id}
Returns a single model by its ID.
# Get models
Source: https://docs.narrative.io/api-reference/models/get-models
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /models
Returns a paginated list of models accessible to the authenticated company.
Models can be filtered by repository type and sorted by creation date.
# Update a model
Source: https://docs.narrative.io/api-reference/models/update-a-model
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /models/{model_id}
Updates an existing model.
Only the fields provided in the request body will be updated.
# Get the result of a NQL forecast
Source: https://docs.narrative.io/api-reference/nql/get-the-result-of-a-nql-forecast
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /nql/{job_id}
This endpoint is used to poll forecast result after it's ran.
# Run a NQL Query
Source: https://docs.narrative.io/api-reference/nql/run-a-nql-query
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /nql/run
The run endpoint in the NQL API allows users to execute their NQL queries.
This endpoint processes the query and performs the specified operations
within the specified data plane. Depending on the NQL provided, the run
endpoint can create materialized views or generate forecasts.
# Validate an NQL query
Source: https://docs.narrative.io/api-reference/nql/validate-an-nql-query
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /nql/validate
The validate endpoint in the NQL API allows users to verify the syntax and structure
of their NQL queries before executing them. This ensures that the queries are correctly
formatted and will not cause errors when passing through the /run endpoint.
The primary goal of using the validate endpoint is to receive a 200 OK status code,
which indicates that the query is valid.
# Creates a new setup intent
Source: https://docs.narrative.io/api-reference/payment-methods/creates-a-new-setup-intent
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /payment-methods/credit-card/setup-intent
# /payment-methods/credit-card/setup-intent
Creates a new setup intent
# Deletes a payment method
Source: https://docs.narrative.io/api-reference/payment-methods/deletes-a-payment-method
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /payment-methods/{payment_method_id}
# /payment-methods/{payment_method_id}
Deletes a payment method
# Returns payment methods for company
Source: https://docs.narrative.io/api-reference/payment-methods/returns-payment-methods-for-company
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /payment-methods
# /payment-methods
Returns payment methods for company
# Sets a valid SetupIntent as the primary payment method
Source: https://docs.narrative.io/api-reference/payment-methods/sets-a-valid-setupintent-as-the-primary-payment-method
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /payment-methods/credit-card/setup-intent/{setup_intent_id}
# /payment-methods/credit-card/setup-intent/{setup_intent_id}
Sets a valid SetupIntent as the primary payment method
# Create a permission
Source: https://docs.narrative.io/api-reference/permissions/create-a-permission
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/permissions
Create a new permission
# Attach permission to product
Source: https://docs.narrative.io/api-reference/products/attach-permission-to-product
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/products/add-permission
Attaches given permission to a given product
# Create a product
Source: https://docs.narrative.io/api-reference/products/create-a-product
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/products
Create a new product.
# Get product
Source: https://docs.narrative.io/api-reference/products/get-product
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/products/{sku}
Return single product
# Get products
Source: https://docs.narrative.io/api-reference/products/get-products
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /admin/products
Get products paginated
# Update product
Source: https://docs.narrative.io/api-reference/products/update-product
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /admin/products/{sku}
Update product by sku.
# Archives a profile
Source: https://docs.narrative.io/api-reference/profiles/archives-a-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations/{installation_id}/profiles/{app_profile_id}/archive
Archiving prevents using the profile again in the future.
# Creates an app profile
Source: https://docs.narrative.io/api-reference/profiles/creates-an-app-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations/{installation_id}/profiles
The profile will be assigned a unique identifier upon creation. New profiles are "disabled" and should be activated after creation.
# Disables a profile
Source: https://docs.narrative.io/api-reference/profiles/disables-a-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations/{installation_id}/profiles/{app_profile_id}/disable
Only profiles that are enabled will be considered when delivering subscriptions.
# Enables a profile
Source: https://docs.narrative.io/api-reference/profiles/enables-a-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /installations/{installation_id}/profiles/{app_profile_id}/enable
Only profiles that are enabled will be considered when delivering subscriptions.
# Returns app profiles
Source: https://docs.narrative.io/api-reference/profiles/returns-app-profiles
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /installations/{installation_id}/profiles
# Returns profile with {profile_id}
Source: https://docs.narrative.io/api-reference/profiles/returns-profile-with-
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /installations/{installation_id}/profiles/{app_profile_id}
# Updates an existing app profile
Source: https://docs.narrative.io/api-reference/profiles/updates-an-existing-app-profile
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /installations/{installation_id}/profiles/{app_profile_id}
# Create a query
Source: https://docs.narrative.io/api-reference/queries/create-a-query
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /v2/queries
Any number of properties can be updated on the same request.
If a property is not sent on the payload, the value won't be updated.
# Deletes a query
Source: https://docs.narrative.io/api-reference/queries/deletes-a-query
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /v2/queries/{query_id}
Deletes a query
# Get a query by id
Source: https://docs.narrative.io/api-reference/queries/get-a-query-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/queries/{query_id}
Get a query.
# Get accessible queries
Source: https://docs.narrative.io/api-reference/queries/get-accessible-queries
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/queries
Get all accessible queries.
# Updates a query
Source: https://docs.narrative.io/api-reference/queries/updates-a-query
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /v2/queries/{query_id}
Updates a query
# Create bucket
Source: https://docs.narrative.io/api-reference/resources/create-bucket
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /resources/buckets
Create an AWS S3 bucket inside Narrative's AWS account assigned to the authenticated company.
The created bucket is uniquely accessible by the authenticated company and can be used to facilitate the automation of tasks like ingestion of data into a dataset.
Pay attention, that bucket-owner-full-control ACL is strictly required for "bucket_policy" type of access.
# Get buckets
Source: https://docs.narrative.io/api-reference/resources/get-buckets
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /resources/buckets
Return the AWS S3 buckets assigned to the authenticated company.
# Update access type
Source: https://docs.narrative.io/api-reference/resources/update-access-type
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /resources/update-access
Update access type to the bucket.
Pay attention, that bucket-owner-full-control ACL is strictly required for "bucket_policy" type of access.
Also, bucket should be whitelisted for mutation. All buckets created by creation endpoint are mutable by default.
Buckets created before 2023 could be immutable in case of customized policies.
You can call 'Get buckets' endpoint to check `is_access_mutable` property in response.
# Infer the dataset schema of a file passed by multipart file.
Source: https://docs.narrative.io/api-reference/schema-inference/infer-the-dataset-schema-of-a-file-passed-by-multipart-file
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /v2/inference
# Create a private schema preset.
Source: https://docs.narrative.io/api-reference/schema-presets/create-a-private-schema-preset
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /schema-presets
Create a private schema preset owned by the authenticated account. Only members of the same organization are able to see this schema preset.
# Create a public schema preset.
Source: https://docs.narrative.io/api-reference/schema-presets/create-a-public-schema-preset
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /admin/schema-presets
Create a **public** schema preset available platform wide. This endpoint is only available to administrators.
# Delete a schema preset by its id.
Source: https://docs.narrative.io/api-reference/schema-presets/delete-a-schema-preset-by-its-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /schema-presets/{schema_preset_id}
Archive (soft delete) a specific schema preset given its id. The schema preset will not be visible from that point forward.
# Detail information on a schema preset by its id.
Source: https://docs.narrative.io/api-reference/schema-presets/detail-information-on-a-schema-preset-by-its-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /schema-presets/{schema_preset_id}
Returns detailed information on a specific schema preset given its id.
# Returns all available schema presets.
Source: https://docs.narrative.io/api-reference/schema-presets/returns-all-available-schema-presets
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /schema-presets
List the schema presets that are publicly available and the ones owned by the authenticated account.
# Returns all publicly available schema presets.
Source: https://docs.narrative.io/api-reference/schema-presets/returns-all-publicly-available-schema-presets
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /schema-presets/public
List the schema presets that are publicly available.
# Activate a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/activate-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/{subscription_id}/activate
Activation will result in charges to your company's payment method.
# Cancel a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/cancel-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/{subscription_id}/cancel
A cancelled subscription cannot be reactivated.
# Checkout a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/checkout-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/{subscription_id}/checkout
Checkout creates and activates a subscription in a single API call.
Activation will result in charges to your company's payment method.
# Create a cost forecast
Source: https://docs.narrative.io/api-reference/subscriptions/create-a-cost-forecast
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/cost-forecasts
Create an estimate of the "cost" of servicing a set of subscription constraints.
Cost has two components:
- The number of rows which must be read in order to find data which matches the subscription constraints.
- An estimate of the "size" of those rows in bytes.
Note: the number of rows reported in the cost is **not** an estimate of the number of rows which will be purchased as part of a subscription using the given constraints.
# Create a cost forecast for a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/create-a-cost-forecast-for-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/{subscription_id}/cost-forecasts
Create a cost forecast for a subscription, taking into account data already processed for the subscription.
# Create a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/create-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions
Subscriptions created using this endpoint are left in the `pending` state and require activation.
# Delete a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/delete-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /data-shops/subscriptions/{subscription_id}
Subscription deletion cannot be undone.
If a subscription is active it will be cancelled.
# Get a cost forecast
Source: https://docs.narrative.io/api-reference/subscriptions/get-a-cost-forecast
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/cost-forecasts/{cost_forecast_id}
Get a cost forecast by id.
# Get a delivered file
Source: https://docs.narrative.io/api-reference/subscriptions/get-a-delivered-file
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/{subscription_id}/deliveries/download
Retrieve a AWS S3 presigned URL to download a file delivered as part of a subscription.
# Get a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/get-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/{subscription_id}
Get a subscription by id.
# Get cost forecasts
Source: https://docs.narrative.io/api-reference/subscriptions/get-cost-forecasts
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/cost-forecasts
Return all cost forecasts belonging to the authenticated company.
# Get deliveries
Source: https://docs.narrative.io/api-reference/subscriptions/get-deliveries
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/{subscription_id}/deliveries
Retrieve metadata about files containing data purchased as part of the subscription.
# Get matches
Source: https://docs.narrative.io/api-reference/subscriptions/get-matches
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /data-shops/subscriptions/matches
Retrieve datasets matching a set of subscription constraints.
# Get schema
Source: https://docs.narrative.io/api-reference/subscriptions/get-schema
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/{subscription_id}/schema
Retrieve schema for deliveries from this subscription.
# Get subscription cost forecasts
Source: https://docs.narrative.io/api-reference/subscriptions/get-subscription-cost-forecasts
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/{subscription_id}/cost-forecasts
Retrieve cost forecasts for a subscription.
# Get subscription matches
Source: https://docs.narrative.io/api-reference/subscriptions/get-subscription-matches
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions/{subscription_id}/matches
Retrieve datasets matching a subscription's constraints.
# Get subscriptions
Source: https://docs.narrative.io/api-reference/subscriptions/get-subscriptions
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /data-shops/subscriptions
Return all subscriptions belonging to the authenticated company.
# Update a subscription
Source: https://docs.narrative.io/api-reference/subscriptions/update-a-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /data-shops/subscriptions/{subscription_id}
Update a subscription
NOTE: If the cadence of the subscription is modified, the frequency filter will
automatically be adjusted to match the new cadence.
# Upload a file
Source: https://docs.narrative.io/api-reference/uploads/upload-a-file
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /uploads/{upload_path}
Sending a file to Narrative is a two step process. This endpoint allows you to request an `upload_url` which can be used in a `PUT` request to upload the file.
For example, once you have received an `upload_url` you could use it as follows:
```bash
curl -v --upload-file my-file.csv {upload_url}
```
The generated `upload_url` is only valid for 30 minutes after issuance.
# Create a usage event
Source: https://docs.narrative.io/api-reference/usage/create-a-usage-event
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /usage
Create a usage event to charge a customer for their use of a product.
# Archives a view
Source: https://docs.narrative.io/api-reference/views/archives-a-view
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /v2/views/{view_id}
Archives a view
# Create a view
Source: https://docs.narrative.io/api-reference/views/create-a-view
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /v2/views
Any number of properties can be updated on the same request.
If a property is not sent on the payload, the value won't be updated.
# Get a view by id
Source: https://docs.narrative.io/api-reference/views/get-a-view-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/views/{view_id}
Get a view.
# Get accessible views
Source: https://docs.narrative.io/api-reference/views/get-accessible-views
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /v2/views
Get all accessible views.
# Updates a view
Source: https://docs.narrative.io/api-reference/views/updates-a-view
https://docs-cdn.narrative.io/api-reference/main/openapi.json put /v2/views/{view_id}
Updates a view
# Archive a webhook subscription
Source: https://docs.narrative.io/api-reference/webhooks/archive-a-webhook-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /webhooks/{webhook_subscription_id}
Archive the specified webhook subscription.
# Create a webhook subscription
Source: https://docs.narrative.io/api-reference/webhooks/create-a-webhook-subscription
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /webhooks
Create a new webhook subscription for the authenticated company.
# Get a webhook subscription by ID
Source: https://docs.narrative.io/api-reference/webhooks/get-a-webhook-subscription-by-id
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /webhooks/{webhook_subscription_id}
Retrieve a specific webhook subscription.
# List webhook subscriptions
Source: https://docs.narrative.io/api-reference/webhooks/list-webhook-subscriptions
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /webhooks
Get all webhook subscriptions for the authenticated company.
# Get a current user
Source: https://docs.narrative.io/api-reference/whoami/get-a-current-user
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /whoami
# Archive a workflow
Source: https://docs.narrative.io/api-reference/workflows/archive-a-workflow
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /workflows/{workflow_id}
Archive a workflow. This terminates any active schedule and running execution,
then marks the workflow as archived in the database.
# Cancel a specific workflow run
Source: https://docs.narrative.io/api-reference/workflows/cancel-a-specific-workflow-run
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /workflows/{workflow_id}/runs/{run_id}/cancel
Request cancellation of a specific workflow execution identified by its run ID.
This is the recommended method for cancelling workflow executions, as it guarantees
that only the intended execution is cancelled. Unlike cancelling by workflow ID alone,
this avoids a race condition where:
1. A request to cancel the last execution is sent.
2. The last execution completes before the server processes the cancellation.
3. A new workflow execution starts.
4. The server processes the cancellation request and cancels the new execution instead of the intended one.
# Cancel a workflow execution
Source: https://docs.narrative.io/api-reference/workflows/cancel-a-workflow-execution
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /workflows/{workflow_id}/cancel
Request cancellation of the currently running execution of a workflow.
# Create a workflow
Source: https://docs.narrative.io/api-reference/workflows/create-a-workflow
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /workflows
Create a new workflow from a YAML specification. The specification is parsed and validated
before the workflow is persisted.
Optionally, the workflow can be scheduled and/or triggered immediately upon creation
using the `trigger_immediately` and `schedule_immediately` request fields.
# Get a workflow
Source: https://docs.narrative.io/api-reference/workflows/get-a-workflow
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /workflows/{workflow_id}
Retrieve a workflow by its ID. Only workflows owned by the authenticated company are accessible.
# List workflow runs
Source: https://docs.narrative.io/api-reference/workflows/list-workflow-runs
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /workflows/{workflow_id}/runs
List execution runs for a workflow. Returns a paginated list of runs with their status,
start time, and close time.
Results are ordered by start time descending (most recent first).
# List workflows
Source: https://docs.narrative.io/api-reference/workflows/list-workflows
https://docs-cdn.narrative.io/api-reference/main/openapi.json get /workflows
List all active workflows for the authenticated company.
# Remove a workflow schedule
Source: https://docs.narrative.io/api-reference/workflows/remove-a-workflow-schedule
https://docs-cdn.narrative.io/api-reference/main/openapi.json delete /workflows/{workflow_id}/schedule
Delete the schedule for a workflow. This stops future scheduled executions from being
created but does not cancel any currently running workflow execution.
Returns an error if the workflow does not have an active schedule.
# Schedule a workflow
Source: https://docs.narrative.io/api-reference/workflows/schedule-a-workflow
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /workflows/{workflow_id}/schedule
Create a schedule for a workflow based on the cron expression defined in its specification.
The workflow specification must contain a `schedule` section.
Optionally, the workflow can also be triggered immediately by setting `trigger_immediately` to true.
# Trigger a workflow
Source: https://docs.narrative.io/api-reference/workflows/trigger-a-workflow
https://docs-cdn.narrative.io/api-reference/main/openapi.json post /workflows/{workflow_id}/trigger
Trigger an immediate execution of a workflow. Returns the run ID of the new execution.
# App Invites
Source: https://docs.narrative.io/concepts/apps/app-invites
How invite links enable third parties to connect accounts without Narrative authentication
App invites let applications generate shareable invitation links for third-party account onboarding. An invitee follows the link, connects their account (for example, via OAuth with Pinterest), and a profile is created in the inviting company's Narrative account — no Narrative authentication required.
## Why app invites exist
Onboarding external partners typically requires coordinating account creation, credentials, and permissions. App invites remove this friction by letting the inviter send a single link. The invitee never needs to sign up for Narrative or understand the platform — they simply authorize access to their external account, and the integration is ready.
This is especially valuable when:
* **Scaling partner networks** — onboard dozens or hundreds of partners without manual account setup
* **Reducing time-to-value** — partners can connect in minutes instead of days
* **Minimizing support burden** — self-service onboarding eliminates back-and-forth coordination
## How app invites work
```mermaid theme={null}
sequenceDiagram
participant Inviter as Inviter
participant App as Connector App
participant API as Narrative API
participant Invitee as Invitee
participant OAuth as External Platform
Inviter->>App: Request invite for partner
App->>API: Create app invite
API-->>App: Return invite URL with app code
App-->>Inviter: Share invite URL
Inviter->>Invitee: Send invite link
Invitee->>App: Open invite URL
App->>API: Look up invite by code
API-->>App: Return invite details
App->>OAuth: Redirect to OAuth flow
OAuth-->>App: Authorization granted
App->>API: Activate invite and create profile
```
1. The **inviter** requests an invite through the connector app
2. The app calls the Narrative API to **create an app invite**, receiving a unique URL containing an [app code](/reference/glossary#app-code)
3. The inviter **shares the URL** with the intended partner
4. The **invitee** opens the link, which directs them to the **app's landing page**
5. The app uses the invite code to **look up the invite** via the Narrative API's public endpoint
6. The app redirects the invitee to the external platform's **OAuth authorization flow**
7. After the invitee **authorizes access**, the app calls the Narrative API to **activate the invite** and create a profile in the inviter's company account
## Invite lifecycle
Each app invite progresses through a series of states:
| State | Description | Transition |
| ---------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `pending` | Invite has been created but the invitee has not yet completed the flow | Moves to `active` when the invitee completes OAuth authorization |
| `active` | The invitee has connected their account and a profile has been created | Can be moved to `archived` when no longer needed |
| `archived` | The invite is deactivated and no longer usable | Terminal state |
## Reconnect invites
Some connectors support a second type of app invite: **reconnect invites**. Instead of creating a new profile, a reconnect invite allows an invitee to refresh the OAuth token on an existing profile.
This is useful when a partner's token has expired and you need them to re-authorize without disrupting the existing profile configuration, ad account associations, or active connections.
The flow is the same as a standard invite — the invitee receives a link, completes OAuth, and the token is updated in place. The [Pinterest Connector](/reference/connectors/pinterest) currently supports reconnect invites.
## Security considerations
App invites are designed with several security properties:
* **Scoped access** — each invite is tied to a specific app and company. The resulting profile has only the permissions needed for that integration
* **No Narrative authentication for invitees** — invitees interact only with the external platform's OAuth flow, reducing the attack surface
* **Public code lookup** — invite details can be retrieved using the [app code](/reference/glossary#app-code) without authentication, allowing invitees to verify the invite before proceeding. The app code reveals only non-sensitive metadata about the invite
***
## Related content
How connectors deliver data to external platforms
Available connectors and their configuration
Required API key permissions for app invite operations
# Data Flow
Source: https://docs.narrative.io/concepts/architecture/data-flow
How data moves through the Narrative platform during queries and collaboration
Narrative's architecture is designed around a core principle: data stays in place. Rather than moving data to queries, queries move to data. This approach enables collaboration across organizations while maintaining data residency and security requirements.
## The principle: Queries go to data
Traditional data sharing requires copying data between systems. Narrative inverts this model:
| Traditional approach | Narrative approach |
| ----------------------------------------- | ------------------------------- |
| Copy data to a shared location | Leave data in place |
| Queries run against copies | Queries run against source data |
| Data freshness depends on sync frequency | Data is always current |
| Multiple copies increase security surface | Single source reduces exposure |
When you query data through Narrative, the [control plane](/concepts/primitives/control-plane) sends query instructions to your [data plane](/concepts/primitives/data-planes). The data plane executes the query locally and returns only the results.
## What flows where
Understanding what crosses boundaries helps clarify the architecture:
### From user to control plane
* NQL query text
* Authentication credentials
* Request metadata
### From control plane to data plane
* Compiled SQL (transpiled for the target database)
* Job metadata and parameters
* Execution instructions
### From data plane to control plane
* Status updates and completion signals
* Error messages and diagnostics
* Data samples (when requested for viewing results)
### What never leaves the data plane
* Raw source data
* Intermediate query results
* Materialized view contents (stored locally)
## Query data flow
All queries—including interactive queries run from the Query Editor—follow the same pattern as materialized views. Results are stored as a dataset in the data plane, not returned directly to the user.
```
User Control Plane Data Plane
│ │ │
│──── NQL Query ──────>│ │
│ │ │
│ │──── Compiled SQL ──────>│
│ │ │
│ │ ┌────┴────┐
│ │ │ Execute │
│ │ │ & Store │
│ │ │(Dataset)│
│ │ └────┬────┘
│ │ │
│ │<──── Status ───────────│
│ │ │
```
Interactive queries are implemented as [materialized views](/concepts/nql/materialized-views) with two automatic constraints:
* A **24-hour retention policy** that expires the results automatically
* A **row limit** that caps the result size
This architecture ensures that even ad-hoc queries never move full result sets through the control plane—data stays in your data plane.
## Materialized view data flow
[Materialized views](/concepts/nql/materialized-views) follow a different pattern. Results stay in the data plane:
```
Control Plane Data Plane
│ │
│──── Refresh Job ───────>│
│ │
│ ┌────┴────┐
│ │ Execute │
│ │ Query │
│ └────┬────┘
│ │
│ ┌────┴────┐
│ │ Store │
│ │ Results │
│ └────┬────┘
│ │
│<──── Status ────────────│
│ │
```
The control plane receives only a completion status—never the actual data. Results are stored as a dataset within your data plane.
## How you view query results
Since query results stay in the data plane, how do you actually see them? Through **data sampling**.
```
User Control Plane Data Plane
│ │ │
│── View Results ─────>│ │
│ │ │
│ │── Sample Request ──────>│
│ │ │
│ │ ┌────┴────┐
│ │ │ Read │
│ │ │ 1000 │
│ │ │ Rows │
│ │ └────┬────┘
│ │ │
│ │<── Sample Data ────────│
│ │ │
│<── Display Sample ───│ │
│ │ │
```
When you click to view query results in the UI or request results via the API:
1. **Sample job** — The control plane requests a sample from the dataset in your data plane
2. **Row retrieval** — Up to 1,000 rows are read from the stored results
3. **Sample storage** — The sample is stored in the control plane for quick access
4. **Display** — The sample appears in the UI or API response
This sample is a preview of your results, not the complete dataset. For full access to query results, use [data exports](/guides/nql/creating-materialized-views) or access the underlying dataset directly.
Samples are one of the few cases where actual data leaves your data plane. For governance implications, see [Sample Data](/concepts/architecture/sample-data). To clear samples programmatically, see [Managing Datasets](/guides/sdk/managing-datasets#sampling-data).
## Cross-data-plane queries
When a query references data in multiple data planes, the control plane coordinates execution:
1. **Query decomposition** — The control plane breaks the query into subqueries, one per data plane
2. **Parallel execution** — Subqueries execute independently in each data plane
3. **Result coordination** — Results are combined as needed, with the control plane coordinating data movement between planes
The specific data movement depends on the query. For joins across data planes, some data may need to move between planes for the join to complete. The control plane optimizes this movement to minimize data transfer.
## Data collaboration flow
When you share data with a partner organization, they query your data through the same architecture:
1. Partner submits NQL query referencing your dataset
2. Control plane verifies their permissions against your [access rules](/concepts/primitives/access-rules)
3. Compiled query is sent to your data plane
4. Your data plane executes and returns results
5. Partner receives results through the control plane
Your raw data never leaves your data plane—only the query results authorized by your access rules.
## Data egress via connectors
While the architecture above keeps data within data planes, you often need to deliver data to external destinations for activation. [Connectors](/reference/connectors/index) enable delivery to platforms like advertising DSPs, cloud storage, and data warehouses.
### How connectors work
Connectors are pre-built integrations that handle authentication, data formatting, and delivery to external platforms. The data flow for connector-based delivery:
```
Data Plane Connector External Destination
│ │ │
│ │ │
│──── Dataset ────────────>│ │
│ │ │
│ ┌────┴────┐ │
│ │ Format │ │
│ │ Data │ │
│ └────┬────┘ │
│ │ │
│ │──── Formatted Data ───────>│
│ │ │
│ │<──── Confirmation ─────────│
│ │ │
```
Unlike queries (where only results leave the data plane), connector delivery sends the full dataset to the external destination. This is intentional—connectors exist specifically to move data outside the platform for activation.
### Setting up delivery
Connector-based delivery involves two configuration levels:
| Level | What it configures | Example |
| --------------------- | ----------------------------------------- | ------------------------------------- |
| **Profile** | Account-level authentication and settings | AWS credentials, ad account ID |
| **Delivery settings** | Job-specific parameters | Destination folder, delivery schedule |
Once a connector is configured, delivery options appear automatically when creating materialized views or working with datasets.
Each connector requires specific identifier types to match users on the destination platform. See [Connectors Reference](/reference/connectors/index) for requirements by platform.
***
## Related content
The complete query lifecycle from submission to results
Where your data lives and queries execute
How queries are compiled and coordinated
Available destinations for data delivery
# Query Processing
Source: https://docs.narrative.io/concepts/architecture/query-processing
How NQL queries are compiled, dispatched, and executed across the platform
When you run an NQL query, it passes through several stages before returning results. The [control plane](/concepts/primitives/control-plane) compiles and coordinates the query, while [data plane](/concepts/primitives/data-planes) operators execute it against your actual data. This separation ensures your data stays in your infrastructure while enabling cross-organization collaboration.
## The query lifecycle
A query moves through six stages from submission to results:
```
┌─────────────────────────────────────────────────────────────────┐
│ Control Plane │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Parse │ → │ Transpile │ → │ Job Queue │ │
│ │ NQL │ │ to SQL │ │ (awaiting pickup) │ │
│ └──────────┘ └──────────────┘ └───────────┬────────────┘ │
│ │ │
└─────────────────────────────────────────────────┼────────────────┘
│ poll
▼
┌─────────────────────────────────────────────────────────────────┐
│ Data Plane (Your Infrastructure) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Operator │ → │ Execute │ → │ Results │ │
│ │ (polls) │ │ Native SQL │ │ │ │
│ └──────────┘ └──────────────┘ └────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Stage 1: Query submission
You submit an NQL query to the control plane through:
* The **Query Editor** in the Narrative I/O interface
* The **NQL API** for programmatic access
* **Materialized view definitions** that run on a schedule
The control plane receives your query and begins processing.
## Stage 2: Compilation and transpilation
The control plane transforms your NQL into executable SQL through several steps:
**Parsing.** The NQL syntax is parsed and validated. Malformed queries fail here with syntax errors.
**Resolution.** Dataset references are resolved to their actual locations. [Rosetta Stone](/concepts/rosetta-stone/overview) mappings translate standardized attribute names to physical column names.
**Permission verification.** Your [access rules](/concepts/primitives/access-rules) are checked for every dataset and field in the query. If you lack permission to access any referenced data, the query fails before execution.
**Optimization.** The query planner analyzes the query structure to determine an efficient execution strategy. For large dataset scans, this may include [chunking](/concepts/nql/chunking)—splitting the query into time-based segments for improved stability and cost efficiency.
**Transpilation.** The NQL is converted to native SQL for your target data plane's database engine. A Snowflake data plane receives Snowflake SQL; a Spark data plane receives Spark SQL. See [NQL Design Philosophy](/concepts/nql/design-philosophy) for details on transpilation.
At the end of this stage, the control plane has a compiled query ready for execution.
## Stage 3: Job enqueueing
The control plane creates a job containing the compiled SQL and adds it to the job queue. The job queue is a coordination mechanism between the control plane and data planes—it holds work waiting for execution.
Query execution is one type of job. The job queue also handles other operations like dataset management and system tasks. See [Job Types](/reference/architecture/job-types) for a complete list.
## Stage 4: Operator polling
Each data plane runs an operator component that bridges the control plane and your data infrastructure. The operator:
* **Polls** the control plane's job queue for work targeting its data plane
* **Authenticates** to ensure only authorized operators can pick up jobs
* **Claims** jobs for execution
This is a **pull-based architecture**. The control plane never connects directly to your database—it only makes jobs available. Your operator reaches out to claim them. This design means:
* Your data plane can be behind a firewall
* The control plane never needs credentials to your database
* You control when and how jobs are executed
## Stage 5: Query execution
Once the operator picks up a job, it executes the compiled SQL against your data plane's native query engine—Snowflake, Spark, or another supported system.
The query runs entirely within your infrastructure. Your data never leaves the data plane; only query instructions enter and results exit.
## Stage 6: Result handling
After execution completes, results are stored in the data plane—never returned directly through the control plane:
**Interactive queries.** Results are stored as a new dataset in your data plane. Interactive queries are implemented as materialized views with a **24-hour retention policy** and an **automatic row limit**. The control plane receives only a completion status—not the data itself. You view results through [data sampling](/concepts/architecture/data-flow#how-you-view-query-results), which retrieves a preview of up to 1,000 rows.
**Materialized views.** Results are written to a dataset in your data plane. The control plane is notified of completion but doesn't see the actual data. Materialized views can have custom retention policies and refresh schedules.
**Data exports.** Results are written to a designated destination within your data plane.
Throughout execution, status updates flow back to the control plane so you can monitor progress. See [Data Flow](/concepts/architecture/data-flow) for details on how data moves through the platform.
## Why this architecture?
The separation between control plane and data plane is deliberate:
**Security.** Your data stays in your infrastructure. The control plane coordinates work without accessing raw data. This enables collaboration while maintaining data residency requirements.
**Flexibility.** Each organization can use their preferred database system. The control plane transpiles NQL to whatever dialect your data plane requires.
**Scalability.** Operators scale with your data plane's capacity. You can run multiple operators for high-throughput workloads or share capacity across fewer operators.
**Reliability.** The pull-based job queue provides resilience. If an operator is temporarily unavailable, jobs wait in the queue until capacity returns.
***
## Related content
Why NQL is an interpreted language with transpilation
How large queries are split into time-based segments
The orchestration layer that compiles queries and coordinates jobs
Where your data lives and queries execute
# Sample Data
Source: https://docs.narrative.io/concepts/architecture/sample-data
Understand how data samples cross from data planes to the control plane, and the governance implications
Sample data is one of the rare cases where actual data leaves your [data plane](/concepts/primitives/data-planes) and is stored in the [control plane](/concepts/primitives/control-plane). For organizations with data residency requirements or strict governance policies, this distinction matters.
## What are data samples?
A data sample is a preview of up to 1,000 rows from a dataset. Samples enable you to view query results, validate data quality, and inspect datasets without downloading full result sets.
When you click to view query results in the UI or retrieve results via the API, the platform creates a sample from your dataset and stores it in the control plane for display.
## Why samples cross the boundary
Narrative's architecture is built around a core principle: [data stays in place](/concepts/architecture/data-flow). Queries are sent to your data plane, executed locally, and results remain within your infrastructure.
Samples are the exception. To display query results in the UI or return them via the API, actual row-level data must be transmitted from your data plane to the control plane and stored there.
Unlike full query results (which stay in your data plane), sample data is transmitted to and persisted in Narrative's control plane infrastructure. If your datasets contain sensitive information, that information will be present in any samples you create.
This is one of only two scenarios where data egresses from your data plane:
| Scenario | Description | Destination |
| ---------------------- | --------------------------------- | ----------------------------------- |
| **Data samples** | Preview rows for viewing results | Control plane database |
| **Connector delivery** | Intentional export for activation | External platforms (S3, DSPs, etc.) |
## What data is included in samples
When a sample is created:
* **Row limit** — Up to 1,000 rows are retrieved
* **All columns** — Every column in the dataset is included
* **Rosetta Stone attributes** — If the dataset has [Rosetta Stone mappings](/concepts/rosetta-stone/how-it-works), normalized attribute values are included alongside the raw columns
* **No transformation** — Raw data is not masked, hashed, or modified (Rosetta Stone attributes reflect their normalized transformations)
* **Point-in-time snapshot** — The sample reflects the dataset at the moment of retrieval
## Sample lifecycle
Samples are created on-demand, not automatically.
### Creation
A sample is created when:
* You click to view query results in the Query Editor
* You call `requestDatasetSample()` via the SDK
* You request results through the API
Each sample creation triggers a [sample job](/reference/architecture/job-types#sample-jobs) that retrieves rows from your data plane and transmits them to the control plane.
### Storage
Once created, samples persist in the control plane database until explicitly deleted. They do not expire automatically.
### Deletion
Samples can be removed:
* Programmatically via `deleteDatasetSample()` in the SDK
* Through the UI when clearing query results
* When the underlying dataset is deleted
## Governance considerations
### Data residency
Sample data resides in Narrative's control plane infrastructure, which may be in a different geographic region than your data plane. If you operate a customer-hosted data plane specifically for data residency requirements, be aware that samples bypass this isolation.
### Sensitive data exposure
If your dataset contains PII or other sensitive information, that data will be present in samples stored in the control plane. Consider:
* Whether viewing results is necessary for your workflow
* Clearing samples promptly after inspection
* Using direct data plane access for sensitive datasets
### Retention practices
Since samples persist until deleted, establish practices for clearing them when no longer needed. This reduces the footprint of sensitive data in the control plane.
For datasets containing sensitive information, clear samples after inspection using the SDK's `deleteDatasetSample()` method. See [Managing Datasets](/guides/sdk/managing-datasets#sampling-data) for implementation details.
### Access control
Sample access follows the same permission model as the underlying dataset. Users who can query a dataset can view its samples.
## Alternatives to sampling
If you need to avoid storing data in the control plane:
* **Direct data plane access** — Query and process data within your data plane infrastructure
* **Cloud storage export** — Use [connectors](/reference/connectors/index) to deliver data to your own S3 bucket or data warehouse
* **Materialized views** — Access the underlying dataset directly rather than viewing samples
These approaches keep data within infrastructure you control, at the cost of not being able to preview results in the Narrative UI.
***
## Related content
How data moves through the platform
What the control plane manages and stores
How Narrative protects your data
SDK methods for sample management
# Security Model
Source: https://docs.narrative.io/concepts/architecture/security-model
Understand how Narrative protects your data through architecture, access controls, and operational practices
Narrative's security model is built on the principle that your data remains under your control. The platform architecture, access controls, and operational practices work together to protect sensitive information while enabling collaboration.
## Separation of control and data planes
Narrative's most fundamental security design is the separation between the [control plane](/concepts/primitives/control-plane) and [data planes](/concepts/primitives/data-planes).
**The control plane** manages metadata, permissions, and orchestration. It knows *about* your data—schema, access rules, query patterns—but never sees the data itself.
**Data planes** store and process actual data. They can be hosted by Narrative or within your own infrastructure, giving you control over where sensitive information resides.
This separation provides several security benefits:
* **Data residency control** — Keep data within specific geographic or infrastructure boundaries
* **Reduced attack surface** — The control plane has no access to raw data, limiting exposure from any single point of compromise
* **Audit clarity** — Data access and metadata operations are logged separately, making compliance auditing straightforward
## Access control model
Narrative uses a multi-layered access control system:
### Organization-level controls
Organizations are the top-level security boundary. Each organization has:
* **Member management** — Control who belongs to your organization
* **Role assignments** — Assign admin or standard roles to members
* **Authentication policies** — Enforce SSO, MFA requirements, or other authentication standards through your identity provider
### Dataset-level controls
When you register or share data, you define who can access it:
* **Visibility rules** — Control which organizations can discover your datasets
* **[Access rules](/concepts/primitives/access-rules)** — Define who can query your data, what they can access, and pricing terms
* **Field-level permissions** — Restrict access to sensitive columns within a dataset
### Query-time enforcement
Access controls are enforced at query execution, not just at the UI level:
* Every query is validated against current permissions before execution
* Changes to access rules take effect immediately
* Queries that exceed granted permissions fail with clear error messages
## Data isolation
Data from different organizations never mingles unintentionally:
**Storage isolation** — Datasets are stored with clear ownership boundaries. Customer-hosted data planes provide physical isolation when required.
**Query isolation** — When you query across organizations (in data collaboration scenarios), the data plane enforces that you only see data you're explicitly permitted to access.
**Result isolation** — Query results are delivered only to the requesting user. Full result sets are not stored beyond the query session, though [data samples](/concepts/architecture/sample-data) (up to 1,000 rows for viewing) are stored in the control plane until explicitly cleared.
## Encryption
Narrative encrypts data at multiple levels:
| Layer | Protection |
| ------------------ | ----------------------------------------------------------------------- |
| **In transit** | All API and data transfer uses TLS 1.2+ encryption |
| **At rest** | Data planes encrypt stored data using AES-256 |
| **Key management** | Encryption keys are managed separately from data, with regular rotation |
Customer-hosted data planes can integrate with your organization's key management infrastructure for additional control.
## Authentication
Narrative supports multiple authentication methods:
* **Email magic links** — Passwordless authentication for quick access
* **Single Sign-On (SSO)** — SAML 2.0 integration with enterprise identity providers
* **API keys** — Long-lived tokens for programmatic access with scoped permissions
Enterprise organizations can enforce SSO-only authentication and inherit MFA requirements from their identity provider.
For SSO configuration details, see [SSO Configuration](/account-settings/sso). To learn how API key permissions work, see [API Key Permissions](/concepts/security/api-key-permissions) and the [Permissions Reference](/reference/security/permissions).
## Audit and compliance
Narrative maintains comprehensive audit logs:
* **Authentication events** — Login attempts, SSO assertions, API key usage
* **Data access** — Queries executed, datasets accessed, results delivered
* **Administrative actions** — Permission changes, member management, configuration updates
These logs support compliance requirements including SOC 2 Type II. Contact your account representative for compliance documentation.
## Operational security
Beyond architectural controls, Narrative maintains operational security practices:
* **Vulnerability management** — Regular security assessments and penetration testing
* **Incident response** — Documented procedures for security event handling
* **Employee access** — Strict access controls and background checks for personnel with system access
* **Change management** — All production changes go through review and approval processes
## Shared responsibility
Security is a shared responsibility between Narrative and customers:
**Narrative is responsible for:**
* Platform infrastructure security
* Control plane availability and integrity
* Narrative-hosted data plane security
* Security updates and vulnerability remediation
**Customers are responsible for:**
* Managing organization membership and roles
* Configuring appropriate access controls for datasets
* Securing API keys and credentials
* Customer-hosted data plane security (when applicable)
## Related content
Control who can access your data and at what price
Understand where your data lives and how data planes provide isolation
Learn how Narrative orchestrates operations without accessing your data
Configure enterprise authentication for your organization
How scoped permissions control what API keys can do
# CCPA
Source: https://docs.narrative.io/concepts/compliance/ccpa
Understanding the California Consumer Privacy Act and California Privacy Rights Act
The California Consumer Privacy Act (CCPA), as amended by the California Privacy Rights Act (CPRA), is the most comprehensive state-level privacy law in the United States. Effective since January 2020 and significantly strengthened in 2023, CCPA gives California residents substantial rights over their personal information and imposes obligations on businesses that collect or process that data.
## Who CCPA applies to
CCPA applies to for-profit businesses that collect California consumers' personal information and meet any of the following thresholds:
| Threshold | Criteria |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Annual revenue** | Gross annual revenue exceeding \$25 million |
| **Data volume** | Buys, sells, or shares the personal information of 100,000 or more California consumers, households, or devices annually |
| **Revenue from data** | Derives 50% or more of annual revenue from selling or sharing California consumers' personal information |
CCPA applies based on where the consumer resides, not where your business is located. A company in New York with California customers must comply if it meets the thresholds.
### Who is a "consumer"?
Under CCPA, a "consumer" is any California resident—defined as a natural person who is in California for other than a temporary or transitory purpose, or who is domiciled in California but currently outside the state temporarily.
***
## Key definitions
CCPA uses specific terminology that differs from GDPR and other privacy frameworks:
| Term | Definition |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Personal information** | Information that identifies, relates to, describes, is reasonably capable of being associated with, or could reasonably be linked with a particular consumer or household. Broader than many definitions, including IP addresses, browsing history, and inferences. |
| **Sale** | Selling, renting, releasing, disclosing, disseminating, making available, or transferring personal information for monetary or other valuable consideration. |
| **Share** | Sharing personal information for cross-context behavioral advertising purposes, whether or not for monetary consideration. Added by CPRA. |
| **Service provider** | An entity that processes personal information on behalf of a business pursuant to a written contract that prohibits retaining, using, or disclosing the information for purposes other than performing the services. |
| **Contractor** | Similar to a service provider but with additional restrictions. Added by CPRA to close perceived loopholes. |
| **Business** | A for-profit entity that collects consumers' personal information and meets CCPA thresholds. |
The "sale" definition is broader than cash transactions. Sharing data with an advertising partner in exchange for services can constitute a sale under CCPA.
***
## Consumer rights
CCPA grants California consumers specific rights over their personal information:
### Right to know
Consumers can request that a business disclose:
* The categories of personal information collected
* The sources from which information was collected
* The business or commercial purpose for collection or sale
* The categories of third parties with whom information is shared
* The specific pieces of personal information collected about them
### Right to delete
Consumers can request deletion of their personal information, with limited exceptions for legal obligations, completing transactions, security purposes, and certain internal uses.
### Right to correct
Added by CPRA, consumers can request correction of inaccurate personal information that a business maintains about them.
### Right to opt-out of sale/sharing
Consumers can direct businesses not to sell or share their personal information. Businesses must provide a clear "Do Not Sell or Share My Personal Information" link on their website.
### Right to limit use of sensitive personal information
Added by CPRA, consumers can limit a business's use of sensitive personal information (such as Social Security numbers, financial accounts, precise geolocation, or racial/ethnic origin) to purposes necessary for providing the requested goods or services.
### Right to non-discrimination
Businesses cannot discriminate against consumers who exercise their CCPA rights through:
* Denying goods or services
* Charging different prices
* Providing different quality of goods or services
* Suggesting that exercising rights will result in any of the above
***
## Business obligations
Businesses subject to CCPA must fulfill several requirements:
### Privacy notices
Provide a privacy policy that discloses:
* Categories of personal information collected in the past 12 months
* Purposes for each category
* Categories of sources and third parties
* Consumer rights and how to exercise them
* Whether information is sold or shared, with opt-out instructions
### Responding to consumer requests
* Provide at least two methods for submitting requests (for online businesses, this must include a web form)
* Verify the identity of consumers making requests
* Respond within 45 days (extendable by an additional 45 days with notice)
* Provide information free of charge for up to two requests per year
### Service provider contracts
Maintain written contracts with service providers and contractors that:
* Specify the business purposes for processing
* Prohibit selling or sharing the information
* Require the same level of privacy protection as required by CCPA
* Grant the business rights to monitor compliance
### Training
Ensure that personnel handling consumer inquiries about privacy practices are informed of CCPA requirements.
### Record keeping
Businesses processing personal information of 10 million or more consumers must maintain records of requests and responses for 24 months.
***
## Sensitive personal information
CPRA created a special category of "sensitive personal information" with additional protections:
* Social Security, driver's license, state ID, or passport numbers
* Account log-in credentials (username with password or security questions)
* Financial account, debit card, or credit card numbers with access codes
* Precise geolocation
* Racial or ethnic origin, religious or philosophical beliefs, union membership
* Contents of mail, email, or text messages (unless the business is the intended recipient)
* Genetic data
* Biometric information for identification purposes
* Health information
* Sex life or sexual orientation information
Consumers can limit the use of sensitive personal information to what is necessary to perform the services or provide the goods reasonably expected.
***
## Enforcement and penalties
CCPA is enforced by the California Privacy Protection Agency (CPPA) and the California Attorney General:
| Violation type | Penalty |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Intentional violations** | Up to \$7,500 per violation |
| **Unintentional violations** | Up to \$2,500 per violation |
| **Data breaches** | Private right of action with statutory damages of $100-$750 per consumer per incident, or actual damages if greater |
Penalties are assessed per violation, meaning each affected consumer or each instance of non-compliance can be a separate violation. This can result in substantial aggregate penalties for widespread issues.
Unlike GDPR, CCPA provides a limited private right of action allowing consumers to sue directly—but only for data breaches involving non-encrypted or non-redacted personal information due to a business's failure to maintain reasonable security procedures.
***
## CCPA vs. GDPR
While both laws protect consumer privacy, they differ in significant ways:
| Aspect | CCPA | GDPR |
| ---------------------------- | -------------------------------------------------------------- | ----------------------------------------------------- |
| **Scope** | For-profit businesses meeting thresholds, California consumers | Any organization processing EU residents' data |
| **Approach** | Opt-out model (consumers must request restrictions) | Opt-in model (requires legal basis before processing) |
| **Personal data definition** | Includes household and device data | Focuses on identified individuals |
| **Enforcement** | State agency and Attorney General | National Data Protection Authorities |
| **Private right of action** | Limited to data breaches | Generally not available |
| **Penalties** | Per-violation penalties | Percentage of global revenue |
***
## Related content
Submit opt-out and deletion requests in Narrative
Compare with the EU's privacy regulation
Privacy-preserving data collaboration techniques
How Narrative protects data throughout the platform
# GDPR
Source: https://docs.narrative.io/concepts/compliance/gdpr
Understanding the General Data Protection Regulation and its requirements for personal data processing
The General Data Protection Regulation (GDPR) is a comprehensive privacy law that governs how organizations collect, process, and store personal data of individuals in the European Union. Since its enforcement in May 2018, GDPR has become the global benchmark for data protection legislation and influences privacy regulations worldwide.
## Who GDPR applies to
GDPR has broad territorial scope. It applies to your organization if:
### Establishment in the EU
You have any establishment (office, subsidiary, or branch) in the EU, regardless of whether the data processing takes place there.
### Offering goods or services to EU residents
You offer goods or services to individuals in the EU, even if your organization is based elsewhere. This includes free services if you're targeting EU users.
### Monitoring behavior of EU residents
You monitor the behavior of individuals in the EU—such as tracking website visitors or profiling users for advertising purposes.
GDPR applies based on where the individuals are located, not their citizenship. An American tourist in Paris has GDPR protections while in the EU.
***
## Key definitions
Understanding GDPR requires familiarity with its specific terminology:
| Term | Definition |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Personal data** | Any information relating to an identified or identifiable natural person. This includes names, email addresses, IP addresses, location data, and online identifiers. |
| **Data subject** | The individual whose personal data is being processed. |
| **Data controller** | The entity that determines the purposes and means of processing personal data—essentially, the decision-maker about what data to collect and why. |
| **Data processor** | An entity that processes personal data on behalf of a controller. Processors act on instructions from controllers. |
| **Processing** | Any operation performed on personal data, including collection, storage, use, transmission, and deletion. |
Organizations often act as both controllers and processors depending on the context. When you decide what customer data to collect, you're a controller. When you process data on behalf of a client according to their instructions, you're a processor.
***
## Core principles
GDPR establishes seven principles that govern all personal data processing:
### Lawfulness, fairness, and transparency
Processing must have a valid legal basis, must not be deceptive or harmful, and individuals must be informed about how their data is used.
### Purpose limitation
Data must be collected for specified, explicit, and legitimate purposes. You cannot repurpose data for unrelated uses without additional consent or legal basis.
### Data minimization
Only collect and process data that is necessary for your stated purposes. Avoid collecting data "just in case" it might be useful later.
### Accuracy
Personal data must be accurate and kept up to date. Inaccurate data should be corrected or deleted without delay.
### Storage limitation
Data should be kept only as long as necessary for the purposes it was collected. Establish retention periods and delete data when no longer needed.
### Integrity and confidentiality
Data must be processed securely, with appropriate technical and organizational measures to protect against unauthorized access, loss, or damage.
### Accountability
Organizations must demonstrate compliance with all principles. This means maintaining records, conducting assessments, and being able to prove you're following the rules.
***
## Data subject rights
GDPR grants individuals significant control over their personal data:
### Right to be informed
Individuals must receive clear information about who is processing their data, why, and how—typically through privacy notices.
### Right of access
Individuals can request a copy of their personal data and information about how it's being processed.
### Right to rectification
Individuals can request correction of inaccurate or incomplete personal data.
### Right to erasure ("right to be forgotten")
Individuals can request deletion of their personal data in certain circumstances, such as when the data is no longer necessary or when they withdraw consent.
### Right to restrict processing
Individuals can request that processing be limited while disputes about accuracy or lawfulness are resolved.
### Right to data portability
Individuals can receive their data in a structured, machine-readable format and transfer it to another controller.
### Right to object
Individuals can object to processing based on legitimate interests or for direct marketing purposes.
### Rights related to automated decision-making
Individuals can request human review of significant decisions made solely by automated processing, including profiling.
***
## Legal bases for processing
GDPR requires a valid legal basis for any processing of personal data. The six legal bases are:
| Legal basis | When to use |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Consent** | The individual has given clear, informed consent for a specific purpose. Must be freely given, specific, informed, and unambiguous. |
| **Contract** | Processing is necessary to fulfill a contract with the individual or to take steps at their request before entering a contract. |
| **Legal obligation** | Processing is necessary to comply with a legal requirement. |
| **Vital interests** | Processing is necessary to protect someone's life. Rarely applicable in commercial contexts. |
| **Public task** | Processing is necessary to perform an official function or task in the public interest. Primarily for public authorities. |
| **Legitimate interests** | Processing is necessary for your legitimate interests, provided these aren't overridden by the individual's rights. Requires a balancing test. |
Consent is not always the best legal basis. If you cannot genuinely offer a choice or would continue processing regardless of consent, consider whether contract or legitimate interests is more appropriate.
***
## Compliance requirements
Organizations subject to GDPR must implement various measures:
### Privacy notices
Provide clear, accessible information about your data processing activities at the point of data collection.
### Records of processing
Maintain detailed records of all processing activities, including purposes, data categories, recipients, and retention periods.
### Data Protection Impact Assessments (DPIAs)
Conduct assessments for high-risk processing activities, such as large-scale profiling or processing sensitive data.
### Data Protection Officer (DPO)
Appoint a DPO if you're a public authority, conduct large-scale monitoring, or process sensitive data at scale.
### Data Processing Agreements (DPAs)
Establish written contracts with any processors that handle personal data on your behalf, specifying their obligations.
### Breach notification
Report personal data breaches to supervisory authorities within 72 hours and notify affected individuals when there's high risk to their rights.
### Cross-border transfers
Implement appropriate safeguards when transferring personal data outside the EU, such as Standard Contractual Clauses (SCCs) or binding corporate rules.
***
## Enforcement and penalties
GDPR is enforced by Data Protection Authorities (DPAs) in each EU member state. Penalties for non-compliance can be significant:
* **Lower tier**: Up to 10 million EUR or 2% of global annual turnover for violations of technical requirements
* **Upper tier**: Up to 20 million EUR or 4% of global annual turnover for violations of core principles and data subject rights
Beyond fines, DPAs can issue warnings, reprimands, and orders to cease processing—which can effectively shut down data-dependent business operations.
***
## Related content
Submit opt-out and deletion requests in Narrative
Compare GDPR with California's privacy regulation
A GDPR-recognized data protection technique
How Narrative protects data throughout the platform
# Sub-processors
Source: https://docs.narrative.io/concepts/compliance/sub-processors
Third-party service providers that process data on behalf of Narrative
Narrative engages certain sub-processors to assist with delivering our services. These third-party providers help process both Narrative-owned data and customer-owned data in accordance with our data processing agreements and applicable privacy regulations.
**Stay informed of changes**
[Subscribe to receive notifications](https://share.hsforms.com/1YeBBL96xThWvrI7iE4ZAiA38eec) when we update our list of sub-processors.
## Current sub-processors
| Name | Data processed | Purpose | Location |
| ------------------------- | ---------------------------------------------- | -------------------------------- | ------------- |
| Amazon Web Services, Inc. | Customer-owned data, Narrative-owned user data | Cloud infrastructure and hosting | United States |
| Stytch, Inc. | Narrative-owned user data | User authentication and OAuth | United States |
## Related
Learn about our GDPR compliance measures
Understand our security architecture
# Audience Studio
Source: https://docs.narrative.io/concepts/data-activation/audience-studio
Build, filter, and deliver audiences to advertising platforms using a guided builder
Audience Studio is the platform's visual audience builder. It provides a guided workflow for selecting a data source, applying filters to segment your audience, choosing connector destinations, and delivering the resulting audience — all without writing NQL by hand.
## How Audience Studio works
Audience Studio builds audiences by combining three things:
1. **A source dataset** — The data you want to segment. This can be your own first-party data (CRM records, transaction data, customer attributes) or third-party data accessed through access rules.
2. **Filters** — Conditions that narrow the source data to the audience you want. Filters use your dataset's Rosetta Stone attribute mappings to provide structured, type-aware controls.
3. **Connectors** — Destinations where the audience will be delivered. Each connector handles format translation, identity matching, and API integration with the destination platform automatically.
The builder generates an NQL query behind the scenes, executes it to create an audience dataset, and sets up connector deliveries — turning what would be a multi-step manual process into a single workflow.
## Builder steps
### 1. Source selection
Choose the dataset that contains your audience data. The dataset must have [Rosetta Stone attribute mappings](/guides/rosetta-stone/mapping-schemas) so Audience Studio can understand which fields are available for filtering and which identifiers are available for delivery.
Only datasets on the currently selected [data plane](/concepts/primitives/data-planes) are shown.
### 2. Connector selection
Choose one or more destination platforms to deliver your audience to. Available connectors include The Trade Desk, Meta, TikTok, Google DV360, Pinterest, Yahoo DSP, Magnite, PubMatic, and Amazon S3.
Audience Studio checks **connector eligibility** automatically — it verifies that your source dataset contains the identifier types required by each connector's collaboration policy. Ineligible connectors are flagged so you know before building the audience whether delivery will work.
### 3. Filters
Apply conditions to narrow your source data. Filters are built from your dataset's mapped attributes, so the available filter options reflect your actual data.
#### Filter types
| Type | Controls | Example |
| --------------------------- | ---------------------------------------------- | ------------------------------- |
| **Enum** (low cardinality) | Checkbox list of values | Gender: male, female |
| **Enum** (high cardinality) | Searchable dropdown | Country: US, CA, GB |
| **Boolean** | Toggle | Opted in: true / false |
| **String** | Text input with multiple values | Email domain: gmail.com |
| **Timestamp** | Curated ranges, custom lookback, or date range | Last 30 days, last 90 days |
| **Numeric** (long, double) | Comparison operators with values | Age greater than or equal to 25 |
#### Logical operators
Filters support **AND** / **OR** logic. Multiple filters default to AND — all conditions must match. Group filters with OR to match any condition within a group.
#### Frequency filters
Any filter supports an optional **frequency** constraint — for example, "purchased at least 3 times in the last 30 days." Frequency filters use a `QUALIFY` clause in the generated NQL to count distinct occurrences of a field.
#### Null handling
Filters include **Is null** and **Is not null** options that generate correct `IS NULL` / `IS NOT NULL` NQL predicates, allowing you to filter on the presence or absence of a value.
### 4. Finalize
Configure the audience metadata and connector-specific settings:
* **Audience name** (required) — Identifies this audience in the platform and in destination systems
* **Description** — Optional context about the audience's purpose
* **Tags** — Optional labels for organization
* **Refresh schedule** — How often the audience is rebuilt. Choose **one-time** for a static audience or set a recurring schedule (daily, weekly, etc.) to keep the audience current as source data changes
* **Connector quick settings** — Each selected connector has platform-specific fields (advertiser selection, audience naming, historical data options). These are validated inline before creation.
## Forecasting
Before creating an audience, you can **forecast** its size. The forecast runs a preview query against your source data with the current filters applied and returns the expected record count. This helps you verify that your filters produce the right audience size before committing to creation.
Suggested filter values show what percentage of your forecasted audience matches each value, helping you understand the impact of adding or adjusting filters.
## What Audience Studio creates
When you click **Create Audience**, Audience Studio:
1. Generates and executes an NQL query based on your filters
2. Materializes the results as a new dataset tagged as an audience
3. Creates connector deliveries for each selected destination with your configured quick settings
4. If a refresh schedule is set, configures incremental updates using NQL `MERGE` to keep the audience current
The resulting audience dataset appears under **My Audiences** in the navigation and can be managed, cloned, or delivered to additional connectors at any time.
## Cloning audiences
You can clone an existing audience by selecting **Open in Audience Studio** from the audience's action menu. The builder pre-populates with the original audience's source, filters, connectors, and settings, giving you a starting point for creating a similar but distinct audience.
## Related content
Step-by-step guide to creating your first audience
Expand a seed audience by scoring a larger population
Strategies for organizing data for activation
How connectors deliver data to destination platforms
Technical details for each destination connector
# Look-alike Studio
Source: https://docs.narrative.io/concepts/data-activation/lookalike-studio
Expand a seed audience by scoring a larger population against the seed using a guided builder
Look-alike Studio is the platform's visual look-alike audience builder. It takes a small **seed** dataset of users you already value (converters, high-LTV customers, opted-in subscribers) and scores a larger **population** dataset to find users who resemble the seed — producing a new audience of likely-to-convert users without writing NQL by hand.
## How Look-alike Studio works
A look-alike audience is built from three inputs:
1. **A seed dataset** — Users who exhibit the behavior you want to find more of. Typically small (thousands to low millions of records).
2. **A population dataset** — The larger universe to score against. Must share a join-key identifier with the seed (for example, both datasets map `sha256_hashed_email`).
3. **Shared attributes** — Categorical or continuous fields present on both datasets that describe each user. These are the features used to score similarity.
The builder generates a multi-stage NQL workflow that computes per-attribute weights from the seed, scores every population user against the seed distribution, and materializes the highest-scoring users as a new audience dataset.
## Scoring model
Look-alike Studio uses a Naive Bayes model with Gaussian terms for continuous attributes:
* **Categorical attributes** (country, device type, product category) contribute log-likelihood weights based on how much more frequently each value appears in the seed versus the population.
* **Continuous attributes** (age, lifetime value, days since last visit) contribute Gaussian density terms parameterized by the seed's mean and variance for each attribute.
* **Attribute weights** down-weight low-information attributes — an attribute that has the same distribution in the seed and the population contributes little signal.
Each population user receives a single composite score between 0 and 1 representing how seed-like they are.
## Builder steps
### 1. Seed selection
Choose the dataset of users you want to find more of. The dataset must have [Rosetta Stone attribute mappings](/guides/rosetta-stone/mapping-schemas) so the builder can identify join keys and shared features.
Only datasets on the currently selected [data plane](/concepts/primitives/data-planes) are shown. Pipeline intermediates and other audiences are hidden from the picker.
### 2. Population selection
Choose the larger dataset to score against. The population must share at least one **join-key identifier attribute** with the seed (such as `sha256_hashed_email` or `narrative_id`) so the builder can identify which population users overlap with seed users and exclude or include them appropriately.
If the seed and population do not share a join-key attribute, the dataset is shown as ineligible.
### 3. Attribute selection
Look-alike Studio classifies every attribute that exists on both datasets into one of three roles:
| Role | Description | Used for scoring? |
| --------------- | ------------------------------------------------------------- | ------------------------------------------ |
| **Identity** | Identifiers like hashed email, narrative\_id, MAID | No — used only to join seed and population |
| **Categorical** | Low-to-medium cardinality fields like country, device, gender | Yes — Naive Bayes |
| **Continuous** | Numeric fields like age, LTV, frequency | Yes — Gaussian density |
You select which classified attributes to include as scoring features. More attributes is not always better — irrelevant or correlated attributes add noise. Start with 3–8 attributes that you believe describe what makes the seed distinctive.
### 4. Output configuration
Choose how the look-alike audience is sized:
* **Limit by size** — Return the top-N highest-scoring population users (for example, the top 100,000).
* **Limit by score** — Return every population user whose score exceeds a threshold (for example, score ≥ 0.5).
You also choose whether to include the original seed users in the output:
* **New users only** — Only population users not already in the seed are returned. Use when you want net-new reach.
* **New + original seed users** — Seed users are added to the output. Use when you want a single combined audience to activate.
### 5. Finalize
Configure the output dataset metadata:
* **Audience name** (required) — Identifies the audience in the platform and downstream systems.
* **Description** — Optional context.
* **Tags** — Optional labels. The system automatically applies the `_nio_audience` and `_nio_lookalike` tags so the audience appears under **My Audiences** and is recognizable as look-alike-derived.
Name uniqueness is enforced against all existing datasets, including hidden interactive results and look-alike pipeline intermediates.
## What Look-alike Studio creates
When you click **Create Look-alike Audience**, the builder:
1. Assembles the seed, population, attribute selections, and output config into a multi-stage workflow YAML.
2. Submits the workflow via the workflow engine with `trigger_immediately`.
3. Materializes around a dozen intermediate views (canonical feature combos, per-attribute distributions, attribute weights, scored candidates) tagged `_nio_lookalike_intermediate`. These are hidden from the standard dataset catalog so only the final audience appears under **My Audiences**.
4. Materializes a final audience dataset with the `_nio_audience` and `_nio_lookalike` tags, your display name, description, and any user-supplied tags applied as part of the `CREATE MATERIALIZED VIEW` so the audience is born fully tagged even if you leave mid-run.
The run surfaces a dismissible progress dialog — closing it does not cancel the workflow, which continues server-side. When the run completes, the audience opens in the standard audience metadata drawer and is available for delivery through any compatible [connector](/reference/connectors/index).
## Resuming or restarting
* **Dismiss progress, keep running** — Closing the progress dialog leaves the workflow running. The audience appears under **My Audiences** when it finishes.
* **Retry on error** — A failed run shows an error dialog with a **Retry** action that resubmits the same workflow.
* **Start Over** — Cancels polling and resets all builder state so you can build a different look-alike audience.
## Related content
Step-by-step guide to creating your first look-alike audience
Filter-based audience builder for direct segmentation
Strategies for organizing data for activation
Technical details for each destination connector
# Data Activation
Source: https://docs.narrative.io/concepts/data-activation/overview
Deliver audience data from Narrative to advertising and marketing platforms
Data activation is the process of delivering audience data from Narrative to external platforms—such as demand-side platforms (DSPs), social networks, and analytics tools—where you can take action on that data. Connectors are the mechanism that make this possible.
## What connectors do
A connector is a pre-built integration that connects Narrative to an external destination. Connectors handle the complexities of delivering data to each platform:
* **Format translation**: Converts your data into the format required by the destination
* **Identity matching**: Maps your identifiers to the platform's identity space
* **API integration**: Manages authentication, rate limits, and delivery protocols
* **Audience management**: Creates and updates audiences or segments in the destination
When you deliver data through a connector, you don't need to build custom integrations or understand each platform's technical requirements. The connector handles all of this automatically.
## How activation works
```mermaid theme={null}
flowchart LR
A[Dataset] --> B[NQL Query]
B --> C[Connector]
C --> D[Destination Platform]
style A fill:#e1e8f0
style B fill:#e1e8f0
style C fill:#1438F5,color:#fff
style D fill:#e1e8f0
```
1. **Prepare your data**: Structure your audience data in one or more [datasets](/concepts/primitives/datasets) with the required identifiers (typically hashed emails or phone numbers)
2. **Normalize with Rosetta Stone**: Map your data fields to [Rosetta Stone](/concepts/rosetta-stone/overview) attributes so the connector knows which fields contain deliverable identifiers
3. **Configure the connector**: Install the connector for your destination and provide the necessary credentials (API keys, account IDs, etc.)
4. **Deliver your audience**: Use NQL to select the records you want to deliver, or deliver entire datasets directly
## Identifier requirements
Each destination requires specific identifier types for matching. Connectors automatically transform your data to meet these requirements, but your source data must include at least one supported identifier type.
| Identifier Type | Common Destinations |
| ---------------------------- | ------------------------------------------ |
| SHA256 hashed email | Google DV360, Meta, The Trade Desk, TikTok |
| SHA256 hashed phone | Google DV360, Meta, TikTok |
| Mobile advertising ID (MAID) | The Trade Desk, DSPs |
| UID2 | The Trade Desk (auto-generated from email) |
If your data lacks these identifiers but you want to activate it as an audience, contact your Narrative account representative for assistance with identity resolution options.
For detailed requirements on preparing identifiers, see [Hashing PII for Upload](/guides/ingestion/hashing-pii).
***
## Available destinations
Narrative provides connectors to major advertising and data platforms:
Deliver audiences to TTD's first-party or third-party marketplace
Push custom audiences to Facebook and Instagram
Send conversion events for ad attribution
Activate audiences in Display & Video 360
Deliver data files to your S3 bucket
See [Connector Reference](/reference/connectors/index) for the complete list of available connectors.
***
## Activation patterns
There are several ways to structure your data for activation, depending on your use case:
### Single-purpose datasets
Create a separate dataset for each audience you want to activate. This approach is straightforward when you have distinct, pre-defined audiences.
**Best for**: Organizations that build and maintain audiences outside of Narrative, then upload them for delivery.
### Master dataset with audience segments
Maintain a single dataset containing all your audience data, with columns that identify which audience each record belongs to. Use NQL to filter and deliver specific segments.
**Best for**: Organizations managing many audiences that share the same underlying data structure.
### Raw data with dynamic segmentation
Upload your complete customer data with all available attributes, then use NQL to create audiences on-the-fly based on any combination of attributes.
**Best for**: Organizations that want maximum flexibility to create new audiences without re-uploading data.
For detailed guidance on implementing these patterns, see [Structuring Audiences for Activation](/guides/activation/audience-strategies).
***
## What happens after delivery
When you deliver data through a connector:
1. **The connector processes your data**: Identifiers are formatted and, if needed, translated (e.g., hashed email to UID2)
2. **Data is sent to the destination**: The connector uses the platform's API to create or update audiences
3. **The platform matches identifiers**: The destination matches your identifiers against its user base. Match rates vary by platform and identifier type (typically 30-70%)
4. **Audiences become targetable**: Once processed, your audiences are available for targeting in campaigns on that platform
Match rates depend on the destination platform's user base and your data quality. Not every identifier will match. Monitor your connector delivery reports to understand match rates for your audiences.
***
## Related content
Build, filter, and deliver audiences using a guided visual builder
Strategies for organizing data for activation
Connect offline data to online identifiers
Prepare identifiers for upload and activation
Technical details for each connector
How invite links onboard third parties to connect accounts
# Data Collaboration Platforms
Source: https://docs.narrative.io/concepts/data-collaboration-platforms
How the convergence of big data infrastructure, specialized talent, and data scarcity led to a new category of software for acquiring and sharing data
A Data Collaboration Platform is software that enables organizations to discover, acquire, and share data with external partners. The category emerged in the mid-2010s as a response to a specific problem: organizations had invested heavily in data infrastructure and analytics talent, but lacked access to the external data they needed to realize the full value of those investments.
## The infrastructure foundation
The 2000s saw a transformation in how organizations store and process data. Open-source projects like Hadoop, Spark, and Kafka—many originating from Silicon Valley companies solving their own scaling challenges—made it possible to work with datasets that would have overwhelmed traditional databases.
Initially, only large enterprises could deploy and operate this infrastructure. The expertise required was scarce and expensive. But cloud platforms changed this dynamic. Services like Amazon Web Services, Google Cloud Platform, and Microsoft Azure abstracted away the operational complexity, allowing mid-sized companies to access the same capabilities without building data centers or hiring specialized operations teams.
By the early 2010s, the infrastructure barrier had largely fallen. Organizations of many sizes could store petabytes of data and run complex analytical workloads. The question shifted from "can we handle this much data?" to "what can we do with it?"
## The talent evolution
Powerful infrastructure created demand for people who could use it. Traditional database administrators understood relational databases and SQL, but the new paradigm required different skills: distributed systems, statistical modeling, and programming in languages like Python and R.
Organizations began hiring data scientists, data engineers, and analytics professionals. These roles brought capabilities that hadn't existed in most companies: predictive modeling, machine learning, and the ability to extract insights from unstructured data. The job title "data scientist" barely existed before 2010; by the mid-2010s, it was one of the most sought-after positions in technology.
This talent could do things with data that simply weren't possible before—identifying patterns in customer behavior, predicting equipment failures, optimizing supply chains in real time. But their effectiveness depended entirely on having access to the right data.
## The data acquisition gap
Infrastructure and talent, it turned out, were necessary but not sufficient. Organizations discovered that their internal data—however large—rarely contained everything they needed for sophisticated analytics.
A retailer might have detailed transaction data but lack information about customer behavior outside their stores. A financial services company might have comprehensive records of their own customers but no visibility into market-wide trends. A healthcare organization might have clinical data but miss the social determinants that influence patient outcomes.
The need for external data wasn't new. What changed was the scale and complexity of what organizations wanted to do with it. Machine learning models require training data from multiple sources. Predictive analytics work better with more signals. The same infrastructure and talent investments that enabled sophisticated analytics also created demand for data that no single organization could generate internally.
Acquiring external data, however, was difficult. The process typically involved:
* **Discovery** — Finding organizations that had relevant data, often through personal networks or industry connections
* **Negotiation** — Working through legal and commercial terms, frequently taking months
* **Technical integration** — Building custom pipelines to ingest data in whatever format the provider used
* **Ongoing maintenance** — Managing schema changes, delivery schedules, and data quality issues
Each data partnership was a bespoke project. Organizations that needed data from multiple sources faced the same integration challenges repeatedly.
## How Data Collaboration Platforms address this
Data Collaboration Platforms emerged to systematize what had been ad-hoc. Rather than negotiating individual data partnerships, organizations can discover and acquire data through a common interface.
The core capabilities of a Data Collaboration Platform typically include:
**Centralized discovery.** A catalog of available datasets that organizations can browse and evaluate, replacing informal networks and one-off outreach.
**Standardized formats.** Common schemas and data formats that reduce the integration burden. Instead of building custom pipelines for each data source, organizations work with consistent structures.
**Programmatic access.** APIs and query interfaces that allow data acquisition to be automated and integrated into existing workflows, rather than relying on manual file transfers.
**Access control and governance.** Mechanisms for data providers to control how their data is used, enabling collaboration that wouldn't happen if providers had to trust recipients with unrestricted access.
**Privacy-preserving techniques.** Capabilities like [data pseudonymization](/concepts/security/data-pseudonymization) that allow organizations to collaborate on sensitive data while protecting individual privacy.
The result is that acquiring external data looks less like a series of custom projects and more like querying a database. Organizations can move from identifying a data need to having that data in their analytics pipeline in days rather than months.
***
## Related content
How Narrative normalizes schemas across different data sources and formats
How Narrative orchestrates data discovery, access control, and collaboration
# IP Addresses
Source: https://docs.narrative.io/concepts/data-formats/ip-addresses
Understanding IP address formats, types, and their role in data collaboration
IP addresses are unique identifiers for devices communicating over networks. Standing for Internet Protocol, they adhere to standardized formats that enable data routing across the internet. In data collaboration, IP addresses provide signals for geographic targeting, fraud detection, and network-level analytics.
## IP address formats
### IPv4
The most common format, IPv4 addresses consist of four octets (8-bit numbers) separated by periods. Each octet ranges from 0 to 255.
```
192.168.1.1
74.125.224.72
```
**Characteristics:**
* 32-bit address space
* \~4.3 billion possible addresses
* Running out of available addresses globally
### IPv6
Developed to expand the address pool, IPv6 uses a 128-bit address format with eight groups of four hexadecimal digits separated by colons.
```
2001:0db8:85a3:0000:0000:8a2e:0370:7334
```
**Characteristics:**
* 128-bit address space
* \~340 undecillion possible addresses
* Growing adoption but not yet universal
IPv6 addresses can be abbreviated by omitting leading zeros and collapsing consecutive zero groups with `::`. The address above could be written as `2001:db8:85a3::8a2e:370:7334`.
***
## Types of IP addresses
### Public vs. private
| Type | Description | Example Ranges |
| ----------- | ---------------------------------------------------- | ------------------------------------------ |
| **Public** | Globally routable, assigned by ISPs | Most addresses outside reserved ranges |
| **Private** | Used within local networks, not routable on internet | `10.x.x.x`, `172.16-31.x.x`, `192.168.x.x` |
Public IP addresses identify devices on the open internet and are the primary type used in data collaboration. Private addresses are used within homes and organizations and are not visible externally.
### Static vs. dynamic
| Type | Description | Use Cases |
| ----------- | -------------------------------------- | ------------------------------- |
| **Static** | Remains constant over time | Servers, business connections |
| **Dynamic** | Changes periodically, assigned by DHCP | Residential connections, mobile |
Most consumer internet connections use dynamic IPs, which can change daily or when the router restarts. This affects data collaboration because the same IP address might represent different users over time.
### Dedicated vs. shared
| Type | Description | Context |
| ------------- | --------------------------------- | ---------------------------------------------- |
| **Dedicated** | Single device or organization | Direct server access |
| **Shared** | Multiple users behind the same IP | NAT, VPNs, corporate networks, mobile carriers |
Carrier-grade NAT (CGNAT) and VPNs mean that many users may share the same public IP address, making IP-based identification less precise.
***
## IP addresses in data collaboration
### Geographic signals
IP addresses can be mapped to approximate geographic locations:
* **Country:** High accuracy
* **Region/State:** Generally reliable
* **City:** Moderate accuracy
* **Postal code:** Less reliable
* **Street level:** Not reliable
IP geolocation is useful for:
* Regulatory compliance (geo-restrictions)
* Regional targeting
* Fraud detection (location mismatches)
IP geolocation is approximate and can be inaccurate, especially for mobile users and VPN users. Never rely on IP location for critical decisions requiring precise location.
### Fraud and security signals
IP addresses provide useful signals for fraud detection:
* Known proxy/VPN IP ranges
* Data center vs. residential IPs
* Bot traffic identification
* Rate limiting and abuse detection
### Privacy considerations
IP addresses are considered personal data under GDPR and may be subject to other privacy regulations:
* They can identify households or organizations
* Combined with other data, they may identify individuals
* Collection requires appropriate legal basis
***
## Working with IP addresses
### Normalization
When matching IP data across sources:
* Validate format (proper IPv4 or IPv6 structure)
* Handle leading zeros consistently
* Consider canonicalizing IPv6 addresses
### Storage
| Format | Storage Size | Notes |
| -------------- | ------------ | ------------------------ |
| String | Variable | Human-readable, flexible |
| Integer (IPv4) | 4 bytes | Efficient for IPv4 only |
| Binary (IPv6) | 16 bytes | Supports both versions |
### Matching limitations
IP addresses are session-level identifiers, not user-level:
* Dynamic IPs change over time
* Shared IPs represent multiple users
* Mobile IPs change as users move between networks
For user-level matching, IP addresses are typically combined with other signals or used as one factor in probabilistic matching.
***
## Related content
Geographic data formats and polygons
Overview of identifier categories
Privacy protection for identifiers
# Location Data
Source: https://docs.narrative.io/concepts/data-formats/location-data
Understanding location polygons, geospatial formats, and how to work with geographic data
Location data adds geographic context to records, enabling use cases like proximity targeting, foot traffic analysis, and location-based audience building. At its core, location data maps coordinates to meaningful places—transforming raw latitude/longitude pairs into actionable insights.
## Why location context matters
Raw location data typically consists of latitude, longitude, and timestamp—coordinates that have limited meaning in isolation. The latitude `40.74576` and longitude `-73.9848` tells you little on its own, but when mapped to "Narrative Headquarters in New York City," the data becomes actionable.
Location context enables:
* **Proximity targeting:** Reach users near specific locations
* **Foot traffic analysis:** Understand visitation patterns
* **Competitive intelligence:** Analyze visits to competitor locations
* **Attribution:** Connect ad exposure to physical store visits
***
## Location polygons
A location polygon is a shape that represents a place, point of interest, or geographic boundary using geospatial coordinates. Polygons are defined by the coordinates of their vertices (corner points), connected by edges.
### What polygons represent
Polygons can represent locations at any scale:
* Individual buildings or storefronts
* City blocks or neighborhoods
* Entire cities or regions
* Country borders
### Coordinate systems
Most location data uses the **WGS84** (World Geodetic System 1984) coordinate reference system, which represents locations as latitude/longitude pairs:
* **Latitude:** -90 to 90 (north/south of equator)
* **Longitude:** -180 to 180 (east/west of prime meridian)
Coordinate order varies by format. GeoJSON uses longitude-first (`[lon, lat]`), while many other systems use latitude-first (`lat, lon`). Always verify the expected order when working with location data.
***
## Location data formats
### GeoJSON
[GeoJSON](https://en.wikipedia.org/wiki/GeoJSON) is a JSON-based standard for encoding geographic data. It's human-readable, easy to parse in most programming languages, and widely supported.
```json theme={null}
{
"type": "Polygon",
"coordinates": [[
[-122.389946, 37.7786],
[-122.389095, 37.7786],
[-122.389095, 37.7793],
[-122.389946, 37.7793],
[-122.389946, 37.7786]
]]
}
```
**Narrative accepts:**
* `.json`, `.geojson`, and `.parquet` files
* `Polygon` and `MultiPolygon` geometries
* Does **not** accept GeoJSON embedded in CSV
Validate your GeoJSON using [GeoJSONLint](https://geojsonlint.com/) before upload.
### Well-Known Text (WKT)
[WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) is a text-based format for representing geometry. It's more compact than GeoJSON but less human-readable.
```
POLYGON((-122.389946 37.7786, -122.389095 37.7786, -122.389095 37.7793, -122.389946 37.7793, -122.389946 37.7786))
```
**Narrative accepts:**
* WKT in CSV format
* Does **not** accept WKT in JSON format
### Shapefile
[Shapefiles](https://en.wikipedia.org/wiki/Shapefile) are a common GIS format, but they require specialized libraries to read and write. They consist of multiple files (`.shp`, `.shx`, `.dbf`) that must be kept together.
### Point-radius
For locations where exact boundaries aren't known, point-radius defines a center point and a radius (typically in meters). While not technically a polygon, many systems convert point-radius to polygons by calculating vertices around the circle.
```
-122.389521, 37.7789, 100
```
(longitude, latitude, radius in meters)
**Narrative accepts:**
* Point-radius in `.txt`, `.json`, `.parquet`, and `.csv` formats
***
## Supported file formats
| Format | File Types | Notes |
| ---------------- | ----------------------------------- | -------------------------------- |
| **GeoJSON** | `.json`, `.geojson`, `.parquet` | Polygon and MultiPolygon only |
| **WKT** | `.csv` | WKT column with polygon geometry |
| **Point-radius** | `.txt`, `.json`, `.parquet`, `.csv` | lon, lat, radius |
***
## Coordinate order
A common source of errors is mixing up longitude and latitude order. Different formats use different conventions:
| Format | Order | Example |
| ----------- | ----------------------- | ------------------------ |
| GeoJSON | `[longitude, latitude]` | `[-122.389, 37.778]` |
| WKT | `longitude latitude` | `POINT(-122.389 37.778)` |
| Google Maps | `latitude, longitude` | `37.778, -122.389` |
For a comprehensive reference, see [lonlat.info](https://macwright.com/lonlat/).
***
## Creating location data
If you need to define custom polygons:
1. **Drawing tools:** Use [Bounding Box Tool](https://boundingbox.klokantech.com/) to draw shapes and export in various formats
2. **GIS software:** Tools like QGIS (free) can create and edit complex geometries
3. **Existing datasets:** Many POI datasets are available commercially or through open sources
***
## Related content
Network-based location signals
Timestamp formats for location data
# Unix Time
Source: https://docs.narrative.io/concepts/data-formats/unix-time
Understanding Unix timestamps and how Narrative uses them for data collaboration
Unix time represents timestamps as the number of seconds elapsed since January 1st, 1970 at 00:00:00 UTC, known as the **Unix epoch**. This method simplifies parsing and utilizing time across various systems by representing it as a single integer.
Narrative's Data Streaming Platform uses Unix time in **milliseconds** for all timestamp fields, providing millisecond-level precision for data collaboration.
## Unix Epoch
The Unix epoch—January 1st, 1970 at 00:00:00 UTC—was chosen by early Unix engineers as a convenient and uniform starting point for representing time. All Unix timestamps are calculated relative to this moment.
## Examples
| Date / Time | Unix Time (seconds) |
| ---------------------------- | ------------------- |
| January 1st, 1970 00:00:00 | 0 |
| June 6th, 1983 00:00:00 | 423705600 |
| June 6th, 1983 16:00:00 | 423763200 |
| September 9th, 2001 01:46:40 | 1000000000 |
| July 20th, 1969 20:17:40 | -14182940 |
Dates before 1970 are represented as negative numbers, counting seconds backward to the Unix epoch.
## Limitations
### Year 2038 Problem
Systems that store Unix time as a signed 32-bit integer will encounter errors after **03:14:07 UTC on January 19, 2038**, when timestamps exceed the maximum 32-bit value. This is similar to the Y2K issue. Modern systems using 64-bit integers are not affected.
### Leap Seconds
Unix time assumes each day has exactly 86,400 seconds and does not account for leap seconds. This means Unix time can drift slightly from true UTC time, though the difference is negligible for most applications.
## Variations
### Unix Time with Decimals
For sub-second precision, Unix time can include decimal places. For example, `1609459200.5` represents a moment 500 milliseconds after midnight on January 1st, 2021.
### Unix Time in Milliseconds
Narrative uses this format, representing time as the number of **milliseconds** since the Unix epoch. This provides greater precision for timestamp data—multiply standard Unix time by 1,000 to convert.
For example:
* Unix time (seconds): `1609459200`
* Unix time (milliseconds): `1609459200000`
## Additional Resources
* [Wikipedia: Year 2038 Problem](https://en.wikipedia.org/wiki/Year_2038_problem)
* [Wikipedia: Unix time](https://en.wikipedia.org/wiki/Unix_time)
* [Epoch Converter](https://www.epochconverter.com/) for practical conversions between Unix time and standard dates
# Addressability Expansion
Source: https://docs.narrative.io/concepts/identifiers/addressability
Improving media activation reach by appending additional identifiers to existing identity graph records
Addressability expansion appends additional identifiers to existing records in your identity graph to improve match rates in downstream activation platforms. Unlike [graph enrichment](/concepts/identifiers/graph-enrichment), which changes the graph's structure by adding edges between nodes, addressability expansion adds more "handles" to existing nodes—giving activation platforms more ways to find your customers.
## What addressability expansion is
Every activation platform has its own identifier ecosystem. The Trade Desk works with UID2 and MAIDs. Meta matches on hashed emails and phone numbers. CTV platforms use device-level identifiers. If your CRM only contains email addresses, your match rate on a MAID-based platform will be low—not because your targeting is wrong, but because you lack the right identifiers to reach your audience there.
Addressability expansion solves this by appending additional identifier types to your existing records. A customer who was previously reachable only via email becomes reachable via email, MAID, hashed phone, and postal address.
## How it works
Consider a direct-to-consumer brand with a CRM containing one email per customer. Their current match rates:
| Platform | Identifier accepted | Match rate |
| -------------- | ------------------- | ---------- |
| Meta | Hashed email | 62% |
| The Trade Desk | UID2, MAID | 15% |
| CTV platforms | MAID, IP address | 8% |
After addressability expansion with a provider that appends MAIDs, hashed phones, and additional email addresses:
| Platform | Identifiers available | Match rate |
| -------------- | --------------------------- | ---------- |
| Meta | Hashed email + hashed phone | 78% |
| The Trade Desk | UID2 + MAID | 51% |
| CTV platforms | MAID + IP address | 34% |
The brand's audience segments did not change. The same customers are targeted with the same logic. But those customers are now reachable on more platforms.
## What it does NOT do
Addressability expansion has clear boundaries:
* **Does not change segmentation.** Your audience definitions stay the same. If a customer was in a segment before expansion, they remain in it. If they were not, expansion does not add them.
* **Does not improve targeting quality.** You are reaching the same people with the same intent. The improvement is in reach, not precision.
* **Does not resolve identity.** Expansion does not merge separate profiles or discover that two records are the same person. That is [graph enrichment](/concepts/identifiers/graph-enrichment).
The value of addressability expansion is straightforward: higher match rates on activation platforms, which means more of your intended audience actually sees your campaigns.
## What to look for in providers
When evaluating addressability expansion providers, focus on activation outcomes:
| Criterion | What to measure | Why it matters |
| ------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------- |
| **Identifier coverage by type** | Volume of MAIDs, hashed emails, hashed phones, etc. | Determines which platforms benefit from expansion |
| **Match rate lift by platform** | Before/after match rates per destination | The direct measure of value |
| **Freshness and churn rate** | How recently identifiers were observed, how quickly they go stale | Stale identifiers waste spend on unreachable users |
| **Identity type breadth** | Person-level vs. household-level identifiers | Different activation strategies require different granularity |
| **Cost per incremental match** | Total cost divided by net new matched users | The efficiency metric for comparing providers |
## Addressability expansion in Narrative
Narrative's platform supports addressability expansion through Rosetta Stone matching and connector-based activation.
### Appending identifiers via Rosetta Stone
Use NQL to join your first-party data against identity providers and append additional identifier types:
```sql theme={null}
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."value" AS seed_id,
seed."_rosetta_stone"."unique_identifier"."type" AS seed_id_type,
provider."_rosetta_stone"."unique_identifier"."value" AS appended_id,
provider."_rosetta_stone"."unique_identifier"."type" AS appended_id_type
FROM company_data."crm_seed" seed
INNER JOIN external_provider."identity_data" provider
ON seed."_rosetta_stone"."unique_identifier"."value" = provider."_rosetta_stone"."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = provider."_rosetta_stone"."unique_identifier"."type"
WHERE provider."_rosetta_stone"."unique_identifier"."type" IN ('maid', 'sha256_phone')
```
### Measuring match rate lift
After expanding your identifiers, use [connectors](/reference/connectors/index) to activate audiences on destination platforms. Compare match rates before and after expansion to quantify the lift each provider delivers.
***
## Related content
The foundational concepts behind identity graph structure
The complementary approach: improving graph structure and resolution
Delivering audiences to destination platforms
Translating offline data for digital activation
# Data Onboarding
Source: https://docs.narrative.io/concepts/identifiers/data-onboarding
Translating offline customer data for digital activation
Data onboarding is the process of taking offline customer data and connecting it to online identifiers for digital activation. It bridges the gap between CRM systems, point-of-sale data, and loyalty programs with the digital advertising ecosystem.
## Why data onboarding matters
Many companies have rich customer data from offline interactions:
* Purchase history from retail stores
* Loyalty program memberships
* Call center interactions
* Direct mail campaigns
But digital advertising platforms work with online identifiers—cookies, mobile ad IDs, and universal IDs. Without onboarding, this valuable first-party data remains unusable for digital marketing.
Data onboarding unlocks:
* **Digital reach for offline customers:** Find your store customers online
* **Personalization at scale:** Apply offline insights to digital experiences
* **Closed-loop measurement:** Connect digital campaigns to offline conversions
***
## How data onboarding works
### 1. Prepare and anonymize offline data
Data onboarding starts with your offline customer file—typically including emails, phone numbers, and customer attributes.
Before any matching occurs, personally identifiable information (PII) must be pseudonymized:
* Hash email addresses and phone numbers
* Remove or mask direct identifiers (names, addresses)
* Retain only the attributes needed for activation
Narrative requires PII to be hashed before upload. See [Hashing PII for Upload](/guides/ingestion/hashing-pii) for formatting requirements.
### 2. Match to online identifiers
Pseudonymized customer data is matched to online identifiers through identity resolution:
| Offline Identifier | Matched To |
| ------------------ | ----------------------------- |
| Hashed email | Mobile ad IDs, cookies, UID2 |
| Hashed phone | Mobile ad IDs, UID2 |
| Postal address | Device graphs (probabilistic) |
Matching can be:
* **Deterministic:** Direct link observed (e.g., user logged in with email on mobile app)
* **Probabilistic:** Inferred from signals (e.g., shared household IP address)
### 3. Activate in digital channels
Once matched, customer segments can be activated across digital platforms:
* Programmatic display and video
* Social media advertising
* Connected TV
* Retail media networks
***
## Benefits of data onboarding
### Boost media reach
By appending online identifiers to your customer file, you gain additional touchpoints to reach customers across devices and channels—not just where they gave you their email.
### Improve personalization
Bring offline insights online. A customer who purchased in-store can receive relevant digital ads, while someone who returned a product can be excluded from promotions.
### Enable omni-channel measurement
Combining offline and online data provides a complete view of customer journeys. Measure how digital campaigns drive store visits, or how in-store experiences influence online behavior.
### Power advanced analytics
Onboarded data enables analysis across the full customer journey. Understand how offline and online touchpoints interact and influence each other, rather than analyzing channels in silos.
***
## Challenges in data onboarding
### Privacy compliance
Offline customer records often contain PII that's regulated under GDPR, CCPA, and other privacy laws. Mitigate risk by:
* Hashing all identifiers before sharing
* Maintaining appropriate consent records
* Working with compliant onboarding partners
### Match rates
Not all offline customers will match to online identifiers. Typical match rates range from 30-70% depending on:
* Data quality and recency
* Identifier types available
* Target platform coverage
### Data freshness
Customer data changes over time. Email addresses become invalid, people move, devices are replaced. Regular refresh of onboarded data maintains accuracy.
### Cost and complexity
Traditional onboarding services can be expensive and require long-term commitments. They may also charge for records that don't match, making ROI difficult to predict.
***
## Data onboarding in Narrative
Narrative simplifies data onboarding with transparent matching and flexible activation:
### Self-service upload
Upload your hashed customer file directly to the platform without intermediaries.
### Transparent matching
See which identity providers match your data and at what rates. Choose which matches to activate based on quality and coverage.
### Flexible activation
Activate matched audiences to multiple destinations:
* The Trade Desk (with automatic UID2 generation)
* Other DSPs and ad platforms
* Analytics environments
### Pay for matches
Only pay for records that successfully match to online identifiers.
***
## Related content
How identifiers are connected across channels
Types of identifiers used in onboarding
Privacy-preserving identifier for activation
Hashing and privacy protection
Prepare data for onboarding
# Graph Enrichment
Source: https://docs.narrative.io/concepts/identifiers/graph-enrichment
Strengthening identity graph structure with third-party linkage data for improved person-level and household-level resolution
Graph enrichment uses third-party identity data to create or strengthen connections between nodes in your identity graph. Unlike [addressability expansion](/concepts/identifiers/addressability), which appends identifiers to existing records, graph enrichment changes the graph's structure itself—adding edges that connect previously isolated nodes into unified profiles.
## What graph enrichment is
Your first-party identity graph reflects the linkages you can directly observe: login events, CRM matches, transaction data. But your observation is limited to your own touchpoints. Graph enrichment fills the gaps by incorporating linkages observed by third-party providers across their own data ecosystems.
The result is a more connected graph with fewer fragmented profiles and better person-level or household-level resolution.
## How it works
Consider a retail company with this first-party data:
* Customer A: hashed email + iPhone IDFA (observed via app login)
* Customer A: hashed email + home address (observed via shipping)
Their first-party graph has a connected component with three nodes: email, IDFA, and address.
A third-party identity provider observes that the same email is also linked to an Android GAID and a second hashed email (a work address). After graph enrichment:
* Customer A's component now includes five nodes: personal email, work email, IDFA, GAID, and home address
* The retailer can now reach Customer A on Android devices and recognize them when they use their work email
The graph's structure changed. New edges were added, and the connected component expanded.
## Effects on the graph
Graph enrichment can improve your identity graph in several ways:
**Person-level resolution.** Connecting identifiers that belong to the same individual across devices and channels. A customer who appears as three separate profiles (email-only, MAID-only, cookie-only) becomes one resolved profile.
**Household-level resolution.** Linking identifiers across individuals within a household. Shared addresses, shared IP ranges, and shared device usage can connect household members when that level of resolution is appropriate for your use case.
**Waterfall matching flexibility.** A richer graph gives you more identifiers per person, which means more opportunities to match against any given activation platform. If one identifier type fails to match, another may succeed.
**Longitudinal consistency.** When identifiers change over time (cookies expire, MAIDs reset), a well-enriched graph maintains continuity through other persistent linkages.
## What to look for in providers
When evaluating graph enrichment providers, focus on these criteria:
| Criterion | What to measure | Why it matters |
| --------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- |
| **Linkage depth** | Average edges per node | More edges per node means richer resolution |
| **Determinism rate** | Percentage of deterministic vs. probabilistic linkages | Deterministic linkages are more reliable |
| **Confidence score quality** | Distribution and calibration of confidence scores | Well-calibrated scores enable meaningful thresholds |
| **Update cadence** | How frequently linkages are refreshed | Stale linkages degrade graph quality over time |
| **Incremental component overlap** | How many of your existing components gain new edges | Measures actual incremental value vs. redundant coverage |
## Risks
Graph enrichment carries higher stakes than addressability expansion. A bad identifier append reduces match rates—an inconvenience. A bad linkage merges two distinct people into a single profile—a structural corruption that propagates through every downstream use case.
Protect against this by:
* **Setting confidence thresholds.** Only incorporate linkages above a minimum confidence score. Start conservative and relax over time as you validate quality.
* **Monitoring component size distribution.** A sudden increase in very large components may indicate over-linking. Healthy graphs follow a predictable size distribution.
* **Evaluating incrementally.** Test a provider's linkages against a subset of your graph before full integration. Measure resolution improvement against a known truth set.
***
## Graph enrichment in Narrative
Narrative's platform supports graph enrichment evaluation and execution through Rosetta Stone and NQL.
### Evaluating provider linkage coverage
Use NQL to measure how much incremental coverage a provider adds to your existing graph:
```sql theme={null}
SELECT
provider."_rosetta_stone"."unique_identifier"."type" AS id_type,
COUNT(DISTINCT provider."_rosetta_stone"."unique_identifier"."value") AS provider_ids,
COUNT(DISTINCT CASE
WHEN seed."_rosetta_stone"."unique_identifier"."value" IS NOT NULL
THEN provider."_rosetta_stone"."unique_identifier"."value"
END) AS overlapping_ids
FROM external_provider."identity_linkages" provider
LEFT JOIN company_data."first_party_graph" seed
ON provider."_rosetta_stone"."unique_identifier"."value" = seed."_rosetta_stone"."unique_identifier"."value"
AND provider."_rosetta_stone"."unique_identifier"."type" = seed."_rosetta_stone"."unique_identifier"."type"
GROUP BY provider."_rosetta_stone"."unique_identifier"."type"
```
### Measuring resolution improvement
Compare connected component metrics before and after incorporating a provider's linkages to quantify the resolution improvement they deliver.
***
## Related content
The foundational concepts behind identity graph structure
The complementary approach: improving activation reach without changing graph structure
How individual identifier linkages work
Translating offline data for digital activation
# Graph Studio
Source: https://docs.narrative.io/concepts/identifiers/graph-studio
Build identity graphs from your first-party and third-party data using a guided, visual builder
Graph Studio is the platform's identity graph building tool. It provides two builders — **Edge Builder** and **Graph Builder** — that work together to transform your raw data into a connected identity graph.
## How it works
Building an identity graph is a two-step process:
1. **Define edges** — Tell the platform which identifiers in your data should be used to connect records. Two records that share the same identifier value (like the same email address) are linked together.
2. **Build the graph** — The platform runs a connected components algorithm over your edges to discover which records belong to the same person or household, resolving transitive connections across multiple hops.
## Edges
An edge connects a **source record** to a **target identifier**. The Edge Builder creates an edges dataset by combining your data sources with the identity attributes you choose as connection points.
### Key concepts
| Term | Description | Example |
| ------------------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------- |
| **Source ID type** | A label you choose to identify which system a record comes from | `CRM`, `Website`, `Partner` |
| **Source ID** | The field that uniquely identifies each record in that system | `CUSTOMER_ID` |
| **Target ID type** | The category of identifier used as a connection point — always a Rosetta Stone attribute | `normalized_email`, `clear_text_e164_phone_number` |
| **Target ID** | The actual identifier value for a given record, derived from the attribute mapping | `michaelscott@gmail.com`, `+15705551234` |
Each source record produces one edge per target ID group. When two records from any source share the same target ID value, the graph recognizes them as connected.
### Target ID groups
Target IDs are organized into **groups**. Each group defines one type of connection. A group can contain a single attribute or multiple attributes — when a group has multiple attributes, **all values in the group must match** for two records to be connected.
For example:
* A group with just **normalized email** connects any two records sharing the same email — high confidence, since email is typically unique to a person
* A group with **phone number + first name** requires both values to match, which is more precise than phone alone — useful when a phone number might be shared across a household
You can define multiple target ID groups to give the graph different ways to find connections. The algorithm considers all groups when resolving identities.
### Data sources
The Edge Builder accepts two types of sources:
* **Datasets** (first-party) — Your own data, [mapped to Rosetta Stone attributes](/guides/rosetta-stone/mapping-schemas)
* **Access rules** (third-party) — Data shared with you by other companies. Third-party sources introduce connections that your first-party data cannot see on its own.
## Graph
The Graph Builder takes one or more edges datasets and runs a **Label Connected Components** algorithm. It follows connections between records — including transitive chains — and groups every connected record into a single identity.
### Algorithm parameters
| Parameter | Default | Description |
| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------ |
| **Max Component Size** | 100 | Caps how many records can merge into one identity. Prevents over-connection. |
| **Max Iterations** | 10 | How many passes the algorithm makes to resolve transitive chains. |
| **Max Degree Threshold** | 100 | Excludes nodes with too many connections (e.g., shared corporate emails) to avoid merging unrelated records. |
The defaults work well for most use cases.
### Output
The graph produces a dataset where each record is assigned a **component ID** — all records with the same component ID belong to the same resolved identity. You can join this back to your original data for analytics, segmentation, and activation.
### Automation
You can run the graph build once or set a **refresh schedule** to keep it current as source data changes. You can also optionally **encode** identifiers in the output using your company's encryption material.
## Source eligibility
Not every dataset or access rule appears in the Graph Studio source pickers. Each builder only lists sources that have been **prepared for the step you're on** — the Edge Builder lists sources that are ready to become edges, and the Graph Builder lists the edges datasets that are ready to be resolved into a graph. If a source you expect is missing, it hasn't met the requirement for that list.
Under the hood, eligibility is driven by dataset and access-rule **tags** and Rosetta Stone **attribute mappings**:
| Builder | Source list | Requirement |
| ----------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Edge Builder** | First-party datasets | Dataset carries the `_nio_ci_components` tag |
| **Edge Builder** | Third-party access rules | Access rule carries the `_nio_ci_components` tag |
| **Graph Builder** | First-party input datasets | Dataset is mapped to the `graph_edge` Rosetta Stone attribute (this is what an edges dataset produced by the Edge Builder looks like) |
| **Graph Builder** | Third-party access rules | Access rule carries both the `_nio_ci_components` tag and a `_nio_ci_source_id_type:` tag (for example `_nio_ci_source_id_type:CUSTOMER_ID`) |
The `_nio_ci_*` tags are managed on the dataset or access rule itself. First-party data becomes edge-eligible when it's tagged `_nio_ci_components`; the Edge Builder's output edges datasets are mapped to `graph_edge`, which is what makes them appear in the Graph Builder. If a source is missing from a picker, check that it carries the tag or mapping listed above.
## Data plane support
Graph Studio runs on both **Snowflake** and **AWS** data planes. Both the Edge Builder and the Graph Builder are available in either environment — the Graph Builder uses the platform's [`LabelConnectedComponents` workflow task](/reference/workflows/tasks) for step 2, which is cross-platform.
## Related content
How connected components and graph structure unify identifiers
Step-by-step guide to creating your first graph
Strengthen graph structure with third-party linkage data
Map your data to Rosetta Stone attributes
# ID Mapping
Source: https://docs.narrative.io/concepts/identifiers/id-mapping
Connecting identifiers across devices, channels, and platforms
ID mapping connects different identifiers to the same user, enabling cross-device targeting, measurement, and analytics. When a data provider links a hashed email to a mobile advertising ID, or a cookie to a device identifier, they create an ID mapping that bridges previously siloed data.
## Why ID mapping matters
Modern consumers interact with brands across multiple touchpoints:
* Mobile apps (identified by MAID)
* Websites on desktop (identified by cookies)
* Email communications (identified by email address)
* Connected TV (identified by device graphs)
Without ID mapping, each touchpoint exists in isolation. A retailer might show the same ad to a customer on mobile, desktop, and CTV without recognizing it's the same person—wasting budget and creating a poor experience.
ID mapping solves this by creating links between identifiers, enabling:
* **Unified targeting:** Reach the same person across devices
* **Frequency capping:** Limit ad exposure across channels
* **Attribution:** Connect conversions to the right touchpoints
* **Audience expansion:** Find customers in new channels
***
## Common ID linkages
### Hashed Email to MAID
The most valuable and deterministic mapping connects hashed emails to mobile advertising IDs.
**How it works:** When a user logs into a mobile app with their email address, the app developer can link the MAID (from the device) to the hashed email (from the login). This creates a deterministic, high-confidence link.
**Use cases:**
* Target email subscribers in mobile apps
* Extend CRM audiences to mobile advertising
* Measure mobile conversions against email campaigns
### Cookie to MAID
Connects web browsing behavior to mobile device identity.
**How it works:**
* Cookie syncing through pixel fires on mobile web
* SDK-based matching when users interact on both web and app
* Probabilistic matching based on shared signals (IP, location, behavior)
**Use cases:**
* Retarget website visitors in mobile apps
* Build unified user profiles across web and app
* Measure cross-platform campaign performance
### Cookie to Hashed Email
Links browser-based identity to email-based identity.
**How it works:** When users log in on websites, the site can link their cookie to their hashed email. This creates a durable identifier that persists even when cookies are cleared.
**Use cases:**
* Build persistent identity across browser sessions
* Connect anonymous browsing to known customers
* Enable email-based audience activation
***
## ID mapping quality
Not all ID mappings are equal. Consider these quality factors:
### Deterministic vs. probabilistic
| Type | Description | Confidence |
| ----------------- | --------------------------------------------------------- | ---------- |
| **Deterministic** | Direct observation of link (e.g., same user logged in) | High |
| **Probabilistic** | Inferred from signals (e.g., shared IP, similar behavior) | Variable |
Deterministic mappings are more accurate but harder to scale. Probabilistic mappings offer broader reach but may include false matches.
### Recency
ID mappings can become stale:
* Users reset their MAIDs
* Cookies expire or are cleared
* Email addresses change
More recent mappings are generally more reliable.
### Source quality
The method of collection matters:
* **First-party login data:** Highest quality
* **SDK integrations:** High quality
* **Cookie syncing:** Medium quality
* **Probabilistic inference:** Variable quality
***
## ID mapping in Narrative
Narrative enables ID mapping through several mechanisms:
### Standardized identifiers
When ingesting data, Narrative normalizes identifier formats to enable matching across sources:
* Consistent casing for MAIDs
* Standardized hashing for emails and phones
* Narrative Cookie IDs for cross-supplier cookie matching
### Cross-supplier matching
Different data suppliers use different cookie namespaces. Narrative creates a standardized cookie ID that links to each supplier's cookies, enabling matching across suppliers.
### Query-time joining
Use NQL to join datasets on shared identifiers:
```sql theme={null}
SELECT
a.hashed_email,
b.maid
FROM company_a."email_data" a
JOIN company_b."maid_data" b
ON a.narrative_id = b.narrative_id
```
***
## Privacy considerations
ID mapping must respect privacy regulations and user expectations:
* **Consent:** Ensure mappings are created with appropriate user consent
* **Pseudonymization:** Always hash PII before creating mappings
* **Data minimization:** Only create mappings needed for specific use cases
* **User controls:** Honor opt-outs across all linked identifiers
***
## Related content
Categories of identifiers used in mapping
Connect offline data to online identifiers
Privacy-preserving universal identifier
Secure cross-partner matching
# Identifier Types
Source: https://docs.narrative.io/concepts/identifiers/id-types
Understanding the categories of identifiers used in data collaboration
Most data records in Narrative's platform have one or more identifiers as attributes. Identifiers act as unique representations of underlying concepts—typically the device, browser, or user where data was collected. Since an identifier can represent different concepts, all identifiers include a type designation so you know what the identifier represents.
## Why identifier types matter
When you query data, you need to know what kind of identifier you're working with:
* A **cookie** represents a browser session on a specific domain
* A **mobile ad ID** represents a mobile device
* A **hashed email** represents a person across devices
Understanding identifier types helps you:
* Match data across the right dimensions
* Estimate reach and overlap correctly
* Apply appropriate privacy controls
***
## Identifier categories
### Mobile Ad IDs (MAIDs)
Mobile advertising identifiers are device-level identifiers provided by mobile operating systems. They're accessible through platform APIs and persist until the user resets them.
| Platform | ID Type | Format |
| -------------- | ------------------------- | ------------------------------------------------------ |
| Apple iOS | `idfa` | Uppercase UUID: `918F1D4F-D195-4A8B-AF47-44683FE11DB9` |
| Google Android | `adid` (also called GAID) | Lowercase UUID: `3f097372-f01e-4b64-984c-395ae5828ee6` |
Mobile ad ID availability has decreased significantly due to privacy changes. Apple's App Tracking Transparency (ATT) requires user opt-in, and many users decline.
**Related:** [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids) for a deeper dive into IDFA and GAID.
***
### Cookies
When data is collected in a web browser context (desktop or mobile web), the identifier is typically a cookie. Cookies are storage mechanisms tied to a specific domain—a cookie set by one website cannot be read by another.
Because cookies are domain-specific, Narrative doesn't have a single "cookie" identifier type. Instead, each data provider has their own cookie namespace. Cookie identifiers are useful for:
* Retargeting within a publisher's properties
* Frequency capping across a provider's inventory
* Cross-device matching when linked to other identifiers
Third-party cookies are being deprecated in major browsers. Chrome has announced plans to phase them out, and Safari and Firefox already block them by default.
***
### Hashed Emails
Email addresses are personally identifiable information (PII) and cannot be shared in raw form. To enable matching while preserving privacy, emails are hashed using one-way cryptographic functions before being shared.
| ID Type | Hash Algorithm | Output Length |
| -------------- | -------------- | ----------------- |
| `md5_email` | MD5 | 32 hex characters |
| `sha1_email` | SHA-1 | 40 hex characters |
| `sha256_email` | SHA-256 | 64 hex characters |
**Important formatting rules:**
1. Convert to lowercase before hashing
2. Remove leading/trailing whitespace
3. Apply the hash algorithm
```
Email: JohnDoe@Gmail.com
Formatted: johndoe@gmail.com
SHA-256: 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
```
Hashed emails are valuable because:
* They persist across devices (people use the same email everywhere)
* They're deterministic (same email always produces the same hash)
* They can't be reversed to reveal the original email
**Related:** [Data Pseudonymization](/concepts/security/data-pseudonymization) for more on hashing techniques.
***
### Hashed Phone Numbers
Similar to emails, phone numbers are hashed to enable matching while protecting PII.
| ID Type | Hash Algorithm | Format |
| -------------- | -------------- | ----------------------- |
| `md5_phone` | MD5 | E.164 format, then hash |
| `sha1_phone` | SHA-1 | E.164 format, then hash |
| `sha256_phone` | SHA-256 | E.164 format, then hash |
Phone numbers should be normalized to E.164 format before hashing:
* Include country code
* Remove spaces, dashes, parentheses
* Example: `+14155551234`
***
### Universal IDs
Emerging universal identifier systems aim to provide privacy-preserving, cross-platform identity without relying on cookies or device IDs.
| ID Type | Description |
| -------- | --------------------------------------------------------------- |
| `uid2` | Unified ID 2.0 - generated from hashed emails, supports opt-out |
| `rampid` | LiveRamp's identity resolution identifier |
| `id5` | ID5's probabilistic and deterministic identity solution |
**Related:** [UID2](/concepts/identifiers/uid2) for details on Unified ID 2.0.
***
## Choosing the right identifier type
| Use Case | Recommended Identifier Types |
| -------------------------- | --------------------------------- |
| Cross-device targeting | Hashed email, UID2 |
| Mobile app targeting | IDFA, GAID |
| Web retargeting | First-party cookies, hashed email |
| Offline-to-online matching | Hashed email, hashed phone |
| Privacy-first campaigns | UID2, first-party data |
***
## Related content
Connect identifiers across devices and channels
Deep dive into IDFA and GAID
How hashing protects identifiers
Step-by-step guide to formatting and hashing
# Identity Graphs
Source: https://docs.narrative.io/concepts/identifiers/identity-graph
How connected components and graph structure unify identifiers into person-level and household-level profiles
An identity graph is a data structure that connects identifiers—emails, device IDs, phone numbers, cookies—across devices, channels, and platforms into unified profiles. Each cluster of connected identifiers (a [connected component](/reference/glossary#connected-component)) represents an individual or household.
Organizations use identity graphs to move from fragmented, device-level data to a coherent view of their customers. The quality of that graph directly determines the quality of downstream analytics, segmentation, and activation.
## What is an identity graph
An identity graph consists of three elements:
* **Nodes** are individual identifiers: a hashed email, a mobile advertising ID (MAID), a cookie, a phone number, a postal address
* **Edges** are linkages between identifiers, representing evidence that two identifiers belong to the same person or household
* **Connected components** are clusters of nodes where every identifier is reachable from every other through some path of edges—each component represents a resolved identity
For example, a single customer might generate nodes for their work email, personal email, iPhone IDFA, Android GAID, and home address. As the graph discovers linkages between these identifiers, they merge into a single connected component.
## Why graph quality matters
Graph quality is a balance between two failure modes:
| Problem | Cause | Consequence |
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
| **Under-connected** | Too few or too conservative linkages | Fragmented user profiles, duplicated outreach, inflated audience counts |
| **Over-connected** | Too many or too liberal linkages | Merged distinct individuals, corrupted targeting, wasted spend on wrong audiences |
Under-connected graphs treat the same person as multiple people. Over-connected graphs treat multiple people as one. Both degrade every downstream use case—segmentation, frequency capping, attribution, and personalization.
## How the graph grows
Identity graphs typically build in layers:
1. **First-party data as foundation.** Login events, CRM records, and transaction data create high-confidence, deterministic linkages. A customer who logs into your app with their email on their iPhone creates a direct edge between that hashed email and that device's IDFA.
2. **Third-party data adding edges.** External identity providers contribute additional linkages that your first-party data cannot observe. A provider might link your customer's email to a second device ID or a postal address that you have never seen.
3. **Deterministic vs. probabilistic linkages.** Deterministic linkages come from direct observation (same user logged in on two devices). Probabilistic linkages are inferred from signals like shared IP addresses, co-location patterns, or behavioral similarity. Deterministic linkages are more reliable but harder to scale; probabilistic linkages offer broader reach but carry higher false-match risk.
## Two approaches to using third-party identity data
When organizations purchase third-party identity data, the data serves one of two distinct purposes:
| | Graph enrichment | Addressability expansion |
| ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------ |
| **Primary benefit** | Improved identity resolution | Improved media activation reach |
| **Effect on graph structure** | Adds or strengthens edges between nodes | Appends identifiers to existing nodes |
| **Impact on segmentation** | Can change segment composition | Does not change segment composition |
| **Impact on match rates** | Indirect (better resolution enables better matching) | Direct (more identifiers per record increases match rates) |
| **Risk profile** | Higher (bad linkages corrupt graph structure) | Lower (bad appends reduce match rates but don't corrupt structure) |
Understanding which approach you need—or whether you need both—is the first decision in any identity data strategy.
Strengthen your identity graph's structure with third-party linkage data
Improve downstream activation reach by appending additional identifiers
A structured framework for choosing and measuring identity data providers
***
## Identity graphs in Narrative
Narrative's platform connects to identity graph concepts in several ways:
### Graph Studio
[Graph Studio](/concepts/identifiers/graph-studio) is the platform's visual identity graph builder. It provides a guided workflow for defining edges between your data sources and running connected components algorithms to resolve identities. You can build graphs from first-party data alone, or combine first-party and third-party sources for stronger resolution. See the [building an identity graph](/guides/graph-studio/building-an-identity-graph) guide to get started.
### Rosetta Stone and unique identifiers
Rosetta Stone's `unique_identifier` attribute provides a standardized way to match identifiers across suppliers. When you join on `unique_identifier.value` and `unique_identifier.type`, Narrative handles format normalization—consistent MAID casing, standardized email hashing—so that identifiers from different sources match correctly.
### Cross-supplier identity resolution
Different data suppliers contribute different identity linkages. Narrative enables you to evaluate and combine these linkages through NQL queries, comparing coverage, overlap, and incremental contribution across providers before committing to a purchase.
### Narrative ID
[Narrative ID](/concepts/security/narrative-id) provides a privacy-preserving mechanism for cross-partner matching. It enables identity resolution across organizations without exposing raw identifiers, maintaining graph connectivity while protecting PII.
***
## Related content
How individual identifier linkages work
Categories of identifiers used in data collaboration
Translating offline data for digital activation
Delivering resolved audiences to destination platforms
# Mobile Advertising IDs
Source: https://docs.narrative.io/concepts/identifiers/mobile-ad-ids
Understanding IDFA, GAID, and how mobile device identifiers work
Mobile advertising identifiers (MAIDs) are unique pseudo-anonymous identifiers tied to mobile devices. Both iOS and Android provide APIs that allow developers to collect data linked to these identifiers, enabling advertising, analytics, and measurement without exposing personally identifiable information.
## How MAIDs work
Mobile operating systems generate a unique identifier for each device that app developers can access through platform APIs. This identifier:
* Is pseudo-anonymous (doesn't directly identify a person)
* Persists across app installs and sessions
* Can be reset or disabled by the user
* Is accessible to any app with appropriate permissions
MAIDs were designed as a privacy-conscious alternative to hardware identifiers like IMEI or MAC address, which cannot be reset and could be used for persistent tracking without user control.
***
## Platform implementations
### Apple iOS: IDFA
Apple's Identifier for Advertisers (IDFA) is available on iOS devices through the [ASIdentifierManager](https://developer.apple.com/documentation/adsupport/asidentifiermanager) API.
**Format:** Uppercase UUID with hyphens
```
918F1D4F-D195-4A8B-AF47-44683FE11DB9
```
**Key characteristics:**
* 32 hexadecimal characters separated by hyphens
* Uppercase by convention
* Same across all apps on the device (when available)
Since iOS 14.5, Apple's App Tracking Transparency (ATT) framework requires apps to request user permission before accessing the IDFA. Many users decline, significantly reducing IDFA availability.
### Google Android: GAID
Google's Advertising ID (GAID, also called Ad ID or AAID) is available on Android devices through [Google Play Services](https://support.google.com/googleplay/android-developer/answer/6048248).
**Format:** Lowercase UUID with hyphens
```
3f097372-f01e-4b64-984c-395ae5828ee6
```
**Key characteristics:**
* 32 hexadecimal characters separated by hyphens
* Lowercase by convention
* Requires Google Play Services (not available on some devices)
***
## Privacy considerations
MAIDs were designed with privacy in mind, providing several user controls:
### User reset
Both platforms allow users to reset their advertising identifier at any time, generating a new ID and breaking the connection to previously collected data.
### Opt-out mechanisms
Users can limit ad tracking or disable personalized advertising entirely:
* **iOS:** Settings > Privacy > Tracking > Allow Apps to Request to Track
* **Android:** Settings > Privacy > Ads > Delete advertising ID
When users opt out, the MAID may return as all zeros or be unavailable entirely.
### Declining availability
The mobile advertising ecosystem has shifted significantly:
* **Apple ATT:** Only \~25-30% of iOS users opt in to tracking
* **Android:** Google has announced plans to introduce similar privacy controls
This declining availability has driven adoption of alternative identifiers like [UID2](/concepts/identifiers/uid2) and increased focus on first-party data strategies.
***
## Using MAIDs in data collaboration
### Data collection context
MAIDs are typically collected in scenarios where apps have user permission:
* SDK integrations with user consent
* Real-time bidding environments where MAIDs are passed in bid requests
* Publisher-direct integrations
### Matching considerations
When matching data using MAIDs:
* **Normalize format:** Ensure consistent casing (uppercase for IDFA, lowercase for GAID)
* **Handle opt-outs:** Filter out all-zero identifiers
* **Consider recency:** Older MAIDs may have been reset
### Cross-device identity
MAIDs identify devices, not people. A person with multiple devices will have different MAIDs on each. To build cross-device identity, MAIDs are often linked to:
* Hashed emails (from app logins)
* Other device identifiers
* Universal IDs like UID2
**Related:** [ID Mapping](/concepts/identifiers/id-mapping) for connecting MAIDs to other identifier types.
***
## Related content
Overview of all identifier categories
Connect MAIDs to other identifiers
Privacy-preserving alternative identifier
How hashing protects identifiers
# Identifiers in Data Collaboration
Source: https://docs.narrative.io/concepts/identifiers/overview
Understanding how identifiers enable privacy-preserving data matching across organizations
Data collaboration depends on identifiers—the keys that connect records across different organizations and systems. When two companies want to find overlapping customers or match behavioral data to a customer file, identifiers make that connection possible.
But identifiers in data collaboration come with unique challenges: privacy regulations require protection of personal information, different organizations use different identifier systems, and the fragmented digital landscape means a single person might be represented by dozens of different identifiers across devices and channels.
## Why identifiers matter
Consider a retail company that wants to target its existing customers with digital advertising. They have email addresses from their CRM, but ad platforms work with mobile device IDs and cookies. Without identifier mapping, their customer data is siloed and unusable for digital activation.
Data collaboration platforms solve this by:
* **Pseudonymizing** identifiers (hashing emails) so they can be matched without exposing PII
* **Mapping** between identifier types (connecting hashed emails to mobile ad IDs)
* **Standardizing** identifier formats so data from different sources can be matched
## The identifier ecosystem
The digital advertising and data collaboration ecosystem uses several categories of identifiers:
| Category | Examples | Persistence | Privacy Status |
| ----------------- | ---------------------------------------- | --------------------- | ---------------- |
| **Device IDs** | IDFA (iOS), GAID (Android) | Resettable by user | Pseudo-anonymous |
| **Browser IDs** | Third-party cookies, first-party cookies | Session or persistent | Pseudo-anonymous |
| **Hashed PII** | MD5/SHA-256 hashed emails and phones | Permanent | Pseudonymized |
| **Universal IDs** | UID2, RampID, ID5 | Varies | Pseudonymized |
Each identifier type serves different use cases and has different trade-offs in terms of reach, accuracy, and privacy.
## Identifier challenges
### Cross-device identity
A single consumer might use a smartphone, tablet, laptop, and smart TV—each with different identifiers. Connecting these identifiers to understand that they represent the same person is a core challenge in data collaboration.
### Privacy and consent
Privacy regulations (GDPR, CCPA) and platform policies (Apple's ATT, Google's Privacy Sandbox) increasingly restrict how identifiers can be collected and used. Organizations must balance data utility with privacy compliance.
### Identifier deprecation
The digital ecosystem is in flux. Third-party cookies are being phased out, mobile ad ID availability is declining, and new privacy-preserving identifiers are emerging. Data strategies must adapt to this changing landscape.
***
## Related content
Categories of identifiers used in data collaboration
Understanding IDFA, GAID, and mobile device identifiers
Connecting identifiers across devices and channels
The Unified ID 2.0 privacy-preserving identifier
Translating offline data for digital activation
How connected components unify identifiers into person-level profiles
Strengthening graph structure with third-party linkage data
Improving activation reach by appending additional identifiers
How hashing protects identifiers
# UID2 (Unified ID 2.0)
Source: https://docs.narrative.io/concepts/identifiers/uid2
A privacy-preserving identifier for cross-platform data collaboration
Unified ID 2.0 (UID2) is an open-source identifier framework designed to enable privacy-conscious data collaboration in a post-cookie world. Developed by the Identity Consortium with The Trade Desk as a primary operator, UID2 provides a deterministic, encrypted identifier derived from email addresses or phone numbers.
## Why UID2 exists
The digital advertising ecosystem faces a identity crisis:
* Third-party cookies are being deprecated
* Mobile ad ID availability is declining
* Privacy regulations require user consent and control
UID2 addresses these challenges by providing:
* **Privacy by design:** Users can opt out at any time
* **Transparency:** Open-source framework with clear governance
* **Interoperability:** Works across platforms and partners
* **Determinism:** Same input always produces the same output
***
## How UID2 works
### Token generation
UID2 tokens are generated from personally identifiable information (PII) through a multi-step process:
1. **Input:** Email address or phone number
2. **Normalization:** Standardize format (lowercase, trim whitespace)
3. **Hashing:** Apply SHA-256 hash
4. **Encryption:** Encrypt with rotating keys
5. **Output:** UID2 token
The resulting token is:
* Deterministic (same email always produces the same UID2)
* Encrypted (raw hash is not exposed)
* Refreshable (tokens can be regenerated with new keys)
### Token types
| Token Type | Description | Use Case |
| -------------- | --------------------------------------- | -------------------------------- |
| **UID2 Token** | Encrypted, rotatable identifier | Real-time bidding, ad serving |
| **Raw UID2** | Underlying identifier before encryption | Secure storage, internal systems |
### Key rotation
UID2 tokens are encrypted with keys that rotate periodically. This means:
* Tokens eventually expire and must be refreshed
* Compromised tokens become invalid
* Users can effectively reset their identity by regenerating tokens
***
## Privacy features
### User opt-out
UID2 includes a centralized opt-out mechanism. When users opt out:
* Their UID2 is added to a global opt-out list
* All participants must check this list before using UID2s
* The opt-out persists across all participating companies
### Transparency
The UID2 framework is open-source and governed by an independent consortium, providing:
* Clear documentation of how identifiers are generated
* Public audit of participating companies
* User-facing explanations of how data is used
### No cross-party tracking without consent
UID2 is generated from first-party data (emails collected with consent). It cannot be used to track users who haven't shared their information with a participating company.
***
## UID2 in Narrative
Narrative supports UID2 through automatic generation and native integration with The Trade Desk.
### Automatic generation
When you upload data with supported identifier types, Narrative can automatically generate UID2 tokens:
**Supported input attributes:**
* Hashed Email (any hash algorithm)
* SHA-256 Hashed Email
* Phone Number
* SHA-256 Hashed Phone Number
### Trade Desk integration
The Narrative TTD Connector enables seamless UID2 workflows:
1. Upload data with email or phone identifiers
2. Narrative generates UID2 tokens automatically
3. Activate audiences directly in The Trade Desk
### Existing UID2s
If your data already contains UID2 tokens (from advertising partners or other sources), Narrative's TTD Connector processes them directly, automatically mapping the UID2 field.
***
## UID2 vs. other identifiers
| Aspect | UID2 | Hashed Email | Cookies | MAIDs |
| -------------------- | ------------------- | ------------- | ---------------- | ---------------- |
| **Persistence** | Until opt-out | Permanent | Session/limited | Until reset |
| **Cross-device** | Yes | Yes | No | No |
| **User control** | Centralized opt-out | None | Browser settings | Device settings |
| **Privacy status** | Enhanced | Pseudonymized | Trackable | Pseudo-anonymous |
| **Future viability** | High | High | Declining | Declining |
***
## Getting started with UID2
To use UID2 in Narrative:
1. Ensure your data includes email or phone identifiers
2. Hash identifiers before upload if not already hashed
3. Configure the TTD Connector for your destination
4. Narrative handles UID2 generation automatically
***
## Additional resources
* [The Trade Desk UID2 Initiative](https://www.thetradedesk.com/us/about-us/industry-initiatives/unified-id-solution-2-0)
* [UID2 Documentation](https://unifiedid.com/docs/intro)
***
## Related content
Overview of all identifier categories
Connect identifiers across platforms
How hashing protects identifiers
Prepare your data for UID2 generation
# Concepts
Source: https://docs.narrative.io/concepts/index
Understand how Narrative I/O works—the architecture, primitives, and design decisions behind the platform
The Concepts section explains the *why* behind Narrative I/O. Rather than step-by-step instructions, you'll find explanations of how the platform works, why it's designed the way it is, and how different pieces fit together.
## Core Primitives
The fundamental building blocks of the platform.
Where your data lives and is processed
How Narrative orchestrates data collaboration
How data plane, compute pool, database, and schema determine where operations run
How compute resources are allocated for query execution
Column-level metrics that describe your data
## Rosetta Stone
Narrative's approach to schema normalization and data mapping.
What Rosetta Stone is and why it matters
The mechanics of automatic schema mapping
How data gets normalized across sources
## NQL
The design and philosophy behind Narrative Query Language.
Why NQL exists and how it differs from traditional approaches
How NQL relates to SQL and when to use each
How NQL handles data types and type safety
Why certain JOIN patterns cause performance problems
Pre-computed query results for performance and caching
How NQL optimizes materialized view refreshes
## Identifiers
How identifiers connect records across organizations and systems.
How connected components unify identifiers into person-level profiles
Strengthening graph structure with third-party linkage data
Improving activation reach by appending additional identifiers
## Architecture
How the platform is built and why.
How queries are compiled, optimized, and executed
How data moves through the platform
How samples cross to the control plane
How Narrative protects your data
## Workflows
Automating multi-step data pipelines.
How workflows automate sequential data operations and relate to the jobs system
## Webhooks
Push-based notifications for job state changes.
How webhooks deliver notifications when jobs change state
## MCP Server for Agents
Connect AI agents to Narrative.
How the Narrative MCP server lets AI agents interact with your data
Pre-built, harness-agnostic workflows that orchestrate the MCP server
## Model Inference
AI-powered operations within your data plane.
How AI inference works within your infrastructure
How inference keeps data secure in your data plane
How JSON Schema ensures predictable responses
## Apps
Third-party onboarding and account connection.
How invite links enable third parties to connect accounts without Narrative authentication
## Security
Enterprise security and access management.
How SSO works with Narrative and why it matters for enterprise security
How scoped permissions protect your resources and enforce least privilege
## Compliance
Privacy regulations and data protection requirements.
The EU's General Data Protection Regulation and its requirements
California's privacy law and consumer rights framework
# Data Collaboration MCP Server
Source: https://docs.narrative.io/concepts/mcp/overview
How the Narrative MCP server lets AI agents interact with your data
The Narrative MCP Server for Agents is a remote [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI agents like Claude explore datasets, kick off queries, refresh materialized views, and manage your Narrative account — all from a single conversation. Instead of switching between a chat window and the Narrative UI, you tell the agent what you need and it operates the platform on your behalf.
## What is MCP?
MCP is an open protocol that standardizes how AI applications connect to external tools and data sources. It defines a universal interface so that any MCP-compatible AI client — Claude Code, Claude Desktop, Cursor, Windsurf, and others — can discover and call tools exposed by an MCP server.
The protocol handles:
* **Tool discovery.** The client asks the server what tools are available and receives their names, descriptions, and parameter schemas.
* **Authentication.** OAuth-based flows let the server verify your identity without you sharing API keys with the AI client.
* **Tool execution.** The client calls a tool on your behalf, and the server returns structured results that the AI can reason about.
## Why use an AI assistant with Narrative?
Working with data collaboration platforms often involves switching between multiple screens — searching for datasets, inspecting schemas, writing queries, monitoring jobs. An AI assistant connected via MCP can compress these multi-step workflows into a conversational loop:
* **Explore datasets naturally.** Ask "what datasets do I have about mobile identifiers?" instead of navigating through the UI. The assistant searches, describes schemas, and summarizes what it finds.
* **Write and validate NQL iteratively.** Describe what you want in plain language, and the assistant drafts NQL, validates it against your schema, and fixes errors before submitting — all without you writing a query from scratch.
* **Operate on datasets without context-switching.** Trigger samples, refresh materialized views, and recalculate statistics through conversation. The assistant polls job status and reports when operations complete.
* **Work across companies.** If you belong to multiple companies, the assistant can switch context on your behalf, making cross-tenant operations seamless.
## How it works
The Narrative MCP server sits between your AI client and the Narrative API:
1. **You connect once.** Add the server URL to your AI client's MCP configuration. The first time the assistant calls a tool, a browser window opens for you to log in with your Narrative credentials.
2. **The assistant discovers tools.** On connection, the client fetches the list of available tools — dataset operations, NQL execution, attribute search, and more.
3. **Tools call the Narrative API.** When the assistant uses a tool, the MCP server translates the request into the appropriate Narrative API calls, using your authenticated session.
4. **Results flow back as context.** The server returns structured responses that the assistant incorporates into the conversation, so it can chain operations (e.g., describe a dataset, then write a query against it).
The server accepts two kinds of bearer credential, so the same endpoint works for interactive and programmatic clients:
* **OAuth login** — Interactive MCP clients (Claude Code, Claude Desktop, Cursor, Windsurf) open a browser-based login, and the server exchanges the resulting credential for a scoped Narrative access token. Your session persists across tool calls, so you only log in once per session.
* **Narrative API token** — Server-to-server callers and scripted clients can send a long-lived Narrative API token directly in the `Authorization: Bearer` header, skipping the browser flow. The token's existing user, company, and permission scope apply. See [Connecting to the Narrative MCP Server](/guides/mcp/connecting-an-ai-assistant#narrative-api-token-for-programmatic-clients) for setup details.
## Available tools
The server organizes its tools into these categories:
| Category | What you can do |
| ---------------------- | ----------------------------------------------------------------------------------------------------- |
| **Context** | Inspect the current session, list your companies, switch the active company, search companies by name |
| **Datasets** | Search and describe datasets, inspect column statistics, configure stats collection |
| **Dataset operations** | Request samples, refresh materialized views, recalculate statistics |
| **Attributes** | Search and describe Rosetta Stone attributes |
| **Access rules** | Discover and inspect the access rules your company owns or has been granted |
| **Mappings** | Create Rosetta Stone mappings from a dataset to an attribute |
| **NQL** | Validate queries and submit them for execution |
| **Jobs** | Search and describe jobs to track asynchronous work — NQL runs, samples, materialized-view refreshes |
| **Data planes** | Discover available data planes and their compute pools |
| **Workflows** | Create, trigger, describe, and follow runs of multi-step workflows |
See the [MCP server reference](/reference/integrations/mcp-server) for the complete tool catalog with parameters and examples.
## Other Narrative MCP servers
Narrative publishes a small suite of MCP servers alongside the Data Collaboration server. You can add any of them to your client independently:
| Server | URL | Auth | What it does |
| ------------------------------------------------------------ | ------------------------------- | ----- | ----------------------------------------------------------------- |
| [Data Collaboration](/reference/integrations/mcp-server) | `https://mcp.narrative.io/mcp` | OAuth | Query datasets, run NQL, manage workflows |
| [Knowledge Base](/reference/integrations/mcp-knowledge-base) | `https://docs.narrative.io/mcp` | None | Search the Narrative docs and answer questions with citations |
| [Agent Feedback](/reference/integrations/mcp-agent-feedback) | `https://narrative.support/mcp` | None | Submit structured product feedback and bug reports from a session |
All three are discoverable in the Narrative product under **Marketplace > MCP Servers**.
## Getting started
To connect your AI assistant to Narrative, follow the setup guide for your client:
Step-by-step setup for Claude Code, Claude Desktop, Cursor, and other MCP clients
Complete catalog of available tools with parameters and usage patterns
# Skills
Source: https://docs.narrative.io/concepts/mcp/skills
Pre-built skills that automate common Narrative workflows on top of the MCP server
Narrative publishes **Skills** — pre-built workflows that orchestrate the [Data Collaboration MCP Server](/concepts/mcp/overview) to accomplish specific tasks. Where the MCP server exposes raw tools (search datasets, validate NQL, describe attributes), a skill packages those tools into an opinionated procedure for a higher-level goal, like "draft and run an NQL query" or "generate a Rosetta Stone mapping for this dataset."
A skill is harness-agnostic — it's a portable instruction file, not logic tied to a particular AI assistant. You browse and install skills from the [Skills catalog](https://app.narrative.io/marketplace/skills) in the Narrative marketplace. Narrative publishes them in two forms from the public [`narrative-io/narrative-skills-marketplace`](https://github.com/narrative-io/narrative-skills-marketplace) repository: as a [`skills` CLI](https://agentskills.io) marketplace that installs into any spec-compliant agent (`npx skills add narrative-io/narrative-skills-marketplace`), and as [Claude Code plugins](https://docs.claude.com/en/docs/claude-code/plugins) you install with `/plugin`.
## How skills relate to the MCP server
Skills are clients of the MCP server, not a replacement for it:
* **The MCP server** exposes platform operations as discoverable tools (`narrative_datasets_search`, `narrative_nql_validate`, `narrative_attributes_describe`, …). Any MCP-compatible client can call them.
* **A skill** is a markdown instruction file (`SKILL.md`) that tells an AI assistant how to combine those tools — in what order, with what guardrails, and when to ask for approval — to complete a specific task.
This means skills inherit MCP authentication, scoping, and audit trails. When a skill calls a tool, it runs as the authenticated user against the active company, just like any other MCP call.
## Plugins and runtimes
Each skill belongs to a **plugin** — a versioned bundle published to the Claude Code marketplace. Narrative ships three plugins today:
| Plugin | Skills | Focus |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `narrative-common` | Find Attribute, Write NQL, Design Analysis, Create Workflow, Generate Rosetta Stone Mappings, Apply Rosetta Stone Mappings, Profile Dataset | General-purpose authoring, querying, profiling, and Rosetta Stone work |
| `narrative-identity` | Generate Identity Graph, Triage Pre-Graph Data, Generate Match Report | Identity resolution, graph building, and partner overlap |
| `narrative-audience` | Create Lookalike Audience | Audience expansion and look-alike modeling |
Skills also declare a **runtime** that describes how they work:
* **Prompt only** — the skill is pure instruction. The assistant follows the procedure using MCP tools and never writes or submits code on your behalf.
* **Prompt + code** — the skill produces or submits artifacts (NQL queries, workflow YAML, mapping definitions) in addition to following the procedure. These skills are always approval-gated.
## The catalog
Available skills include:
Resolve a fuzzy description or schema shape to the canonical Rosetta Stone attribute ID.
Draft, validate, and optionally run an NQL query against a Narrative dataset.
Scope a fuzzy analytical question into a structured brief of query specifications.
Author and submit a Narrative workflow from a natural-language intent.
Generate, evaluate, and improve Rosetta Stone attribute mappings for a dataset.
Apply generated mappings to a dataset via a one-shot, approval-gated workflow.
Compose and submit an identity-graph workflow that unions edge sources and labels components.
Audit a dataset or access rule for bad edges before an identity-graph build.
Produce a coverage and quality profile of a dataset or access rule: fill rate, cardinality, ranges, and quality flags.
Compare an owned dataset to a partner's marketplace data: overlap, match counts, and enrichment coverage.
Score a candidate population against a seed and materialize the closest matches as a new audience.
The Skills catalog page lists every published skill with its plugin, runtime, example prompts, accepted arguments, dependencies, and install commands for both the `skills` CLI and Claude Code.
## Arguments
Most skills expose a slash-command form with explicit arguments. The skill's detail page shows an **Arguments** table listing each flag, whether it's required, its default, and what it does. For example, `/write-nql` accepts `--dataset`, `--run`, `--limit`, and a free-text tail, so you can scope a single invocation:
```text theme={null}
/write-nql --dataset 123 --run --limit 50 distinct users in the last 30 days
```
When invoked without arguments, a skill walks you through its flow interactively.
## Dependencies
Skills can depend on other skills. For example, **Generate Identity Graph** delegates attribute lookup to **Find Attribute** rather than reimplementing it. When you install a plugin, every skill in that plugin becomes available — so dependencies within the same plugin are automatically satisfied. Cross-plugin dependencies are called out on the skill's detail page.
## Approval gating
Skills with the `prompt+code` runtime never modify your Narrative account without explicit approval. They draft the artifact (an NQL query, a workflow specification, a mapping), explain what it does, and wait for you to say "go" before calling a tool that submits work. This matters most for skills that generate workflows or write mappings, where the cost of an unintended run is highest.
## Next steps
Add the Narrative marketplace with the `skills` CLI or as a Claude Code plugin.
Skills require an authenticated MCP connection. Start here if you haven't connected yet.
See every published skill with example prompts and install commands.
The underlying tools that skills orchestrate.
# Data Privacy in Model Inference
Source: https://docs.narrative.io/concepts/model-inference/data-privacy
How Model Inference keeps your data secure within your infrastructure
Model Inference is designed with data privacy as a foundational principle. Unlike traditional AI services that require sending data to external providers, Model Inference runs entirely within your [data plane](/concepts/primitives/data-planes), ensuring your data never leaves your controlled infrastructure.
## The privacy challenge with external AI
When using external AI APIs (OpenAI API, Anthropic API, etc.), your data flows to third-party servers:
```mermaid theme={null}
sequenceDiagram
participant App as Your Application
participant API as External AI API
participant Provider as AI Provider Servers
App->>API: Send data for inference
API->>Provider: Data processed externally
Note over Provider: Data potentially logged, retained, or used for training
Provider->>API: Return response
API->>App: Response
```
This creates several concerns:
* **Data residency**: Data leaves your infrastructure and jurisdiction
* **Third-party access**: Provider can potentially access your data
* **Compliance complexity**: Must evaluate provider's data handling practices
* **Retention policies**: Provider may retain data for improvement or debugging
## How Model Inference protects your data
Model Inference eliminates these concerns by hosting models within your data plane:
```mermaid theme={null}
sequenceDiagram
participant App as Your Application
participant CP as Control Plane
participant DP as Your Data Plane
participant Model as Hosted LLM
App->>CP: Submit inference request
Note over CP: Only job metadata (no raw data)
CP->>DP: Route job to data plane
DP->>Model: Local inference
Note over Model: Model runs in your infrastructure
Model->>DP: Response
DP->>CP: Job completion status
CP->>App: Structured output
```
### Key privacy guarantees
| Aspect | Guarantee |
| ----------------------- | ------------------------------------------------------------ |
| **Data location** | Data never leaves your data plane |
| **No external calls** | No network requests to Anthropic, OpenAI, or other providers |
| **Your infrastructure** | Models run on compute within your environment |
| **No provider logging** | AI providers have no access to your data |
| **Full control** | You control data access, retention, and deletion |
## Compliance implications
Model Inference simplifies compliance with data protection regulations:
### GDPR considerations
* **Data minimization**: Only necessary data is processed
* **Storage limitation**: You control retention within your data plane
* **Data transfers**: No cross-border transfers to AI providers
* **Processor agreements**: No need for DPAs with AI providers for inference
### CCPA considerations
* **Service provider status**: AI providers are not service providers for your data
* **Sale of data**: No data is shared with third parties
* **Right to delete**: Full control over data deletion
### Industry regulations
For industries with strict data handling requirements (healthcare, finance, government), Model Inference enables AI capabilities without the compliance burden of external AI services:
* **HIPAA**: PHI never leaves your controlled environment
* **PCI DSS**: Payment data stays within your secure perimeter
* **FedRAMP**: Data remains in authorized boundaries
## Audit trail
All inference jobs are tracked through Narrative's standard job system:
* Job creation timestamp
* Data plane where inference ran
* Model used
* Token usage metrics
* Job completion status
This provides a complete audit trail without exposing the actual data processed.
## What the control plane sees
The [control plane](/concepts/primitives/control-plane) only handles:
* Job routing and coordination
* Metadata about requests (model choice, configuration)
* Job status updates
* Token usage statistics
The control plane never receives or processes:
* Your prompt content
* Your data
* The model's responses
## Comparison with external AI
| Aspect | External AI APIs | Model Inference |
| -------------------- | ----------------------------- | ------------------------- |
| Data location | Provider's servers | Your data plane |
| Network traffic | Data sent externally | Local only |
| Provider data access | Yes (per their policies) | No |
| Compliance burden | High (must evaluate provider) | Low (your infrastructure) |
| Audit complexity | Must rely on provider logs | Full control |
| Data retention | Provider-controlled | You control |
## Best practices
1. **Use appropriate models**: Don't send more context than necessary
2. **Review prompts**: Ensure prompts don't unnecessarily include sensitive data
3. **Monitor usage**: Track inference jobs through the job system
4. **Set retention policies**: Configure data plane retention appropriately
## Related content
How Model Inference works
Understanding data plane architecture
Platform security architecture
GDPR compliance
# Model Inference
Source: https://docs.narrative.io/concepts/model-inference/overview
Run AI-powered operations within your data plane using hosted LLMs
Model Inference enables you to run large language model (LLM) operations directly within your [data plane](/concepts/primitives/data-planes). Unlike traditional AI APIs that require sending data to external services, Model Inference keeps your data secure within your own infrastructure while still leveraging powerful AI capabilities.
## Why Model Inference matters
When working with sensitive data, sending it to external AI services creates compliance and security risks. Model Inference solves this by hosting models within your data plane infrastructure:
| Traditional AI APIs | Model Inference |
| ------------------------------------ | --------------------------------- |
| Data sent to external servers | Data stays in your infrastructure |
| Subject to third-party data policies | You control data residency |
| Network latency to external services | Local execution within data plane |
| Compliance complexity | Simplified compliance posture |
This architecture enables AI-powered features—like auto-generating dataset descriptions or translating natural language to technical formats—without compromising your data governance.
## How Model Inference works
Model Inference operates through Narrative's [job queue](/reference/glossary#job-queue), following the same pattern as other asynchronous operations:
1. **Submit request**: Your application sends an inference request specifying the model, messages, and output schema
2. **Job creation**: The [control plane](/concepts/primitives/control-plane) creates an inference job and queues it
3. **Local execution**: The data plane [operator](/reference/glossary#operator-data-plane) picks up the job and runs inference locally
4. **Structured response**: Results are returned in a predictable format defined by your JSON Schema
```mermaid theme={null}
sequenceDiagram
participant App as Your Application
participant CP as Control Plane
participant DP as Data Plane
participant Model as Hosted LLM
App->>CP: Submit inference request
CP->>CP: Create inference job
CP-->>App: Return job ID
DP->>CP: Poll for jobs
CP->>DP: Return inference job
DP->>Model: Execute inference
Model->>DP: Return completion
DP->>CP: Report results
App->>CP: Poll job status
CP-->>App: Return structured output
```
## Key capabilities
### Supported models
Model Inference supports models from multiple providers, all hosted within your data plane:
| Provider | Models |
| --------- | ---------------------------------------------------- |
| Anthropic | Claude Haiku 4.5, Claude Sonnet 4.5, Claude Opus 4.5 |
| OpenAI | GPT-4.1, o4-mini, GPT-oss-120b |
For detailed model specifications, see the official documentation from [Anthropic](https://docs.anthropic.com/en/docs/about-claude/models) and [OpenAI](https://platform.openai.com/docs/models).
### Structured output
Every inference request includes a JSON Schema that defines the expected response format. The model is constrained to return valid JSON matching your schema, making responses predictable and easy to parse programmatically.
```typescript theme={null}
const inferenceConfig = {
output_format_schema: {
type: 'object',
properties: {
summary: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 },
categories: {
type: 'array',
items: { type: 'string' }
}
},
required: ['summary', 'confidence']
}
};
```
This guarantees you receive a response with exactly the fields you expect, in the types you specify.
### Conversation context
Inference requests support multi-turn conversations through the messages array. Each message
has a role (`system`, `user`, or `assistant`) and a `content` array of content blocks. The
dominant block type is `text`; agent-loop flows additionally emit `tool_use` (the model
requesting a tool call) and `tool_result` (the response) blocks.
```typescript theme={null}
const messages = [
{
role: 'system',
content: [{ type: 'text', text: 'You are a data classification assistant.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Classify this dataset based on its columns...' }]
}
];
```
**Legacy `{role, text}` shape is still accepted.** Older integrations that send
`{ role: 'user', text: 'hello' }` continue to work — the API auto-canonicalizes the
request into a single `text` content block. New code should use the content-block shape;
responses always emit it.
### Configuration options
Fine-tune model behavior with inference configuration parameters:
| Parameter | Description | Default |
| ---------------- | -------------------------------------------- | ------------- |
| `max_tokens` | Maximum tokens in the response | Model default |
| `temperature` | Randomness (0 = deterministic, 1 = creative) | Model default |
| `top_p` | Nucleus sampling parameter | Model default |
| `stop_sequences` | Strings that stop generation | None |
## Common use cases
Model Inference powers AI features throughout the Narrative platform:
* **Dataset descriptions**: Automatically generate human-readable descriptions from dataset metadata and samples
* **Schedule translation**: Convert natural language schedules ("every weekday at 9am") to CRON expressions
* **Data classification**: Categorize records based on content analysis
* **Schema suggestions**: Recommend Rosetta Stone attribute mappings based on column names and sample data
## Data privacy
Because inference runs within your data plane:
* **No external API calls**: Data is never sent to Anthropic, OpenAI, or any external service
* **Your infrastructure**: Models run on compute resources within your data plane
* **Compliance-friendly**: Simplifies GDPR, CCPA, and other regulatory requirements
* **Audit trail**: All inference jobs are logged through the standard job system
For more details, see [Data Privacy in Model Inference](/concepts/model-inference/data-privacy).
## Related content
Submit inference requests using the SDK
How JSON Schema ensures predictable responses
Available models and their capabilities
How inference keeps data secure
# Structured Output
Source: https://docs.narrative.io/concepts/model-inference/structured-output
How JSON Schema ensures predictable, typed responses from Model Inference
Structured output is a core capability of Model Inference that guarantees the model returns data in a predictable, machine-readable format. By providing a JSON Schema, you constrain the model's response to match your exact specification.
## The problem with unstructured output
Traditional LLM interactions return free-form text:
```
User: Classify this review as positive, negative, or neutral.
Model: Based on my analysis, I believe this review expresses a positive sentiment. The customer seems satisfied with their purchase.
```
This creates challenges:
* **Parsing complexity**: Must extract the actual classification from prose
* **Inconsistent formats**: Response structure varies between calls
* **Error-prone**: Regex or string matching can fail on edge cases
* **No type safety**: Can't validate response structure at compile time
## How structured output works
With structured output, you define the exact response format:
```typescript theme={null}
const schema = {
type: 'object',
properties: {
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
confidence: { type: 'number', minimum: 0, maximum: 1 }
},
required: ['sentiment', 'confidence']
};
```
The model returns exactly what you specify:
```json theme={null}
{
"sentiment": "positive",
"confidence": 0.92
}
```
## Why structured output matters
### 1. Reliable automation
Structured output enables reliable automation pipelines:
```typescript theme={null}
// Process results immediately without parsing
const result = job.result.structured_output;
await database.insert({
category: result.category,
confidence: result.confidence,
tags: result.tags
});
```
### 2. Type safety
With TypeScript, you get compile-time type checking:
```typescript theme={null}
interface ClassificationResult {
category: 'retail' | 'finance' | 'healthcare';
confidence: number;
}
const result = job.result as ModelInferenceRunResult;
// TypeScript knows result.structured_output.category is a string
// and result.structured_output.confidence is a number
```
### 3. Schema validation
The model is constrained to produce valid JSON matching your schema:
* Required fields are always present
* Types match your specification
* Enum values are restricted to defined options
* Numeric constraints are enforced
### 4. Consistent integration
Every response has the same structure, making integration predictable:
| Without Schema | With Schema |
| ------------------------- | ------------------------ |
| Parse response text | Direct property access |
| Handle format variations | Consistent structure |
| Runtime validation needed | Schema-enforced validity |
| Type casting required | Native types |
## JSON Schema capabilities
Model Inference supports standard JSON Schema features:
### Basic types
```json theme={null}
{
"type": "object",
"properties": {
"name": { "type": "string" },
"count": { "type": "integer" },
"score": { "type": "number" },
"active": { "type": "boolean" }
}
}
```
### Constraints
```json theme={null}
{
"type": "object",
"properties": {
"rating": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"category": {
"type": "string",
"enum": ["A", "B", "C"]
}
}
}
```
### Nested structures
```json theme={null}
{
"type": "object",
"properties": {
"analysis": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"details": { "type": "string" }
}
},
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
}
```
## Design principles
### 1. Define what you need
Only include fields your application will use:
```typescript theme={null}
// Good: Focused schema
const schema = {
type: 'object',
properties: {
category: { type: 'string', enum: ['spam', 'not_spam'] },
confidence: { type: 'number' }
},
required: ['category', 'confidence']
};
// Avoid: Overly broad schema
const schema = {
type: 'object',
properties: {
category: { type: 'string' },
confidence: { type: 'number' },
reasoning: { type: 'string' },
alternatives: { type: 'array' },
metadata: { type: 'object' }
// ... many unused fields
}
};
```
### 2. Use enums for known values
Constrain categorical outputs to valid options:
```typescript theme={null}
const schema = {
type: 'object',
properties: {
priority: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical']
}
}
};
```
### 3. Set appropriate bounds
Define numeric ranges when applicable:
```typescript theme={null}
const schema = {
type: 'object',
properties: {
confidence: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'Confidence score from 0 (uncertain) to 1 (certain)'
}
}
};
```
### 4. Add descriptions
Help the model understand field semantics:
```typescript theme={null}
const schema = {
type: 'object',
properties: {
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral'],
description: 'Overall emotional tone: positive for satisfaction, negative for complaints, neutral for factual'
}
}
};
```
## Common patterns
### Classification
```json theme={null}
{
"type": "object",
"properties": {
"label": { "type": "string", "enum": ["class_a", "class_b", "class_c"] },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"reasoning": { "type": "string" }
},
"required": ["label", "confidence"]
}
```
### Extraction
```json theme={null}
{
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": { "type": "string" },
"type": { "type": "string" }
},
"required": ["value", "type"]
}
}
},
"required": ["entities"]
}
```
### Transformation
```json theme={null}
{
"type": "object",
"properties": {
"input_format": { "type": "string" },
"output_format": { "type": "string" },
"transformed_value": { "type": "string" }
},
"required": ["transformed_value"]
}
```
## Related content
Practical guide to schema definition
Supported schema features
How inference works
Submit inference requests
# Chunking
Source: https://docs.narrative.io/concepts/nql/chunking
How NQL splits large queries into time-based segments for stability and cost efficiency
Chunking is an execution strategy that processes large datasets in smaller, time-bounded segments. It improves stability and cost efficiency without changing your query syntax—you write normal NQL, and the platform decides when and how to split the work.
## Why chunking matters
Very large datasets—from tens of gigabytes to hundreds of terabytes—present challenges for single-shot query execution:
**Stability.** Long-running scans can fail due to resource pressure, network timeouts, or cluster issues. A failure 4 hours into a 5-hour query means starting over from scratch.
**Cost control.** When a query fails late in execution, you've already consumed compute resources with nothing to show for it. The later the failure, the greater the waste.
**Scalability.** Single-pass processing doesn't scale predictably. A query that works for 10GB may fail at 100GB or behave differently at 1TB.
Chunking addresses these problems by capping the work performed in any single step.
## How chunking works
When NQL determines a query is eligible for chunked execution:
1. **Evaluation.** The query planner analyzes the query structure to determine if chunking can apply without changing results.
2. **Time-based splitting.** The platform divides the scan into ranges based on a last-modified timestamp column (typically `nio_last_modified_at` or `last_modified_at`).
3. **Independent execution.** Each chunk runs as a separate operation. If one chunk fails, only that chunk needs to retry—not the entire query.
4. **Result combination.** Results from all chunks are combined to produce the final output, semantically equivalent to running the query as a single operation.
This happens automatically. There are no user-facing configuration options or syntax changes required.
## Eligible queries
Chunking applies to queries that primarily scan a single dataset with standard projections and filters. These patterns work well:
* **Simple scans** with filters and projections from `company_data` datasets
* **Queries against `narrative.rosetta_stone`** with standard selection patterns
* **Materialized views** that read from a single source with straightforward transformations
### Example: A chunkable query
This materialized view is a good candidate for chunking:
```sql theme={null}
CREATE MATERIALIZED VIEW "large_user_enrichment"
REFRESH_SCHEDULE = '@weekly'
WRITE_MODE = 'overwrite'
AS
SELECT
user_id,
email.value AS email_sha256,
country_code,
last_seen_at
FROM company_data."large_source_dataset"
WHERE last_seen_at >= CURRENT_TIMESTAMP - INTERVAL '180' DAY
```
The query shape—a scan with filters and projections from a single dataset—makes it eligible. The platform may split work by last-modified ranges to improve reliability on very large inputs.
## Queries not eligible for chunking
To preserve correctness and keep the execution model predictable, certain query patterns are not chunked:
**Complex joins.** Multi-table joins, especially when join semantics span large time ranges, require seeing all matching rows together. Chunking could produce incorrect results.
**Unions and set operations.** Operations like `UNION`, `INTERSECT`, and `EXCEPT` combine results from multiple queries in ways that don't partition cleanly by time.
**Global aggregations.** Operations like `SUM`, `AVG`, or `COUNT` over the entire dataset need all rows in a single pass to produce correct results. (Group-by aggregations within chunks may still work.)
**Sorting and windowing.** `ORDER BY` across the full result set and window functions that depend on complete data can't be chunked without changing semantics.
**Table functions.** Functions that change scan behavior may not be compatible with time-based partitioning.
Queries that include these operations still run—they just execute as a single operation rather than being split into chunks.
## Result consistency
Chunked execution aims to produce results equivalent to a single-pass run. In practice:
* **Results are materially the same** for typical analytical workloads
* **Minor differences** can occur at chunk boundaries due to processing order or concurrent updates to source data
* **Deterministic queries** over stable data produce identical results regardless of chunking
For most use cases, you won't notice whether a query was chunked or not—which is the point.
## Relationship to other optimizations
Chunking complements other NQL execution strategies:
**[Incremental View Maintenance](/concepts/nql/incremental-view-maintenance)** processes only changed data since the last refresh. Chunking handles the initial scan of large datasets; IVM handles ongoing updates efficiently.
**[Query Processing](/concepts/architecture/query-processing)** describes the full query lifecycle. Chunking is one optimization the query planner may apply during the optimization stage.
**[Materialized Views](/concepts/nql/materialized-views)** benefit from chunking when refreshing over very large source datasets. The refresh becomes more reliable without any changes to the view definition.
***
## Related content
Pre-computed query results that benefit from chunked refresh
Update views by processing only changed data
How queries are compiled, optimized, and executed
Step-by-step guide to creating materialized views
# NQL Design Philosophy
Source: https://docs.narrative.io/concepts/nql/design-philosophy
Why Narrative created a purpose-built query language for data collaboration
NQL (Narrative Query Language) is an interpreted language designed specifically for data collaboration. Rather than running directly against a database, NQL is parsed by the [control plane](/concepts/primitives/control-plane) and transpiled into native SQL for whatever database powers your [data plane](/concepts/primitives/data-planes)—whether that's Snowflake, Spark, or another supported engine.
## Why not just use SQL?
SQL was designed for querying a single database. Data collaboration introduces challenges that standard SQL doesn't address:
**Multiple database dialects.** Organizations store data in different systems—Snowflake, Spark, BigQuery, and others. Each has its own SQL dialect with subtle differences in date functions, string handling, NULL semantics, and syntax. A query written for Snowflake won't run on Spark without modification.
**Cross-organization permissions.** When querying data from multiple organizations, permissions must be checked before execution. Standard SQL has no concept of cross-organization [access rules](/concepts/primitives/access-rules) or field-level controls.
**Schema normalization.** Data from different sources uses different column names and formats. [Rosetta Stone](/concepts/rosetta-stone/overview) handles this normalization, but it requires a layer between your query and the underlying database.
**Optimization across data planes.** Queries that span multiple data planes need coordination that a single-database SQL engine can't provide.
NQL addresses these challenges by providing an abstraction layer that handles dialect differences, permission enforcement, and schema normalization transparently.
## NQL as an interpreted language
When you submit an NQL query, it doesn't execute directly. Instead, the control plane:
1. **Parses** the NQL syntax and validates the query structure
2. **Resolves** dataset references and applies Rosetta Stone mappings
3. **Checks** your permissions against access rules and field-level controls
4. **Optimizes** the execution plan for your target data plane(s)
5. **Transpiles** the query into the native SQL dialect of your data plane
This interpretation happens at query time, which means the same NQL query can run against different database systems without modification.
## Transpilation: One query, many dialects
Transpilation is the process of converting NQL into equivalent SQL for a specific database. Consider a simple date comparison:
```sql NQL theme={null}
SELECT user_id, email, event_date
FROM my_dataset
WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
```
```sql Snowflake SQL theme={null}
SELECT user_id, email, event_date
FROM my_dataset
WHERE event_date >= DATEADD(DAY, -7, CURRENT_DATE())
```
```sql Spark SQL theme={null}
SELECT user_id, email, event_date
FROM my_dataset
WHERE event_date >= DATE_SUB(CURRENT_DATE(), 7)
```
The NQL query is identical regardless of where your data lives. The control plane transpiles it to the correct syntax for your data plane's database engine.
This abstraction extends beyond date functions to:
* **String operations** — Functions like `CONCAT`, `SUBSTRING`, and `TRIM` have different syntax across databases
* **NULL handling** — How NULLs behave in comparisons and aggregations varies by dialect
* **Type casting** — Explicit and implicit type conversions differ between systems
* **Window functions** — Partitioning and ordering syntax has dialect-specific nuances
## Benefits of the abstraction layer
Building NQL as an interpreted, transpiled language provides several advantages:
**Portability.** Your queries work regardless of which database powers your data plane. If you migrate from Snowflake to Spark, your NQL queries continue to work without modification.
**Governance at compile time.** Permissions are checked before the query reaches your data. If you don't have access to a dataset or field, the query fails at compilation—not after scanning millions of rows.
**Rosetta Stone integration.** Schema normalization happens during query compilation. You query standardized attribute names, and the transpiler maps them to the actual column names in each dataset.
**Cross-organization coordination.** For queries that span multiple data planes, the control plane can coordinate execution and optimize data movement.
**Consistent behavior.** NQL provides consistent semantics regardless of the underlying database. A query behaves the same way whether it runs on Snowflake or Spark.
**Explicit column selection.** NQL requires you to list columns explicitly rather than using wildcards like `SELECT *`. This design choice enables better cost transparency, governance compliance, and query optimization—particularly for [Rosetta Stone](/concepts/rosetta-stone/overview) queries where wildcards would expand across many underlying tables. See [Explicit Column Selection](/nql/general/explicit-columns) for details.
## Trade-offs
Every abstraction has costs. NQL's design involves these trade-offs:
**Dialect-specific features unavailable.** Some database-specific features and optimizations aren't exposed through NQL. If you need a Snowflake-specific function that NQL doesn't support, you can't use it directly.
**Compilation overhead.** Query interpretation adds latency before execution begins. For very simple queries, this overhead may be noticeable—though for most analytical workloads, execution time dominates.
**Debugging complexity.** When a query fails, you're working with NQL syntax while the underlying error may come from the transpiled SQL. Error messages bridge this gap, but the indirection adds complexity.
For most data collaboration use cases, these trade-offs are worthwhile. The ability to query across organizations, databases, and schemas with a consistent language outweighs the loss of dialect-specific features.
***
## Related content
How NQL queries are compiled, dispatched, and executed across data planes
The orchestration layer that handles NQL compilation and coordination
Where your data lives and where transpiled queries execute
How Narrative normalizes schemas across organizations
# Incremental View Maintenance
Source: https://docs.narrative.io/concepts/nql/incremental-view-maintenance
How NQL optimizes materialized view refreshes by updating only changed data
Incremental View Maintenance (IVM) is an optimization technique that updates materialized views by processing only the changes in underlying data, rather than recomputing the entire result from scratch.
## Why IVM matters
Consider a materialized view over a billion-row dataset. If only 10,000 rows changed since the last refresh, a full refresh would reprocess all billion rows to incorporate those changes. IVM processes just the 10,000 changed rows and merges them into the existing result.
The benefits scale with dataset size:
**Performance.** IVM can reduce refresh time from hours to minutes or seconds. When only a small fraction of data changes between refreshes, the savings are dramatic.
**Resource efficiency.** Processing less data means lower compute costs. For views that refresh frequently, IVM can significantly reduce your infrastructure spend.
**Faster refresh cycles.** Because incremental refreshes are faster, you can refresh more frequently without proportionally increasing costs. This enables near-real-time analytics for workloads that would otherwise require daily batches.
## How IVM works
IVM tracks changes to underlying data and applies those changes to the materialized view result:
### Change detection
The system identifies which rows in the source data have been inserted, updated, or deleted since the last refresh. This is typically done by tracking timestamps or using change data capture mechanisms.
### Delta computation
Rather than running the full query, IVM computes only how the result should change based on the detected changes. For an aggregation like `SUM(amount) GROUP BY category`, an inserted row adds to the sum for its category; a deleted row subtracts from it.
### Merge strategies
The computed deltas are merged into the existing materialized view:
* **Insertions** add new rows or update aggregations
* **Deletions** remove rows or adjust aggregations
* **Updates** are typically handled as a deletion followed by an insertion
The result is mathematically equivalent to a full refresh, but computed much more efficiently.
For explicit control over merge behavior—such as matching on specific keys and deciding whether to update or insert—see [Incremental Upserts with MERGE ON](/guides/nql/incremental-upserts).
## When IVM applies
Not all queries can be incrementally maintained. IVM works well for certain patterns:
**Simple projections and filters.** Selecting columns and filtering rows is straightforward to maintain incrementally—new rows that pass the filter are added, deleted rows are removed.
**Group-by aggregations.** Common aggregations like `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX` can often be maintained incrementally. The system tracks partial aggregates per group and updates them as data changes.
**Inner joins.** When the join keys don't change, new matching pairs can be identified and added without reprocessing existing matches.
### Requirements for IVM
For IVM to work effectively, certain conditions must be met:
* **Deterministic change tracking**: The system must reliably identify what changed
* **Associative operations**: Aggregations must be computable from partial results
* **Stable keys**: Join and group keys should not change for existing rows
## When full refresh is needed
Some scenarios require recomputing the entire view:
**Schema changes.** If you alter the view definition, underlying tables, or query structure, a full refresh ensures consistency.
**Complex aggregations.** Operations like `MEDIAN`, `PERCENTILE`, or custom window functions may not support incremental computation because they depend on the entire dataset, not just deltas.
**Historical corrections.** When backfilling or correcting historical data that spans the entire view, a full refresh may be simpler and more reliable than computing complex deltas.
**Non-monotonic changes.** If changes invalidate previously computed results in non-obvious ways (like changing a dimension value that affects many groups), full refresh avoids subtle correctness issues.
## IVM and refresh scheduling
IVM complements scheduled refreshes. Consider this pattern:
| Refresh frequency | Without IVM | With IVM |
| ----------------- | ---------------------------- | ------------------------------ |
| Daily | Processes full dataset daily | Processes only daily changes |
| Hourly | 24x daily compute | Only new hourly data each time |
| Every 15 minutes | Very expensive | Efficient, near-real-time |
With IVM, you can often increase refresh frequency without proportionally increasing costs, because each refresh processes only a fraction of the data.
## Trade-offs
IVM isn't free—it introduces some complexity:
**Change tracking overhead.** The system must track what changed, which has some storage and processing cost.
**Query restrictions.** Not all query patterns support incremental maintenance. Complex queries may fall back to full refresh.
**Correctness complexity.** Incremental computation must produce results identical to full refresh. For complex queries, this requires careful implementation.
For most analytical workloads with regular data updates, IVM provides significant benefits. For small datasets or infrequent refreshes, the overhead may not be worthwhile.
***
## Related content
When and why to use materialized views
How large queries are split into time-based segments
Use MERGE ON for deduplication and updates
Step-by-step guide to creating materialized views
# Understanding JOIN Performance
Source: https://docs.narrative.io/concepts/nql/join-performance
How query planners optimize JOIN operations and why certain patterns cause problems
Query performance is critical when working with large datasets in Narrative's Data Collaboration Platform. Understanding how query planners handle JOIN operations helps you write queries that execute efficiently at scale.
## How query planners optimize JOINs
When you write a JOIN in NQL, the query planner analyzes the join condition and chooses an execution strategy. For simple equality conditions like `A.key = B.key`, the planner can use highly optimized algorithms.
### Hash joins
The most efficient join algorithm for equality conditions is the hash join:
1. The planner builds a hash table from the smaller table's join keys
2. It scans the larger table and probes the hash table for each key
3. Matches are found in near-constant time (O(1) per lookup)
Hash joins work because a single equality condition provides a clear, unambiguous way to partition and match data. The hash function maps each key to exactly one bucket, making lookups extremely fast.
### Index utilization
When tables have indexes on join columns, the planner can use index lookups instead of full scans. Like hash joins, index-based strategies require predictable, single-condition lookups to work effectively.
### What makes a join condition optimizable
A join condition is optimizable when the planner can:
* Build a hash table or index on one side
* Probe that structure efficiently from the other side
* Match rows without examining every possible pair
Single-key equality joins (`A.key = B.key`) satisfy all these requirements. The planner knows exactly how to partition the data and find matches efficiently.
## Why OR conditions prevent optimization
When you introduce OR into a join condition, the optimization breaks down:
```sql theme={null}
ON A.user_id = B.user_id OR A.email = B.email
```
The query planner cannot create a single hash table that handles both conditions simultaneously. The OR means a row can match through either path, but the paths are independent—there's no single key to hash.
### Query planner limitations
Most query planners treat OR joins as a single complex condition rather than recognizing they could be split into two efficient joins. The planner isn't sophisticated enough to automatically:
* Run two separate joins and merge results
* Build multiple hash tables and probe them in parallel
* Recognize that UNION would produce the same result more efficiently
Instead, the planner falls back to less efficient strategies.
### What happens internally
When faced with an OR join, the query planner typically resorts to:
**Nested loop joins**: The planner iterates through one table and, for each row, scans the other table checking both conditions. This approach has O(n × m) complexity—for tables with millions of rows, this becomes computationally expensive.
**Spool operations**: Some planners create temporary storage structures (spools) to materialize intermediate results. In extreme cases, these spools can grow to enormous sizes. One documented example showed a spool containing 7 billion rows (112 GB) for what should have been a straightforward join.
**Constant scans and multiple passes**: The planner may create multiple internal join passes or constant scan operations to handle the OR logic, adding overhead with each pass.
### The exponential cost problem
The fundamental issue is that an OR join isn't really one join—it's conceptually two joins that need to be merged. When the planner treats it as one operation, the cost compounds. Instead of `cost(join1) + cost(join2)`, you get something closer to `cost(join1) × cost(join2)` due to the nested evaluation required.
## Performance impact in practice
The theoretical problems translate to dramatic real-world performance differences.
### Orders of magnitude slower
Queries that should complete in seconds can run for hours with OR in the join:
| Scenario | With OR join | Without OR join | Improvement |
| ------------------ | ------------ | --------------- | ----------- |
| Two 10k-row tables | \~4 hours | \~2 minutes | 120x faster |
| Simple match query | 4 min 37 sec | 12 seconds | 23x faster |
| Large dataset join | 3 hr 55 min | under 2 min | 118x faster |
These aren't edge cases—they represent typical results when OR joins encounter non-trivial data volumes.
### Resource consumption
Beyond time, OR joins consume disproportionate resources:
* **Memory**: Spool operations and intermediate results can consume gigabytes
* **CPU**: Nested loop evaluation keeps processors busy with redundant work
* **I/O**: Multiple passes over data increase disk reads dramatically
In a shared platform environment, a single poorly-optimized query can impact other users' workloads.
### When queries never finish
For sufficiently large tables, OR joins may never complete. The exponential cost growth means that doubling the data size more than doubles the execution time. What works on test data may be completely impractical on production volumes.
## Patterns that enable optimization
Understanding what works helps you write efficient queries.
### Single-key equality: the optimal path
The ideal join condition is a simple equality on one column:
```sql theme={null}
ON A.key = B.key
```
This pattern enables hash joins, index utilization, and predictable performance as data scales.
### How UNNEST transforms OR into single-key joins
The UNNEST technique works by restructuring your data so that multiple possible keys become multiple rows, each with a single key:
```sql theme={null}
-- Before: Two keys per row, requiring OR
Purchases: {user_id: 123, email: "a@b.com", ...}
-- After UNNEST: Two rows, each with one key
Purchases_flat: {key: 123, ...}
Purchases_flat: {key: "a@b.com", ...}
```
Now the join uses a single `key` column, and the planner can apply its efficient algorithms. You've done the work of separating the conditions that the planner couldn't do automatically.
### How UNION separates work efficiently
The UNION approach explicitly runs two optimized joins and combines results:
```sql theme={null}
(SELECT ... JOIN ON condition1)
UNION
(SELECT ... JOIN ON condition2)
```
Each individual join is efficient. The UNION handles deduplication. The total cost is `cost(join1) + cost(join2) + cost(dedup)`—linear, not exponential.
## Related content
Step-by-step guide to restructuring OR conditions
More techniques for writing efficient queries
Why NQL is designed the way it is
How queries are compiled, optimized, and executed
# Materialized Views
Source: https://docs.narrative.io/concepts/nql/materialized-views
How materialized views work in NQL and when to use them
A materialized view is a pre-computed query result stored as a dataset. Unlike a regular query that executes each time you run it, a materialized view stores the results physically, giving you the speed of reading a table with the flexibility of a query.
## Why materialized views exist
Materialized views solve three common problems in data collaboration:
**Query performance.** Complex queries with joins, aggregations, or filters can take significant time to execute. When you need to run the same query repeatedly—for dashboards, reports, or API responses—a materialized view lets you compute once and read many times.
**Data caching for collaboration.** When sharing data with partners, you often want to provide a curated, pre-processed dataset rather than raw tables. Materialized views let you define exactly what data is shared and keep it updated automatically.
**Scheduled refresh for near-real-time data.** By refreshing on a schedule, materialized views provide data that's fresh enough for most analytical workloads without the cost of real-time processing.
## Stored snapshot vs. dynamic retrieval
Understanding this distinction is key to using materialized views effectively:
| Aspect | Regular query | Materialized view |
| -------------- | --------------------------- | ------------------------- |
| Execution | Runs every time you query | Runs only on refresh |
| Data freshness | Always current | As fresh as last refresh |
| Speed | Depends on query complexity | Fast reads (like a table) |
| Storage | None | Stores full result set |
| Cost | Compute on every read | Compute on refresh only |
A regular query is like making a phone call—you get live information but it takes time to connect. A materialized view is like receiving a daily newsletter—the information is only as fresh as the last edition, but it's instantly available.
## When to use materialized views
Materialized views are well-suited for:
**Expensive aggregations that don't change frequently.** If you're computing daily summaries, monthly rollups, or other aggregations over large datasets, a materialized view can run this computation once per day instead of on every request.
**Cross-organization queries.** Queries that join your data with partner data or the Rosetta Stone can be expensive. Materialize the results to avoid repeated cross-organization computation.
**Dashboard and reporting data.** Dashboards often run the same queries many times as users refresh pages. A materialized view provides consistent, fast responses for analytical workloads.
**Data products for partners.** When sharing curated datasets with collaborators, materialized views let you define exactly what's included and refresh it automatically.
## When not to use materialized views
Materialized views aren't always the right choice:
**Constantly changing data.** If your underlying data changes every few seconds and you need up-to-the-second accuracy, the refresh overhead of a materialized view may not be worthwhile.
**Queries that are already fast.** Simple queries against small tables don't benefit from materialization. The added complexity and storage cost isn't justified.
**Storage-constrained environments.** Materialized views duplicate data. If storage costs are a concern, consider whether the performance benefits outweigh the storage requirements.
**Ad-hoc analysis.** For one-time queries or exploratory analysis, just run the query directly. Materialized views are for queries you'll run repeatedly.
## Refresh strategies
Materialized views must be refreshed to reflect changes in underlying data. NQL supports several approaches:
**Scheduled refresh.** Set a CRON expression or use presets like `@daily` or `@hourly` to refresh automatically. This is the most common approach for production workloads.
**Manual refresh.** Trigger a refresh via API when you know the underlying data has changed. Useful for event-driven workflows or testing.
**Incremental refresh.** For large datasets, [Incremental View Maintenance](/concepts/nql/incremental-view-maintenance) can update only the changed portions of the view rather than recomputing everything. This dramatically reduces refresh time for views over large datasets with relatively small changes.
### Choosing a refresh schedule
Consider these factors:
* **How fresh does the data need to be?** If daily is acceptable, don't pay for hourly refreshes.
* **How expensive is the underlying query?** Frequent refreshes of expensive queries increase costs.
* **When do underlying datasets update?** Schedule refreshes after upstream data is available.
## How materialized views relate to datasets
When you create a materialized view, the result becomes a dataset in Narrative I/O. This means:
* The view appears in your dataset catalog
* You can apply access controls and sharing rules
* Partners can query it like any other dataset
* You can see statistics, row counts, and schema information
* The view inherits the governance and compliance features of the platform
This makes materialized views a powerful tool for data products—you define the query once, and the platform handles keeping it fresh and making it available to authorized consumers.
## View datasets
A view dataset is a lightweight alternative to a materialized view. Instead of physically storing query results, a view dataset stores only the NQL query definition. When another query references a view dataset, the NQL is inlined and re-evaluated at query time—similar to a database view.
### When to use view datasets
View datasets are useful when:
* **Data freshness matters more than speed.** Because the query is re-evaluated every time, results always reflect the latest underlying data without waiting for a refresh.
* **You want to avoid data duplication.** View datasets don't store a copy of the results, so there's no additional storage cost.
* **You need a reusable query abstraction.** Define a complex query once and reference it by name in other queries.
### View dataset limitations
View datasets have several restrictions compared to materialized views:
| Feature | Materialized view | View dataset |
| ---------------------- | ----------------------------- | --------------------------------------- |
| Stores query results | Yes | No |
| Refresh schedule | Supported | Not applicable |
| `MERGE ON` (upsert) | Supported | Not supported |
| `PARTITIONED_BY` | Supported | Not supported |
| Chunking strategy | Supported | Not supported |
| `DELTA` syntax | Supported | Not supported |
| Access rules | Can be created | Cannot be created |
| Connectors | Can be attached | Cannot be attached |
| Rosetta Stone mappings | Derived via attribute lineage | Uses explicit mappings from the dataset |
### Creating a view dataset
You create a view dataset by passing `create_as_view: true` when executing a query through the [SDK](/guides/sdk/executing-queries#creating-a-view-dataset) or API. The resulting dataset appears in your dataset catalog like any other dataset, but its NQL is inlined at compile time rather than being materialized into a physical table.
View datasets resolve their dependencies recursively up to a maximum depth of 5 levels. If a view dataset references another view dataset, the platform inlines each layer until it reaches physical datasets.
## Attribute lineage
Attribute lineage refers to how Rosetta Stone attribute mappings are automatically preserved when creating materialized views. When source datasets already have attribute mappings, manually re-mapping these attributes for every derived view is redundant. The attribute lineage feature automates this preservation, maintaining data consistency across your data pipeline.
### When attribute mappings persist
Attribute mappings are automatically preserved in three scenarios:
**Direct selection of unaltered attributes.** When you select a Rosetta Stone attribute directly from a dataset or access rule without modifying its output through data functions, the attribute mapping persists. Aliasing the attribute (e.g., `SELECT email AS user_email`) does not count as altering the data—the mapping is still preserved.
**Selection from an existing materialized view.** Selecting attributes from an already-created materialized view preserves the attribute mappings. Since the source view has already maintained the integrity of its mappings, the new view inherits them.
**Comprehensive selection of unmanipulated fields.** When you select all fields or columns that constitute an attribute mapping without manipulating any of these elements, the resulting materialized view retains the original mappings.
### How lineage is implemented
The system uses several mechanisms to track and preserve attribute mappings:
**Implicit usage-based remapping.** The system automatically remaps attributes based on their usage within dataset properties. For example, if a dataset includes a `Raw Email` attribute mapped through a `value` property, creating a materialized view that selects the `value` property implicitly remaps the `Raw Email` attribute to the new view.
**Identity mappings for dataset attributes.** When attributes are queried from specific datasets using the `_rosetta_stone` namespace, the resulting materialized view inherits mappings to all properties of the selected attribute, regardless of what mappings exist in the source dataset.
**Identity mappings for rosetta\_stone attributes.** Similarly, querying attributes directly from a `rosetta_stone` table and creating a materialized view results in the view inheriting mappings to all properties of the queried attribute.
### Derived mapping properties
When mappings are inherited, certain properties are inferred or copied from the original:
| Property | Behavior |
| ---------- | ------------------------------------------------------------------------------- |
| **Scope** | Set to `GLOBAL` for `rosetta_stone` attributes; otherwise inherited from source |
| **Source** | Typically set to `SYSTEM` for derived mappings |
| **Tags** | Copied from the original mapping |
| **Status** | Inferred based on context |
### Handling multiple datasets
When multiple datasets contribute to a materialized view and have overlapping attribute mappings, a prioritization logic determines which mapping is preserved. The system typically favors the first dataset encountered in the query. This ensures deterministic behavior when the same attribute appears in multiple source datasets.
***
## Related content
Step-by-step guide to creating your first materialized view
Complete reference for all CREATE MATERIALIZED VIEW options
How NQL optimizes refreshes by updating only changed data
How large queries are split into time-based segments for stability
# Query Templates
Source: https://docs.narrative.io/concepts/nql/query-templates
Reusable query patterns with configurable placeholders
A query template is a reusable NQL pattern with placeholders that users fill in at execution time. Instead of writing a new query from scratch, users select a template, provide values for the variable parts, and run it. Templates make complex queries accessible to users who don't need to understand the underlying NQL syntax.
## Why query templates exist
Query templates solve three common challenges in data collaboration:
**Ease of customization.** Not everyone who needs data is comfortable writing NQL. Templates let you build sophisticated queries once and expose only the parameters that matter—date ranges, regions, thresholds—so others can customize without touching the query logic.
**Reusability.** When you find yourself writing similar queries repeatedly with minor variations, a template captures the pattern. Change the date range or filter criteria without rewriting the joins, aggregations, or transformations each time.
**Complexity management.** Large queries with multiple joins, conditional logic, and aggregations can be difficult to understand and modify. Templates hide this complexity behind a simple interface of named placeholders, reducing the cognitive load for users who just need to run the query.
## Templates vs. saved queries
Both templates and saved queries let you reuse NQL, but they serve different purposes:
| Aspect | Saved query | Query template |
| ------------ | ------------------------------------ | ---------------------------------------- |
| Variability | Static—runs the same way every time | Dynamic—placeholders filled at execution |
| Audience | Query author who wants quick access | Anyone who needs customized results |
| Modification | Edit the full NQL to change behavior | Fill in placeholders without editing NQL |
| Use case | Personal shortcuts | Shared, parameterized queries |
A saved query is like a bookmark—it gets you back to the same place quickly. A template is like a form—it captures a structure that different people can fill in differently.
## Placeholder types
Templates use placeholders to mark the variable parts of a query. Each placeholder has a type that determines what kind of value it accepts:
**Literal values.** Replace constants like strings, numbers, booleans, or dates. Use these for parameters like date ranges, price thresholds, or status filters.
**Columns.** Replace column names in SELECT or GROUP BY clauses. Use these when users need to choose which fields to include in results.
**Filters.** Replace entire WHERE conditions. Use these when the filtering logic itself varies, not just the filter values.
**Output fields.** Replace parts of the SELECT list. Use these when the shape of the output depends on user choice.
For syntax details on each placeholder type, see the [Query Template Syntax](/nql/commands/query-templates) reference.
## When to use templates
Templates work well for:
**Standard reports with variable parameters.** A monthly sales report where the user picks the month, region, and product category. The query structure stays the same; only the filter values change.
**Queries shared with partners.** When collaborators need to run queries against your data with their own parameters, templates let them customize without accessing the full query logic.
**Recurring patterns with variable inputs.** Audience building, cohort analysis, or data exports that follow a consistent pattern but need different inputs each time.
**Materialized views with configurable parameters.** Templates can define materialized views, letting users create scheduled data products with customized filters or aggregations.
## When not to use templates
Templates aren't always the right tool:
**One-time exploratory queries.** If you're investigating data ad-hoc and won't rerun the query, just write it directly.
**Simple queries with no variable parts.** If every aspect of the query is fixed, a saved query is simpler than a template with no placeholders.
**When full NQL flexibility is needed.** Templates constrain what users can change. If the use case requires modifying joins, adding columns, or restructuring the query, direct NQL access is more appropriate.
***
## Related content
Step-by-step guide to finding, using, and creating templates
Complete reference for placeholder syntax and options
Pre-compute query results for faster access
Core NQL syntax reference
# NQL vs SQL
Source: https://docs.narrative.io/concepts/nql/sql-comparison
Understanding how NQL extends and differs from standard SQL
If you know SQL, you already know most of NQL. This page explains what's the same, what's different, and what NQL adds for data collaboration scenarios.
## What stays the same
NQL uses standard SQL syntax for core query operations. If you've written SQL queries before, these patterns work exactly as you'd expect:
### SELECT statements
```sql theme={null}
SELECT
column1,
column2,
UPPER(column3) AS transformed
FROM table_reference
WHERE condition
ORDER BY column1 DESC
LIMIT 100
```
### Aggregations and grouping
```sql theme={null}
SELECT
category,
COUNT(1) AS count,
SUM(amount) AS total,
AVG(score) AS average
FROM table_reference
GROUP BY category
HAVING COUNT(1) > 10
```
### Joins
```sql theme={null}
SELECT t1.column1, t2.column2
FROM table1 t1
INNER JOIN table2 t2 ON t1.id = t2.foreign_id
LEFT JOIN table3 t3 ON t1.id = t3.ref_id
```
### Subqueries and CTEs
```sql theme={null}
WITH summary AS (
SELECT category, SUM(amount) AS total
FROM sales
GROUP BY category
)
SELECT category, total FROM summary WHERE total > 1000
```
### INSERT, UPDATE, and DELETE
NQL supports the standard DML statements. `INSERT` accepts both `VALUES` and `SELECT` forms; `UPDATE` and `DELETE` take an optional `WHERE` clause:
```sql theme={null}
INSERT INTO company_data."123" (user_id, email)
VALUES (42, 'alice@example.com')
INSERT INTO company_data."456" (user_id, email)
SELECT user_id, email FROM company_data."123" WHERE is_active = TRUE
UPDATE company_data."123" SET status = 'archived' WHERE user_id = 42
DELETE FROM company_data."123" WHERE email IS NULL
```
See [INSERT](/nql/commands/insert), [UPDATE](/nql/commands/update), and [DELETE](/nql/commands/delete) for full references.
### Standard functions
Most SQL functions work as expected:
* String: `UPPER`, `LOWER`, `CONCAT`, `SUBSTRING`, `TRIM`
* Numeric: `ABS`, `ROUND`, `FLOOR`, `CEIL`
* Date: `CURRENT_DATE`, `DATE_TRUNC`, `EXTRACT`
* Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`
***
## What's different
### Table references use dataset IDs or names
In traditional SQL, you reference tables by name. In NQL, you reference datasets within the `company_data` schema by either their numeric ID or their dataset name:
```sql Standard SQL theme={null}
SELECT user_id, email FROM users
SELECT event_type, timestamp FROM analytics.events
```
```sql NQL (by name) theme={null}
SELECT user_id, email FROM company_data.my_users
SELECT event_type, timestamp FROM company_data.web_events
```
```sql NQL (by ID) theme={null}
SELECT user_id, email FROM company_data."123"
SELECT event_type, timestamp FROM company_data."456"
```
Numeric IDs must be quoted because they're numeric. Dataset names don't need quoting unless the name is a reserved keyword or contains special characters. The `company_data` schema contains your datasets.
Queries built with Data Studio's Query Builder render the FROM clause using dataset names by default, so the NQL editor view shows readable references like `company_data.my_users`. Numeric IDs are still valid everywhere, and Narrative I/O uses them internally where a stable identifier is required — for example, the NQL persisted with an [access rule](/concepts/primitives/access-rules) is always ID-qualified, since dataset names can be reused after a dataset is archived.
### Special data sources
NQL provides access to shared resources that don't exist in traditional databases:
| Source | Purpose |
| ----------------------------- | ----------------------------------- |
| `narrative.rosetta_stone` | Identity resolution across datasets |
| `provider_slug."access_rule"` | Data shared via access rules |
```sql theme={null}
-- Identity resolution data
SELECT unique_id, event_timestamp
FROM narrative.rosetta_stone
-- Data from another company's access rule
SELECT user_id, activity_type, calories
FROM partner_company."fitness_data"
```
### Price filtering
Every dataset includes a `_price_cpm_usd` column representing the cost per 1,000 rows. This doesn't exist in traditional databases:
```sql theme={null}
-- Only get data priced at $1 or less per 1000 rows
SELECT user_id, email, created_at
FROM company_data."123"
WHERE _price_cpm_usd <= 1.00
```
***
## What NQL adds
### Budget controls
NQL includes budget clauses to control data spending—a concept that doesn't exist in traditional SQL:
```sql theme={null}
CREATE MATERIALIZED VIEW "my_data"
AS (SELECT user_id, email, created_at FROM company_data."123")
BUDGET 50 USD
-- Recurring budget limits
LIMIT 100 USD PER CALENDAR_MONTH
LIMIT 10 USD PER CALENDAR_DAY
```
### Materialized views with options
NQL's `CREATE MATERIALIZED VIEW` includes scheduling, partitioning, and metadata options beyond what most databases offer:
```sql theme={null}
CREATE MATERIALIZED VIEW "daily_summary"
REFRESH_SCHEDULE = '@daily'
DISPLAY_NAME = 'Daily Summary Report'
DESCRIPTION = 'Aggregated daily metrics'
EXPIRE = 'P30D'
TAGS = ('analytics', 'daily')
PARTITIONED_BY event_date DAY
AS (
SELECT date, COUNT(1) as events
FROM company_data."123"
GROUP BY date
)
BUDGET 25 USD
```
### QUALIFY clause
While some databases support `QUALIFY`, it's not part of standard SQL. NQL includes it for filtering on window function results:
```sql theme={null}
-- Keep only the most recent record per user
SELECT user_id, email, last_seen
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
Without `QUALIFY`, you'd need a subquery:
```sql theme={null}
-- Equivalent without QUALIFY (more verbose)
SELECT user_id, email, last_seen
FROM (
SELECT
user_id, email, last_seen,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY last_seen DESC) AS rn
FROM company_data."123"
) sub
WHERE rn = 1
```
### DELTA tables
Query only changed records since the last execution—useful for incremental processing:
```sql theme={null}
SELECT user_id, email, updated_at
FROM DELTA(TABLE company_data."123")
```
This capability requires the platform to track changes, which isn't standard SQL functionality.
### Rosetta Stone integration
Access identity resolution through special columns:
```sql theme={null}
SELECT
company_data."1"._rosetta_stone.unique_id,
company_data."1".user_attribute
FROM company_data."1"
```
### Narrative-specific functions
Functions designed for data collaboration scenarios:
```sql theme={null}
SELECT
NORMALIZE_EMAIL(email) AS clean_email,
NORMALIZE_PHONE('E164', phone, 'US') AS clean_phone,
HASH(user_id, email) AS identity_hash,
UNIVERSE_SAMPLE(user_id, 0.1) AS in_sample
FROM company_data."123"
```
***
## SQL features not supported
Some SQL features aren't available in NQL:
| Feature | Status | Alternative |
| ---------------------- | --------------- | -------------------------------------------------------- |
| `SELECT *`, `COUNT(*)` | Not supported | [List columns explicitly](/nql/general/explicit-columns) |
| `CREATE TABLE` | Not supported | Use `CREATE MATERIALIZED VIEW` |
| `CREATE INDEX` | Not supported | Partitioning provides similar benefits |
| Stored procedures | Not supported | — |
| Transactions | Not supported | Each query is atomic |
| `UNION` | Limited support | Some restrictions apply |
***
## Dialect differences
NQL is transpiled to different database engines (Snowflake, Spark). The transpiler handles dialect differences, but awareness helps when debugging:
### Date functions
```sql NQL theme={null}
-- NQL uses consistent syntax
WHERE event_date >= CURRENT_DATE - INTERVAL '30' DAY
```
```sql Snowflake (transpiled) theme={null}
WHERE event_date >= DATEADD(DAY, -30, CURRENT_DATE())
```
```sql Spark (transpiled) theme={null}
WHERE event_date >= DATE_SUB(CURRENT_DATE(), 30)
```
### NULL handling
NQL follows standard SQL NULL semantics, but the underlying engine may have subtle differences. The transpiler normalizes most cases.
***
## Migration tips
### Coming from PostgreSQL/MySQL
1. Reference datasets through the `company_data` schema, by name or ID: `users` → `company_data.users` or `company_data."123"`
2. Add budget clauses to materialized views
3. Consider `_price_cpm_usd` filtering for cost control
### Coming from Snowflake/BigQuery
1. Dataset references live under the `company_data` schema and can use the dataset name or numeric ID
2. Most functions work the same
3. `QUALIFY` is fully supported
4. Window functions work as expected
### Coming from Spark SQL
1. Similar syntax for complex types (arrays, structs, maps)
2. `UNNEST` and `LATERAL` joins work as expected
3. UDFs are replaced with Narrative-specific functions
***
## Related content
Why Narrative created a purpose-built query language
Complete query structure and grammar
Hands-on tutorial to get started
All available functions
# Type System
Source: https://docs.narrative.io/concepts/nql/type-system
How NQL handles types across different database engines
NQL's type system bridges the gap between your queries and multiple underlying database engines. Understanding how types work helps you write portable queries and avoid common pitfalls.
## Why NQL needs its own type system
Different database engines handle types differently:
* **Snowflake** uses `NUMBER`, `VARCHAR`, `VARIANT` for semi-structured data
* **Spark** uses `LONG`, `STRING`, and nested types with specific semantics
* **BigQuery** uses `INT64`, `STRING`, and `STRUCT`/`ARRAY` with its own rules
NQL provides a consistent type system that the control plane maps to the appropriate native types during transpilation. You write one query, and it works correctly regardless of where your data lives.
## Primitive types
NQL's primitive types map to standard database types across all supported engines:
| NQL Type | Snowflake | Spark | Description |
| -------------------- | --------------- | ----------- | --------------------- |
| `STRING` / `VARCHAR` | `VARCHAR` | `STRING` | Variable-length text |
| `BOOLEAN` | `BOOLEAN` | `BOOLEAN` | True/false |
| `LONG` / `BIGINT` | `NUMBER` | `BIGINT` | 64-bit integer |
| `DOUBLE` | `FLOAT` | `DOUBLE` | 64-bit floating point |
| `DECIMAL` | `NUMBER` | `DECIMAL` | Arbitrary precision |
| `DATE` | `DATE` | `DATE` | Calendar date |
| `TIMESTAMP` | `TIMESTAMP_NTZ` | `TIMESTAMP` | Date and time |
### When precision matters
For financial or measurement data where exact precision is required, use `DECIMAL` instead of `DOUBLE`:
```sql theme={null}
-- Floating-point: may have rounding errors
SELECT 0.1 + 0.2 AS float_result -- Could be 0.30000000000000004
-- Decimal: exact precision
SELECT CAST(0.1 AS DECIMAL) + CAST(0.2 AS DECIMAL) AS decimal_result -- Exactly 0.3
```
***
## Complex types
Data collaboration often involves hierarchical and nested data structures. NQL supports three complex types that work consistently across engines.
### Arrays
Arrays store ordered collections of same-type elements. They're useful for:
* Lists of identifiers
* Tags or categories
* Time-series values
```sql theme={null}
-- Creating arrays
SELECT ARRAY('email', 'phone', 'cookie') AS id_types
-- Accessing elements (0-based indexing)
SELECT identifiers[0] AS first_id FROM company_data."123"
-- Getting array size
SELECT SIZE(tags) AS tag_count FROM company_data."123"
```
**Why arrays exist**: Many identity resolution scenarios involve multiple identifiers per record. Arrays let you store and query these without flattening to separate rows.
### Structs
Structs group named fields with potentially different types—like a row within a row. They're useful for:
* Nested attributes
* Composite identifiers
* Grouped metadata
```sql theme={null}
-- Creating structs
SELECT NAMED_STRUCT('type', 'email', 'value', 'user@example.com') AS identifier
-- Accessing fields
SELECT person.name, person.age FROM company_data."123"
-- Deeply nested access
SELECT user.address.city FROM company_data."123"
```
**Why structs exist**: Real-world data is hierarchical. An identity might have a type and value; an address might have street, city, and postal code. Structs preserve this structure without requiring separate columns.
### Maps
Maps store key-value pairs where all keys share a type and all values share a type. They're useful for:
* Dynamic properties
* Metadata with variable keys
* Key-value attributes
```sql theme={null}
-- Accessing map values
SELECT ELEMENT_AT(properties, 'source') AS source FROM company_data."123"
```
**Why maps exist**: Some data has dynamic or sparse attributes. Rather than creating columns for every possible property, maps store only the properties that exist for each record.
***
## Nested types
Complex types can be nested to represent hierarchical data:
```sql theme={null}
-- Array of structs (common for identifiers)
ARRAY>
-- Struct with nested array
STRUCT>
-- Map with struct values
MAP>
```
### Accessing nested data
Use dot notation for struct fields and bracket notation for arrays:
```sql theme={null}
SELECT
-- Array of structs
identifiers[0].type AS first_type,
identifiers[0].value AS first_value,
-- Nested struct
user.preferences.language AS user_language,
-- Map within struct
ELEMENT_AT(user.metadata, 'source') AS source
FROM company_data."123"
```
***
## Type coercion
NQL automatically converts types in certain contexts to reduce the need for explicit casting.
### Implicit coercion
| Context | Behavior |
| -------------------- | ------------------------------------------------ |
| Numeric comparisons | Smaller types promoted to larger (LONG → DOUBLE) |
| String concatenation | Non-strings converted to strings |
| Boolean context | Some conversions allowed |
```sql theme={null}
-- Numeric promotion
SELECT 1 + 1.5 -- LONG + DOUBLE = DOUBLE
-- String concatenation
SELECT 'User #' || 123 -- Number converted to string
```
### When to use explicit CAST
Use `CAST` when:
* Converting between incompatible types
* Ensuring specific precision
* Documenting intent clearly
```sql theme={null}
-- Parse string as number
WHERE CAST(string_column AS LONG) > 100
-- Ensure decimal precision
SELECT CAST(amount AS DECIMAL) * CAST(rate AS DECIMAL)
-- Convert timestamp to date
SELECT CAST(event_timestamp AS DATE) AS event_date
```
***
## NULL handling
NULL represents missing or unknown data. Understanding NULL behavior prevents subtle bugs.
### NULL in comparisons
NULL is not equal to anything, including itself:
```sql theme={null}
-- These all return NULL, not TRUE or FALSE
SELECT NULL = NULL -- NULL
SELECT NULL <> NULL -- NULL
SELECT NULL = 'value' -- NULL
```
Use `IS NULL` and `IS NOT NULL` for NULL checks:
```sql theme={null}
WHERE email IS NOT NULL
WHERE phone IS NULL
```
### NULL in operations
Operations involving NULL typically return NULL:
```sql theme={null}
SELECT NULL + 1 -- NULL
SELECT CONCAT('Hi ', NULL) -- NULL (in most engines)
```
### NULL-safe comparisons
Use `IS NOT DISTINCT FROM` for NULL-safe equality:
```sql theme={null}
-- Returns TRUE when both are NULL
WHERE a IS NOT DISTINCT FROM b
```
This is especially useful in MERGE conditions:
```sql theme={null}
MERGE ON target.id IS NOT DISTINCT FROM source.id
```
***
## Type inference
NQL infers types from context when possible:
```sql theme={null}
-- Literal type inference
SELECT 'text' -- STRING
SELECT 123 -- LONG
SELECT 123.45 -- DOUBLE
SELECT true -- BOOLEAN
SELECT DATE '2024-01-01' -- DATE
```
### ARRAY type inference
Array literals infer element type from contents:
```sql theme={null}
SELECT ARRAY(1, 2, 3) -- ARRAY
SELECT ARRAY('a', 'b', 'c') -- ARRAY
```
***
## Cross-engine considerations
While NQL abstracts type differences, some edge cases exist:
### Timestamp precision
Different engines support different timestamp precision. NQL uses millisecond precision as the common denominator.
### String collation
String comparison and sorting may differ slightly between engines. For consistent behavior, normalize strings (lowercase, trim) before comparison.
### Numeric overflow
Large numbers may overflow differently across engines. For critical calculations, consider using DECIMAL with explicit precision.
***
## Troubleshooting type errors
### UnsupportedTypeError
This error occurs when an operation doesn't support the given types:
```sql theme={null}
-- Error: Can't compare STRING to LONG directly
WHERE string_column = 123
```
**Fix**: Use explicit CAST:
```sql theme={null}
WHERE CAST(string_column AS LONG) = 123
-- or
WHERE string_column = CAST(123 AS STRING)
```
### Type mismatch in arrays
All array elements must have the same type:
```sql theme={null}
-- Error: Mixed types in array
SELECT ARRAY(1, 'two', 3)
```
**Fix**: Ensure consistent types or cast explicitly:
```sql theme={null}
SELECT ARRAY(CAST(1 AS STRING), 'two', CAST(3 AS STRING))
```
### NULL type resolution
NULL by itself has no type. In some contexts, you need to cast NULL:
```sql theme={null}
-- May cause issues in some contexts
SELECT NULL
-- Explicit typed NULL
SELECT CAST(NULL AS STRING)
```
***
## Related content
Complete type reference with syntax
How NQL types compare to standard SQL
Type conversion and manipulation functions
Common type-related errors and solutions
# Access Rules
Source: https://docs.narrative.io/concepts/primitives/access-rules
Control who can access your data, what they can query, and what it costs
An access rule is a set of business rules that define how other organizations can access data within the Narrative platform. Access rules are a core primitive that give data owners fine-grained governance over their datasets.
## What access rules control
Access rules let you define three key aspects of data access:
**Who can access your data.** Specify which organizations can run queries against your datasets. You can grant access to specific companies, make data available to all platform participants, or restrict access to a closed group of partners.
**What data they can access.** Control which records and fields are available through filtering conditions. You might expose only certain date ranges, geographic regions, or record types to different partners.
**How much access costs.** Set pricing for data access on a per-record basis. Prices can vary by record type, buyer, or any other criteria you define. For non-commercial collaborations, set the price to zero.
## Why access rules exist
Without access rules, datasets in Narrative cannot be queried by other organizations. This is by design—data remains private by default, and you must explicitly create access rules to enable collaboration.
This approach ensures:
* **Data owners stay in control.** No one can access your data without your explicit permission
* **Flexible monetization.** Charge different rates for different data or different buyers
* **Governance compliance.** Audit exactly who has access to what data and under what terms
## How access rules work
Access rules are defined using NQL (Narrative Query Language) and are enforced at query execution time. When another organization queries data you own:
1. **Rule matching** — The control plane identifies which access rules apply to the requested data
2. **Permission verification** — The query is checked against the access rule's constraints
3. **Price calculation** — If the rule includes pricing, costs are calculated based on matching records
4. **Query execution** — Only records that pass the access rule's filters are included in results
```
┌─────────────────────────────────────────────────────────────────┐
│ Query Submitted │
└───────────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Access Rules Evaluated │
│ • Which rules match the requested datasets? │
│ • Does the requester have permission? │
│ • What are the pricing terms? │
└───────────────────────────────┬─────────────────────────────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Rules Match │ │ No Match │
│ Query Executes │ │ Query Fails │
└──────────────────┘ └──────────────────┘
```
## Common use cases
### Make data available to a specific partner
Grant a single organization access to your dataset:
```sql theme={null}
-- Access rule granting Partner Corp access to customer segments
SELECT
segment_id,
segment_name,
audience_size
FROM company."customer_segments"
```
Configure the rule to be visible only to the partner's organization, and set pricing (or `$0` for a data share).
### Set different prices for different data
Create multiple access rules with different pricing tiers:
```sql theme={null}
-- Premium access rule: recent data at higher price
SELECT
user_id,
event_type,
event_timestamp
FROM company."user_events"
WHERE event_timestamp > CURRENT_DATE - INTERVAL '30' DAY
```
```sql theme={null}
-- Standard access rule: older data at lower price
SELECT
user_id,
event_type,
event_timestamp
FROM company."user_events"
WHERE event_timestamp <= CURRENT_DATE - INTERVAL '30' DAY
```
### Restrict fields for certain buyers
Expose different columns to different partners by creating separate access rules:
```sql theme={null}
-- Full access for premium partners
SELECT
user_id,
email,
purchase_history,
lifetime_value
FROM company."customers"
```
```sql theme={null}
-- Limited access for standard partners (no PII)
SELECT
user_id,
purchase_history
FROM company."customers"
```
### Enable non-commercial data sharing
Share data at no cost for research, partnerships, or data exchanges:
```sql theme={null}
SELECT
anonymized_id,
survey_responses,
demographic_category
FROM company."research_data"
```
Set the price to `$0 CPM` when creating the rule.
## Attribute mappings
When a dataset has [Rosetta Stone mappings](/concepts/rosetta-stone/how-it-works), the API returns which normalized attributes each access rule exposes. The `mappings` array on each access rule response tells you:
* **Which attributes are mapped** — The Rosetta Stone attribute ID and name
* **Which properties are exposed** — The dot-separated paths of attribute properties that the rule's NQL query selects
For example, if a dataset column `idValue` is mapped to the `identity.value` property of an `identity` attribute, and the access rule's NQL selects `idValue`, the response includes:
```json theme={null}
{
"mappings": [
{
"attribute_id": 42,
"attribute_name": "identity",
"properties": ["id.value"]
}
]
}
```
Only properties that are both mapped **and** selected by the access rule's NQL query appear in the response. If a column is mapped to an attribute but the access rule does not select that column, the attribute is excluded. Scalar attributes (attributes without sub-properties) return an empty `properties` list.
Attribute mappings are filtered by the caller's permissions. You only see mappings for attributes your organization has viewing access to.
## Access rules and datasets
Every dataset can have multiple access rules, and a single access rule can reference multiple datasets. This flexibility supports complex governance scenarios:
* **One dataset, many rules** — Different pricing tiers, different partner access levels
* **Many datasets, one rule** — Bundle related datasets for partners who need comprehensive access
If a dataset has no access rules, it cannot be queried by other organizations. You always retain access to your own datasets regardless of access rules.
Access rules cannot be created for [view datasets](/concepts/nql/materialized-views#view-datasets). Because a view dataset stores only a query definition rather than physical data, access must be controlled on the underlying datasets that the view references.
## Pricing with access rules
Access rule pricing uses **CPM (cost per thousand)**—the cost per 1,000 records. When a query matches an access rule with pricing:
1. The number of matching records is counted
2. The total cost is calculated: `(matching_records / 1000) × CPM`
3. The buyer is charged and the data owner receives payment
Pricing is enforced at the data plane level, meaning costs are calculated based on actual query results, not estimates.
## Related content
Where your data lives and access rules are enforced
How Narrative orchestrates permissions and query coordination
The broader security architecture including access controls
Understand the attribute mappings returned by access rules
Manage access rules programmatically
Resolve HTTP 409 errors when a name is already in use
# Compute Pools
Source: https://docs.narrative.io/concepts/primitives/compute-pools
Understand how compute pools control resource allocation for query execution within a data plane
A compute pool determines the compute resources allocated to process your queries within a [data plane](/concepts/primitives/data-planes). When you execute a query, the compute pool controls how much processing power is available and whether those resources are shared with other users or dedicated to your workload.
Compute pools are one of the four dimensions of your [execution context](/concepts/primitives/execution-context), alongside data plane, database, and schema.
## Compute pool types
### Dedicated
Dedicated compute pools provide isolated resources reserved for your workloads. Your queries don't compete with other users for processing power, which results in more predictable performance.
Use dedicated compute pools when:
* Running production workloads where performance consistency matters
* Processing large or complex queries that need guaranteed resources
* Operating time-sensitive pipelines where latency must stay predictable
### Shared
Shared compute pools use pooled resources across multiple users. This is more cost-effective but means your query performance may vary depending on current platform load.
Use shared compute pools when:
* Running exploratory queries or ad-hoc analysis
* Developing and testing queries before promoting to production
* Working with smaller datasets where performance variability is acceptable
### Snowflake warehouse
On Snowflake-based data planes, each compute pool maps to a Snowflake virtual warehouse. When you [register warehouses](/guides/data-planes/snowflake-native-app-installation) through the Snowflake Native App, each warehouse becomes a compute pool on your data plane. You can register multiple warehouses to separate workloads—for example, a smaller warehouse for exploratory queries and a larger one for production pipelines.
Each Snowflake compute pool has a collaboration policy that controls which companies can use it, and one pool can be designated as the default for the data plane.
### AWS EMR
On AWS-based data planes, compute pools map to Amazon EMR Spark clusters that the data plane operator provisions, reuses across jobs targeting the same pool, and terminates when idle. Each pool has a configured size that determines the cluster's worker memory budget and vCPU count. The operator schedules NQL jobs—including `materialize-view`, `nql-forecast`, `datasets_sample`, and `datasets_execute_dml`—onto the appropriate cluster based on the compute pool selected for the workload.
Sizes mirror Snowflake's warehouse vocabulary (`x_small` through `6x_large`) and target the **same worker memory budget** as the equivalent Snowflake warehouse, so a workload that fits in a Snowflake warehouse of size N gets equivalent RAM on the EMR cluster of size N. vCPU counts won't match Snowflake because EMR uses memory-optimized instances (8 GiB per vCPU), so the same memory budget delivers fewer vCPUs.
| Size | Worker memory | vCPU (max) | Notes |
| ---------- | ------------- | ---------- | ------------------------------------------------------ |
| `x_small` | \~32 GiB | \~4 | Fixed size; rounded up from Snowflake's 16 GiB minimum |
| `small` | \~32 GiB | \~4 | Fixed size |
| `medium` | \~64 GiB | \~8 | Fixed size |
| `large` | \~128 GiB | \~16 | Fixed size |
| `x_large` | \~256 GiB | \~32 | EMR Managed Scaling |
| `2x_large` | \~512 GiB | \~64 | EMR Managed Scaling |
| `3x_large` | \~1 TiB | \~128 | EMR Managed Scaling |
| `4x_large` | \~2 TiB | \~256 | EMR Managed Scaling |
| `5x_large` | \~4 TiB | \~512 | EMR Managed Scaling |
| `6x_large` | \~8 TiB | \~1024 | EMR Managed Scaling |
Sizes `x_large` and above use EMR Managed Scaling: the cluster boots small and expands toward the maximum based on YARN load, then contracts back down when idle. Sizes `large` and below run at a fixed instance count.
#### Instance-storage (`_storage`) variants
Every size has a sibling `_storage` variant — `x_small_storage`, `small_storage`, `medium_storage`, `large_storage`, `x_large_storage`, `2x_large_storage`, `3x_large_storage`, `4x_large_storage`, `5x_large_storage`, `6x_large_storage` — with the **same worker memory budget and node counts** as its base size, but with core and task nodes running on Graviton instances with local NVMe SSD (`r7gd`/`r8gd`) instead of EBS-backed families. The master node stays on `m6g` (it does no shuffle).
Use a `_storage` variant when a job spills a large volume of shuffle or scratch data to disk and fails on an EBS-backed pool with `No space left on device`. Typical culprits are wide joins, large `GROUP BY` / `DISTINCT` aggregations, and materialized-view refreshes over very wide datasets. For jobs that are CPU- or memory-bound rather than shuffle-bound, stick with the base size — the local-NVMe families cost more per node.
You switch between the base and `_storage` tiers the same way you change any other size: update the pool's `provider.size` via [`PATCH /data-planes/{id}/compute-pools/{poolId}`](/api-reference/compute-pools/update-a-compute-pool), or create a new pool sized for the workload and pin it on the affected job or dataset.
#### Idle and job execution timeouts
EMR-backed pools expose two optional tunables that control cluster lifetime and per-job runtime. Both are set on the `aws_emr` provider when you [create](/api-reference/compute-pools/create-a-compute-pool) or [update](/api-reference/compute-pools/update-a-compute-pool) a pool, and both are validated server-side — invalid values return HTTP 400.
| Field | Range | Default on create | What it controls |
| ------------------------------- | ------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `idle_timeout_seconds` | `-1`, or `60`–`604800` (7 days) | `900` (15 minutes) | How long an EMR cluster sits idle before auto-termination. `-1` disables idle-termination entirely; the cluster runs until it is explicitly terminated or recycled. |
| `job_execution_timeout_seconds` | `60`–`604800` (7 days) | `14400` (4 hours) | Maximum time a single job's EMR step may stay in `RUNNING`. When exceeded, the operator cancels the step and fails the job with an explanatory message. |
When either field is omitted (or set to `null`) on create **or update**, the API fills in the default above before validating and persisting the pool. Explicit values — including the `-1` disabled-idle sentinel — are always preserved. This matters when you update the pool's `size` (for example, switching to a `_storage` variant): the `provider` block is replaced wholesale rather than merged, so a size-only update that omits the timeout fields still gets the standard 4h/15m defaults instead of silently dropping them. Set `idle_timeout_seconds` to `-1` when you want a long-lived cluster — for example, a pool dedicated to back-to-back batch jobs where boot latency dominates run time.
The shared Narrative compute pool is configured with a 1-hour job execution timeout and is intended for small jobs. Pools you provision for yourself get the 4-hour default on create, and can set their own (longer or shorter) limit.
## Which compute pools are available
The compute pool options available to you depend on your data plane's underlying provider:
| Provider | Available compute pools | Notes |
| -------------------------- | -------------------------- | --------------------------------------------------------------- |
| **Snowflake** | Snowflake warehouse | One compute pool per registered warehouse |
| **Narrative (shared AWS)** | Dedicated, Shared | Choose based on workload requirements |
| **Customer AWS** | AWS EMR, Dedicated, Shared | EMR-backed pools support sized Spark clusters for NQL workloads |
You select your compute pool through the [context selector](/guides/platform/using-the-context-selector) in the platform's top navigation.
## Default compute pool resolution
When a job is created without an explicit compute pool, the platform resolves one through a **four-level fallback chain**. Resolution happens at job-creation time, so a job's `compute_pool_id` is fixed before it lands in `Pending`. The first level present wins.
| Level | Source | When it applies |
| --------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. **Job-specific** | The `computePoolId` passed in the request body (or a workflow task input) | A specific job needs a non-default pool |
| 2. **Dataset default** | `dataset.computePoolConfig.defaultComputePoolId` | Dataset-scoped operations (refresh, sample, execute-DML, stats) when the request didn't pin a pool |
| 3. **Company default (per data plane)** | `companies.compute_pool_config.by_data_plane[].default_compute_pool_id` | The company has a catch-all default for the job's data plane and nothing earlier supplied a pool. Scoped per data plane because a company can own pools across multiple data planes. Covers the wider surface (model training, model inference, healthcheck) that has no dataset analog. |
| 4. **Data plane default** | `dataPlane.defaultComputePoolId` | Catch-all when nothing else resolved |
A company admin can set the level-3 default for a given data plane with:
```bash theme={null}
PUT /company/{companyId}/data-planes/{dataPlaneId}/default-compute-pool/{computePoolId}
```
and clear it with:
```bash theme={null}
DELETE /company/{companyId}/data-planes/{dataPlaneId}/default-compute-pool
```
Both endpoints require the **Company Info** write permission and return `204 No Content` on success.
Use a **dataset default** for "all my materialize-view refreshes on this dataset run on a `large` pool", and a **company default** for the broader set of operations that have no dataset to attach to — model inference, healthchecks, or non-dataset NQL workloads. The two configs are intentionally separate so each can grow operation-specific knobs without polluting the other.
## When to use each type
| Scenario | Recommended pool | Why |
| --------------------------------- | ------------------- | ------------------------------------------------------- |
| Production data pipelines | Dedicated | Predictable performance, no resource contention |
| Ad-hoc data exploration | Shared | Cost-effective for variable, low-priority workloads |
| Testing queries before production | Shared | Saves dedicated resources for production use |
| Time-sensitive audience builds | Dedicated | Guaranteed resources ensure timely completion |
| Snowflake data planes | Snowflake warehouse | Register one or more warehouses sized for your workload |
## Archiving compute pools
Deleting a compute pool is a soft delete: the pool's status changes to `archived`, the record stays in place, and it is filtered out of list responses but still resolvable by id. Archival behavior diverges by provider because each integration has different operational realities:
| Reference path | Snowflake data planes | AWS data planes |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| Pool is the data plane default | The platform re-elects another active pool as the new default (or clears the default if none remain), then archives the pool | The archive is rejected; you must clear or change the data plane default before retrying |
| Pool is set as a dataset default | The platform clears the default on every affected dataset, then archives the pool. Affected datasets fall through to the rest of the chain on their next job | The archive is rejected; you must clear the dataset defaults before retrying |
| Pool is set as a company default (per data plane) | The platform clears the per-data-plane entry on every affected company, then archives the pool. Companies fall through to the data plane default on their next job | The archive is rejected; you must clear the company defaults before retrying |
| Pool has in-flight `Pending` or `Running` jobs | Archive succeeds; jobs are handled at runtime (see below) | Archive succeeds; jobs are handled at runtime (see below) |
Snowflake users frequently mutate their warehouse list outside the platform — through the Snowflake UI, permissions, or infrastructure as code. When a backing warehouse disappears, the platform has to track reality, so the archive must succeed and dependent references are updated automatically. AWS compute pools are wholly managed inside the platform, so the API rejects an archive that would leave a dangling default and forces an explicit decision.
### Runtime impact on in-flight jobs
Neither archive flow blocks in-flight jobs at archive time. Instead, the data plane operator fails any job whose compute pool resolves to a non-`active` status on its next polling iteration. The job reports as failed with an actionable message similar to:
```text theme={null}
compute pool '' could not be resolved (it may have been archived or does not exist).
Resubmit the job with an active compute pool.
```
This is uniform across `Pending`, `Running`, and `PendingCancellation` jobs. Jobs in `PendingCancellation` are failed rather than cancelled because the missing pool is the actionable signal — a successful cancellation report would mask the root cause.
See the [Archive a compute pool](/guides/data-planes/archive-compute-pool) guide for the API workflow and the order in which to clear references.
On AWS data planes, the first compute pool you create is **not** automatically promoted to the data plane default. This is the permanent behavior: jobs targeting an AWS data plane must either pin an explicit `computePoolId`, resolve to a dataset-level default, or have a data plane default set explicitly via `PUT /data-planes/{id}/default-compute-pool/{poolId}`. The intent is that AWS workloads make a deliberate choice about where they run rather than implicitly routing through a "first pool wins" default.
On Snowflake data planes, the first compute pool you create is currently auto-promoted to the data plane default for backward compatibility while the Snowflake-side migration off implicit defaults is in flight. This behavior is temporary and will be removed once every Snowflake workload pins its compute pool explicitly.
Every newly registered company is provisioned a private, x-small AWS EMR compute pool on the Narrative data plane and that pool is set as the company-level default for the same data plane. Jobs that don't pin a pool explicitly resolve to this default through the pool resolution chain (job → dataset → company → data plane). You can rename, archive, or replace the default at any time.
### `external_id` is optional for `aws_emr` providers
When creating a compute pool with the `aws_emr` provider via `POST /data-planes/{id}/compute-pools`, you can omit `external_id` from the request body — the server fills in the trivial `{"type": "aws_emr"}` payload automatically. Supplying it explicitly still works, and the type is validated against the provider.
For the `snowflake_warehouse` provider, `external_id` is still required because it carries the warehouse name and alias that the platform uses to dispatch queries to the correct Snowflake warehouse.
## How compute pools relate to the SDK
When executing queries through the [TypeScript SDK](/guides/sdk/executing-queries), the `execution_cluster` parameter maps to the compute pool concept:
```typescript theme={null}
const result = await api.executeNql({
nql: 'SELECT _nio_id, _nio_updated_at FROM company_data."my_dataset" LIMIT 100',
data_plane_id: null,
execution_cluster: { type: 'dedicated' },
});
```
The `execution_cluster.type` accepts `'dedicated'` or `'shared'`, corresponding directly to the Dedicated and Shared compute pool types. If omitted, the data plane's default compute pool is used.
For Snowflake-based data planes, omitting `execution_cluster` uses the data plane's default compute pool (the warehouse you've designated as default).
## Related content
How data plane, compute pool, database, and schema work together
Where your data lives and is processed
Run queries programmatically with the TypeScript SDK
Transition from a single Snowflake warehouse to compute pools
Safely retire a compute pool and clear its references
# Control Plane
Source: https://docs.narrative.io/concepts/primitives/control-plane
Understand how Narrative orchestrates data collaboration across data planes
The control plane is Narrative's centralized orchestration layer. It manages metadata, enforces permissions, coordinates queries, and enables collaboration between organizations—without ever touching the raw data itself.
## What the control plane manages
The control plane is responsible for everything *about* data, rather than the data itself:
**Metadata and catalogs.** The control plane maintains the catalog of all datasets, their schemas, and their attributes. When you browse available data or search for datasets, you're querying the control plane.
**Identity and permissions.** User authentication, organization membership, and [access rules](/concepts/primitives/access-rules) all live in the control plane. When you grant another organization access to your data, that permission is recorded and enforced by the control plane.
**Query compilation and dispatch.** When you write an NQL query, the control plane parses it, validates your permissions, optimizes the execution plan, and [transpiles](/reference/glossary#transpilation) it to the native SQL dialect of your target [data plane](/concepts/primitives/data-planes). The compiled query is then placed in a [job queue](/reference/glossary#job-queue), where data plane operators pick it up for execution. See [Query Processing](/concepts/architecture/query-processing) for a detailed breakdown of this flow.
**Collaboration coordination.** The control plane enables organizations to discover and transact with each other. It maintains the marketplace, processes access requests, and tracks collaboration activity.
## Why separate control from data?
The separation between control plane and data plane is a deliberate architectural choice:
**Privacy by design.** The control plane orchestrates operations without accessing raw data. This means Narrative's infrastructure never sees your actual data when you host your own data plane—only the metadata needed to coordinate operations.
**Multi-cloud collaboration.** Organizations store data in different environments (AWS, Snowflake, on-premises). A centralized control plane can coordinate across all of these without requiring data to move between them.
**Centralized governance.** While data can be distributed across many data planes, permissions and access policies are managed in one place. This simplifies governance and provides a single audit point for who can access what.
## Single control plane, multiple data planes
Narrative operates a single control plane that coordinates across all data planes in the ecosystem. This architecture enables:
* **Cross-organization collaboration** — Organizations with data in different data planes can still collaborate, with the control plane handling the coordination
* **Consistent experience** — Regardless of where data lives, the interface for discovering, querying, and governing data remains the same
* **Unified permissions** — Access controls are managed centrally, even when data spans multiple data planes
```
┌─────────────────────────────────────────────────────┐
│ Control Plane │
│ (Metadata, Permissions, Query Coordination) │
└──────────┬──────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Data Plane A │ │ Data Plane B │
│ (Your AWS) │ │ (Narrative) │
└──────────────────┘ └──────────────────┘
```
## Related content
Control who can access your data and at what price
Learn about where your data lives and is processed
How queries are compiled, transpiled, and executed
Understand how Narrative protects your data
# Data Planes
Source: https://docs.narrative.io/concepts/primitives/data-planes
Understand where your data lives and is processed in the Narrative platform
A data plane is the infrastructure where your data physically resides and where queries execute. When you run a query or register a dataset in Narrative, the data plane determines where that data lives and where computation happens.
## Why data planes exist
Narrative separates the *control plane* (which manages metadata, permissions, and orchestration) from the *data plane* (which stores and processes actual data). This separation exists for three reasons:
**Data residency requirements.** Organizations often have strict requirements about where data can be stored—whether for regulatory compliance, data sovereignty, or internal policy. By separating the data plane, Narrative enables you to keep data in your own infrastructure while still participating in data collaboration.
**Performance optimization.** Queries execute directly in the data plane, close to where the data lives. This avoids the latency and cost of moving large datasets across networks.
**Security boundaries.** The control plane never sees your raw data. It only manages metadata and coordinates operations, while the actual data remains within the data plane's security perimeter.
## How data planes relate to the control plane
The [control plane](/concepts/primitives/control-plane) is Narrative's centralized service that orchestrates all data collaboration activities. It maintains the catalog of available datasets, enforces access rules, and coordinates queries across data planes.
When you execute a query:
1. The control plane validates your permissions and [transpiles](/reference/glossary#transpilation) your NQL to the native SQL dialect for the target data plane
2. The control plane creates a job containing the compiled SQL and adds it to a [job queue](/reference/glossary#job-queue)
3. The data plane's [operator](#the-operator-component) polls the job queue and picks up the compiled query
4. The operator executes the native SQL against the data plane's query engine (Snowflake, Spark, etc.)
5. Results flow back through the control plane to you
This architecture means you can collaborate with organizations whose data lives in completely different data planes—the control plane handles the coordination. See [Query Processing](/concepts/architecture/query-processing) for a detailed breakdown of this flow.
## Hosting options
Narrative supports two hosting models for data planes:
### Narrative-hosted data planes
Narrative operates a shared data plane that provides a turnkey experience. Your data is stored in Narrative's infrastructure, and you benefit from managed operations without needing to provision or maintain infrastructure.
This option works well when:
* You want to get started quickly without infrastructure setup
* You don't have strict data residency requirements
* You prefer a fully managed experience
### Customer-hosted data planes
You can run a data plane in your own infrastructure—whether that's your AWS account, Snowflake environment, or another supported platform. Narrative's control plane coordinates with your data plane, but your data never leaves your infrastructure.
This option works well when:
* Regulatory or compliance requirements mandate data residency
* You want to leverage existing infrastructure investments
* You need to keep data within your organization's security perimeter
* You want to process data closer to where it's already stored
Customer-hosted data planes require additional setup and configuration. Contact your Narrative representative to discuss options for your infrastructure.
## What belongs to a data plane
Several objects in Narrative are scoped to a specific data plane:
* **[Datasets](/concepts/primitives/datasets)** — The actual data you've registered lives in a data plane
* **Queries** — Query execution happens within a data plane
* **[Access rules](/concepts/primitives/access-rules)** — Rules that govern data access are enforced at the data plane level
* **Jobs** — Each job executes against a specific data plane; the Jobs list shows only jobs for the active data plane
* **[Workflows](/guides/workflows/workflow-orchestration)** — Workflows are tied to the data plane their tasks run on; the Workflows list shows only workflows for the active data plane
When working in the Narrative platform, you'll use the [context selector](/guides/platform/using-the-context-selector) to choose which data plane (and other execution settings) are active. See [Execution Context](/concepts/primitives/execution-context) for details on how data plane selection combines with compute pool, database, and schema to form your full execution context.
## The operator component
Customer-hosted data planes include an operator—a software component that bridges the control plane and your data infrastructure. The operator:
* **Polls the control plane's job queue** for work targeting your data plane
* **Executes compiled SQL** against your native database engine (Snowflake, Spark, etc.)
* **Reports results and status** back to the control plane
The operator runs within your infrastructure, meaning it has access to your data but the control plane does not. This **pull-based design** ensures:
* Your data plane can be behind a firewall with no inbound connections
* The control plane never needs database credentials or direct access
* You control the compute resources allocated to query execution
The operator handles various types of jobs beyond queries, including materialized view refreshes and dataset operations. See [Job Types](/reference/architecture/job-types) for a complete reference.
## Related content
Understand the datasets that live within data planes
How queries flow from submission through execution
Learn how Narrative orchestrates data collaboration across data planes
Reference for the different types of jobs operators execute
How data plane, compute pool, database, and schema work together
How compute resources are allocated for query execution
# Dataset Retention Policies
Source: https://docs.narrative.io/concepts/primitives/dataset-retention-policies
Understand how retention policies manage your data lifecycle and automatic cleanup
A retention policy is a set of rules that governs how long data within a dataset is kept before automatic deletion. Retention policies help organizations manage storage costs, comply with data governance requirements, and ensure data is only stored for as long as necessary.
## Why retention policies matter
**Cost management.** Data storage incurs ongoing costs. Retention policies automatically remove data you no longer need, preventing unbounded storage growth.
**Compliance.** Many regulations (GDPR, CCPA, industry-specific rules) require organizations to delete data after a certain period. Retention policies automate this requirement.
**Data hygiene.** Stale data can lead to incorrect analysis or decisions. Retention policies ensure your datasets contain only current, relevant information.
## How retention policies work
Retention policies are applied at the dataset level. Each dataset can have one or more policies that Narrative evaluates on a configurable schedule to determine which data to remove.
Each policy specifies:
* **A policy class** that determines *what* gets deleted (rows, snapshots, or the entire table)
* **An interval** that determines *when* deletion occurs, expressed as an [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) (for example, `P30D` for 30 days)
* **An enabled flag** that controls whether the policy is actively enforced
You can combine multiple policy classes on a single dataset. For example, you might use a Row TTL policy for routine data expiration alongside a Table TTL policy as a safety net to drop the dataset entirely if it becomes stale.
Retention policy behavior varies by [data plane](/concepts/primitives/data-planes). See [Data plane differences](#data-plane-differences) below for details.
## Policy classes
Narrative supports three policy classes, each targeting a different level of the data lifecycle.
### Row TTL
Performs **row-level hard deletes** based on a timestamp column in your data. Row TTL evaluates each row individually and deletes rows whose timestamp exceeds the retention interval.
Row TTL is supported on both **AWS-based** and **Snowflake-based** data planes. On AWS data planes, row deletions are executed against the dataset's Iceberg table; physical file reclamation happens later when Iceberg snapshots expire.
Row TTL uses an **event time clock**—a [Rosetta Stone](/concepts/rosetta-stone/overview) attribute that maps to a timestamp column in your dataset. If you don't specify a clock, the system defaults to the `nio_last_modified` attribute mapping when one exists. If no default mapping is available, the request fails.
Row TTL is useful when:
* You need fine-grained control over which rows expire
* Your dataset contains rows with different ages that shouldn't all expire together
* You want to enforce retention based on when events occurred, not when data was ingested
### Snapshot TTL
Deletes old [snapshots](/reference/glossary#snapshot) based on snapshot age—the time since data was ingested into the dataset. This is the original retention model and operates at the Iceberg snapshot level.
Snapshot TTL is **only available on AWS-based data planes** that use Iceberg storage.
Within a Snapshot TTL policy, you specify a **retention value** that controls behavior:
| Value | Behavior |
| --------------------- | ---------------------------------------------------------------------------------- |
| **Time-based** | Delete snapshots older than a specified duration (for example, `P90D` for 90 days) |
| **Retain everything** | Keep all snapshots indefinitely until manually deleted |
| **Expire everything** | Remove all snapshots immediately |
Common ISO 8601 duration examples:
| Duration | Meaning |
| -------- | -------- |
| `P30D` | 30 days |
| `P90D` | 90 days |
| `P6M` | 6 months |
| `P1Y` | 1 year |
### Table TTL
Drops the **entire dataset** when the table age exceeds the retention interval. This is the most aggressive policy class—instead of removing individual snapshots or rows, it deletes the dataset object itself.
Table TTL supports three clock types that determine how table age is measured:
| Clock | Measures age from |
| -------------------- | ------------------------------------------------------------------------- |
| **`created_at`** | When the dataset was created |
| **`max_event_time`** | The most recent event timestamp in the data (requires a column reference) |
| **`static_time`** | A fixed timestamp you provide (requires a column reference) |
Table TTL is useful for:
* Temporary or staging datasets that should be automatically cleaned up
* Datasets with a defined useful lifespan
* Enforcing hard data deletion deadlines for compliance
Table TTL permanently deletes the dataset, not just its data. Use this policy class with care—once the dataset is dropped, it cannot be recovered.
### Choosing the right policy class
| Scenario | Recommended policy |
| ---------------------------------------------------------------------- | ------------------- |
| Delete individual rows that have aged out based on event timestamps | Row TTL |
| Remove old ingestion batches based on when they arrived (Iceberg only) | Snapshot TTL |
| Drop a temporary or staging dataset after a fixed period | Table TTL |
| Combine routine row expiration with a hard dataset deadline | Row TTL + Table TTL |
## Retention evaluation schedule
Retention policies are not evaluated continuously. Instead, Narrative evaluates them on a configurable schedule. You can set the evaluation schedule when configuring your retention policies through the API.
Between evaluation runs, data that has exceeded its retention period remains in the dataset until the next evaluation occurs.
## Retention and dataset deletion
When you delete a dataset, the retention policy determines what happens to the underlying data:
**On AWS-based data planes**, Narrative applies a default 30-day retention period before permanent deletion. This grace period protects against accidental deletions and provides an opportunity to restore the dataset if needed.
**On Snowflake-based data planes**, data is deleted immediately when you delete a dataset. There is no grace period.
Understand which data plane your dataset resides in before deleting. Snowflake-based data planes do not offer a recovery window.
## Data plane differences
Retention policy behavior differs depending on where your data is stored:
| Behavior | AWS Data Planes | Snowflake Data Planes |
| ----------------------------- | --------------- | --------------------- |
| Default deletion grace period | 30 days | None (immediate) |
| Row TTL | Supported | Supported |
| Table TTL | Supported | Supported |
| Snapshot TTL | Supported | Not supported |
Row TTL and Table TTL work across both data plane types. Snapshot TTL operates on Iceberg snapshots, which are specific to AWS-based data planes. On AWS, Row TTL deletes rows from the dataset's Iceberg table; the underlying files are reclaimed when Iceberg snapshots expire.
## Retention policies for materialized views
[Materialized views](/concepts/nql/materialized-views) support retention policies through the `EXPIRE` clause in NQL. When creating or modifying a materialized view, you can specify how long data should be retained:
```sql theme={null}
CREATE MATERIALIZED VIEW "my_view"
EXPIRE = 'P90D'
AS
SELECT
user_id,
event_type,
event_timestamp
FROM my_dataset
```
For complete syntax details, see the [EXPIRE clause reference](/nql/commands/create-materialized-view#expire).
## Choosing a retention period
Consider these factors when setting retention policies:
**Regulatory requirements.** Check what your compliance obligations require. Some regulations mandate maximum retention periods, while others require minimum retention.
**Business needs.** How far back does your analysis typically need to go? Set retention to cover your longest reasonable lookback period plus a buffer.
**Cost sensitivity.** Longer retention means higher storage costs. Balance completeness against budget constraints.
**Data refresh patterns.** If you're regularly refreshing data (for example, daily snapshots), you may not need to retain old versions for long.
Start with a longer retention period and reduce it based on observed usage patterns. It's easier to shorten retention than to recover deleted data.
## Related content
Step-by-step guide for adding and managing retention policies from the UI
Understand how datasets store and organize your data
Learn where your data physically resides
Complete syntax for setting retention in materialized views
API reference for configuring retention policies
# Dataset Statistics
Source: https://docs.narrative.io/concepts/primitives/dataset-statistics
Understand how column-level metrics describe your data and power platform features
Dataset statistics are column-level metrics computed over the contents of a [dataset](/concepts/primitives/datasets). They provide a quantitative profile of each column—counts, bounds, distributions, and quality indicators—so you can understand what your data looks like without querying it directly.
## Why statistics matter
**Data quality assessment.** Statistics give you an immediate picture of data health. You can see how many values are null, whether numeric ranges look reasonable, and how complete each column is—all without writing a query.
**Schema understanding.** When working with a new dataset, statistics help you understand the shape of the data. Cardinality tells you whether a column has a few categories or millions of unique values. Histograms show you how values are distributed.
**Query context.** Statistics surface in the platform UI to help you make better decisions. For example, when filtering on a field in Data Studio, histogram statistics populate a dropdown with the field's actual values, so you can select from real data rather than guessing.
## Where statistics appear in the platform
Statistics are visible in two places:
* **Dataset details page** — shows column-level statistics for the full dataset
* **Dataset sample page** — shows statistics computed over the sample
They also power UI features elsewhere. In Data Studio, filtering on a field that has histogram statistics displays the histogram values in a dropdown for easy selection.
## Configuring what gets computed
You can control which statistics are computed, set refresh schedules, and add per-field overrides directly from the dataset details page. The configuration supports three levels of specificity: global defaults that apply to all fields, namespace-scoped overrides for dataset columns or Rosetta Stone attributes, and individual field-level overrides.
For step-by-step instructions, see the [configuring dataset statistics](/guides/platform/configuring-dataset-statistics) guide. For the full configuration schema and API details, see the [reference](/reference/architecture/dataset-statistics).
## What gets computed
The platform computes 12 statistics per column, grouped into three categories.
### Counts
Metrics that describe how many values exist and their uniqueness.
| Statistic | What it tells you |
| --------------------- | ----------------------------------------------------------------------------------- |
| `valueCount` | Total number of non-null values |
| `nullValueCount` | Number of null values |
| `nanValueCount` | Number of NaN (not-a-number) values—applies only to floating-point columns |
| `approxCountDistinct` | Approximate number of unique values, using a probabilistic algorithm for efficiency |
| `countDistinct` | Exact number of unique values |
### Bounds and distribution
Metrics that describe how values are distributed across the column.
| Statistic | What it tells you |
| ------------------- | ---------------------------------------------------------------- |
| `lowerBound` | Minimum value in the column |
| `upperBound` | Maximum value in the column |
| `histogram` | Frequency distribution of values across distinct buckets |
| `mean` | Average value—applies only to numeric columns |
| `standardDeviation` | Spread of values around the mean—applies only to numeric columns |
### Storage and quality
Metrics that describe the physical footprint and overall completeness of the column.
| Statistic | What it tells you |
| ------------------- | ---------------------------------------------------------------------------- |
| `columnStoredBytes` | Bytes of storage consumed by the column |
| `completeness` | Ratio of non-null values to total rows, expressed as a value between 0 and 1 |
## How data types affect available statistics
Not all statistics apply to all data types. Numeric columns support the full set of 12 statistics, while non-numeric types like strings and booleans don't have `mean` or `standardDeviation`. Complex types like arrays and objects only support basic counts and storage metrics.
For the full mapping of which statistics are available for each data type, see the [type compatibility matrix](/reference/architecture/dataset-statistics#type-compatibility-matrix) in the reference.
## Related content
How datasets store, organize, and protect your data
Set up statistics through the platform UI
Full reference for each statistic and type compatibility matrix
Create and manage datasets with the SDK
How samples cross to the control plane
# Datasets
Source: https://docs.narrative.io/concepts/primitives/datasets
Understand how datasets store, organize, and protect your data in Narrative
A dataset is a structured collection of data registered in Narrative. Think of it as a table in a database—it has a defined schema, holds rows of data, and can be queried, shared, and collaborated on. Datasets are one of the core primitives in the Narrative platform.
## What datasets are for
Datasets serve as the primary way to bring data into Narrative and make it available for collaboration:
**Storage and organization.** Datasets provide a structured container for your data. Each dataset has a defined schema that specifies what fields exist, their data types, and how they should be validated.
**Querying.** Once data is in a dataset, you can query it using [NQL](/concepts/nql/design-philosophy). Datasets are the foundation of all query operations in Narrative.
**Collaboration.** Through [access rules](/concepts/primitives/access-rules), you can grant other organizations permission to query your datasets—enabling data sharing, monetization, or joint analysis.
## How datasets are structured
### Schema
Every dataset has a schema that acts as its structural blueprint. The schema defines:
* **Field names** — The columns that exist in the dataset
* **Field types** — The data type for each field (string, number, timestamp, etc.)
* **Descriptions** — Documentation explaining what each field contains
* **Validations** — Rules that ensure data integrity when records are added
```typescript theme={null}
// Example schema definition
{
properties: {
customer_id: { type: 'string' },
event_type: { type: 'string' },
event_timestamp: { type: 'timestamptz' },
event_value: { type: 'double' },
}
}
```
Schemas are designed to be stable. While you can add new fields to a schema, changing or removing existing fields requires careful consideration to avoid breaking queries or integrations that depend on them.
### Records and snapshots
Data in a dataset is organized into records (rows) and snapshots:
* **Records** are individual data entries that conform to the dataset's schema
* **Snapshots** represent a point-in-time collection of files that were ingested together
When you upload data, the ingestion process validates each record against the schema and adds it to the dataset as part of a new snapshot.
## Adding data to datasets
Datasets support multiple ways to add data:
**Append mode.** New data is added alongside existing data. Use this for event-style data where each upload contains new records.
**Overwrite mode.** New data replaces existing data. Use this when you want to refresh the entire dataset with an updated version.
For procedural details on uploading data, see [Uploading Data](/guides/sdk/uploading-data).
## Retention policies
Datasets can have [retention policies](/concepts/primitives/dataset-retention-policies) that automatically manage data lifecycle. A retention policy defines how long data is kept before automatic deletion, helping you manage storage costs and comply with data governance requirements.
Common retention configurations include:
* **Time-based retention** — Automatically remove data older than a specified period (e.g., 90 days, 1 year)
* **Retain everything** — Keep all data indefinitely until manually deleted
For details on how retention policies work, including differences between data planes and how to configure them, see [Dataset Retention Policies](/concepts/primitives/dataset-retention-policies).
## Ownership and access
### Single-company ownership
Every dataset is owned by exactly one company. The owner has full control over:
* The dataset's schema and configuration
* Who can access the data and under what terms
* Whether to archive or delete the dataset
This ownership model ensures clear accountability and prevents ambiguity about who controls sensitive data.
### Access through access rules
By default, a dataset is private to its owner. To share data with other organizations, you create [access rules](/concepts/primitives/access-rules) that define:
* Which organizations can query the dataset
* Which fields and records they can access
* What pricing applies (if any)
This separation between ownership and access provides flexibility—you retain full control while selectively enabling collaboration.
## Where datasets live
Datasets are scoped to a specific [data plane](/concepts/primitives/data-planes). The data plane determines:
* Where the data physically resides (Narrative-hosted or your own infrastructure)
* Which query engine processes queries against the dataset
* What data residency and compliance requirements are met
When you create a dataset, you specify which data plane it belongs to. The [control plane](/concepts/primitives/control-plane) maintains metadata about the dataset—its schema, access rules, and statistics—while the actual data remains in the data plane.
## View datasets
A view dataset is a dataset backed by an NQL query rather than uploaded data. When you execute a query with the `create_as_view` option, the result is stored as a view dataset. Unlike a [materialized view](/concepts/nql/materialized-views), a view dataset does not refresh on a schedule — the stored NQL is inlined and re-evaluated at query time whenever the view dataset is referenced.
View datasets are useful when you want a reusable, queryable subset of your data without duplicating it into a separate physical dataset. The underlying NQL query can reference other datasets (including other view datasets), and the platform resolves those dependencies automatically.
### Restrictions
View datasets have specific restrictions compared to regular datasets:
* **No access rules.** You cannot create [access rules](/concepts/primitives/access-rules) on a view dataset. If you need to share the data with other organizations, create a regular dataset or materialized view instead.
* **No connections.** You cannot create [connections](/concepts/data-activation/overview) to deliver a view dataset to an external platform.
* **No forecasting.** Query cost forecasting is not available for queries against view datasets.
For the full feature comparison with materialized views — including unsupported NQL features like `MERGE ON`, `PARTITIONED_BY`, and chunking strategies — see [view dataset limitations](/concepts/nql/materialized-views#view-dataset-limitations).
### Creating a view dataset
Use the `create_as_view` option when executing an NQL query through the SDK:
```typescript theme={null}
const result = await api.executeNql({
nql: 'SELECT user_id, email, event_type FROM company_data."my_dataset" WHERE event_type = \'purchase\'',
data_plane_id: null,
create_as_view: true,
});
```
For details on executing queries with this option, see [Executing NQL Queries](/guides/sdk/executing-queries#creating-a-view-dataset).
## Datasets, materialized views, and view datasets
Narrative supports three types of data containers:
| Type | Source | Data storage | Updates |
| --------------------- | ------------------------ | ------------------------------- | ------------------------------------- |
| **Dataset** | External data you upload | Physical table | Manual uploads or automated ingestion |
| **Materialized view** | NQL query results | Physical table (cached results) | Automatic refresh on schedule |
| **View dataset** | NQL query definition | No physical storage | Re-evaluated at query time |
[Materialized views](/concepts/nql/materialized-views) are created from NQL queries and automatically refresh their contents. Regular datasets require you to explicitly add data through uploads or ingestion. View datasets store only the NQL query definition and re-evaluate it at query time — see [View datasets](#view-datasets) above.
## Related content
Configure automatic data lifecycle management
Control who can query your datasets and at what price
Understand where your datasets physically reside
Column-level metrics computed over your dataset contents
Create and manage datasets with the SDK
Resolve HTTP 409 errors when a dataset name is already in use
Resolve HTTP 400 errors when a dataset schema doesn't match a connector
# Execution Context
Source: https://docs.narrative.io/concepts/primitives/execution-context
Understand how data plane, compute pool, database, and schema combine to determine where and how platform operations run
Execution context is the combination of four settings—data plane, compute pool, database, and schema—that determines where and how your platform operations run. Every query you execute, every dataset you browse, and every audience you build runs within a specific execution context.
Think of it as answering four questions at once: *Where does my data live?* (data plane), *What resources should process it?* (compute pool), *Which database?* (database), and *Which schema?* (schema).
## The four dimensions
| Dimension | What it controls | Example values |
| ---------------- | ------------------------------------------------------------ | ----------------------------------------------- |
| **Data plane** | Where your data physically resides and where queries execute | Narrative DCP, Customer Snowflake, Customer AWS |
| **Compute pool** | The compute resources allocated to process your queries | Dedicated, Shared, Default |
| **Database** | The logical database within the data plane | `DEFAULT` |
| **Schema** | The schema within the database that scopes visible objects | `DEFAULT` |
Each dimension narrows the scope of the next. Selecting a data plane determines which compute pools are available. Selecting a database determines which schemas are available.
## How context affects the platform
Your execution context influences what you see and how operations behave across the platform:
| Feature | How context applies |
| -------------------- | -------------------------------------------------------------------------------------- |
| **NQL queries** | Queries execute in the selected data plane using the selected compute pool's resources |
| **Dataset browsing** | Only datasets registered in the selected data plane are visible |
| **Data Studio** | Query editor and results operate within the current context |
| **Audiences** | Audience creation and activation use the selected data plane and compute resources |
If you switch your data plane, you'll see a different set of datasets, and any queries you run will execute against different infrastructure.
## Supported providers
Execution context options vary depending on your data plane's underlying provider:
| Provider | Description | Available compute pools |
| -------------------------- | ------------------------------------------------- | ------------------------------- |
| **Snowflake** | Customer-hosted Snowflake data planes | Registered Snowflake warehouses |
| **Narrative (shared AWS)** | Narrative's managed data collaboration platform | Dedicated, Shared |
| **Customer AWS** | Customer-hosted data planes on AWS infrastructure | Dedicated, Shared |
See [Compute Pools](/concepts/primitives/compute-pools) for details on what each compute pool type offers.
## Default behavior
When you first load the platform, context auto-selects based on what's available to you:
* **Single data plane** — Auto-selected. No action needed.
* **Multiple data planes** — Defaults to the Narrative DCP (data collaboration platform).
* **Compute pool** — Defaults to the data plane's default compute pool. For Snowflake, this is the warehouse you've designated as default. For Narrative and Customer AWS, this is Shared.
* **Database and schema** — Default to `DEFAULT`.
For most users with a single data plane, the execution context is set automatically and requires no interaction.
## Context persistence
Your execution context is saved to your browser's local storage. When you return to the platform in a later session, your previous selections are restored automatically. This means you don't need to re-select your context each time you log in.
Because context is stored per-browser, using a different browser or clearing local storage will reset your selections to the defaults.
## Database and schema
The database and schema dimensions currently default to `DEFAULT` for all data planes. These dimensions exist to support future multi-database and multi-schema configurations, where organizations may need to scope operations to specific databases or schemas within a data plane.
For now, you can leave these at their default values.
## Related content
Where your data lives and is processed
How compute resources are allocated for query execution
Step-by-step guide to changing your execution context
# Confidence Scoring
Source: https://docs.narrative.io/concepts/rosetta-stone/confidence-scoring
How AI evaluates mapping quality and generates confidence scores
When you create mappings in Rosetta Stone, how do you know they're correct? A mapping might look reasonable but produce unexpected results with certain data values. Confidence scoring uses AI to evaluate your mappings and identify potential issues before they affect your data.
## What is a confidence score?
A confidence score is an AI-generated rating from 0-100% that indicates how likely a mapping is to produce correct results. Higher scores mean the AI found strong evidence that the mapping is accurate; lower scores indicate potential issues that warrant review.
Confidence scores differ from manual validation in important ways:
| Approach | What it checks | When it runs |
| ------------------ | -------------------------------- | ------------- |
| Manual validation | Specific test cases you define | On demand |
| Confidence scoring | Pattern analysis across all data | Automatically |
Manual validation confirms mappings work for known cases. Confidence scoring identifies issues you might not have anticipated—unusual data patterns, edge cases, or transformation logic that could fail under certain conditions.
## How scores are calculated
The AI evaluates each mapping by analyzing multiple factors:
**Column name analysis**
The AI examines whether the source column name semantically matches the target attribute. A column named `email_address` mapping to the `raw_email` attribute scores higher than a column named `field_7`.
**Data sample inspection**
The system samples actual data values and checks whether they match expected patterns for the target attribute. If the `hl7_gender` attribute expects values like `male` or `female`, but the source column contains `1` and `2`, the AI will flag this unless there's an appropriate transformation.
**Transformation logic evaluation**
When a mapping includes a transformation expression, the AI analyzes whether the logic correctly handles the conversion. It looks for:
* Missing case handling (what happens to unexpected values?)
* Type mismatches (is a string being cast to a number correctly?)
* Edge cases (null values, empty strings, special characters)
**Historical patterns**
The AI learns from mappings across the platform. If similar columns from other datasets map to the same attribute using a particular transformation, it uses this pattern to inform scoring.
## Understanding score ranges
Confidence scores fall into three tiers:
| Score range | Classification | Recommended action |
| ----------- | ----------------- | ----------------------------------------- |
| 80-100% | High confidence | Generally reliable; spot-check if desired |
| 50-79% | Medium confidence | Review the mapping and AI feedback |
| 0-49% | Low confidence | Manual review required before use |
**High confidence (80-100%)** indicates strong alignment between the source column and target attribute. The AI found consistent patterns, appropriate transformations, and no significant edge cases. These mappings are likely correct, though you should still validate critical data flows.
**Medium confidence (50-79%)** suggests the mapping is probably correct but has characteristics worth reviewing. Common reasons include:
* Partial transformation coverage (handles most but not all values)
* Column names that don't clearly indicate content
* Data patterns that vary from typical examples
**Low confidence (0-49%)** means the AI identified significant concerns. The mapping might still be correct, but requires human verification. Low scores often result from:
* Missing or incomplete transformations
* Source data that doesn't match expected patterns
* Ambiguous column names with multiple possible interpretations
## Evaluation vs. suggestions
Confidence scoring powers two distinct workflows:
**Mapping evaluation** analyzes your existing mappings. Run an evaluation to:
* Assess the overall quality of your normalizations
* Identify specific mappings that need attention
* Get AI-generated explanations for quality issues
**Mapping suggestions** proposes new mappings for columns that aren't yet normalized. The AI examines unmapped columns and recommends:
* Which attribute each column should map to
* What transformation expression to use
* Sample output showing before/after values
Both workflows use the same underlying AI analysis, but evaluation focuses on improving what exists while suggestions help you expand your normalizations.
## The confidence gradient
In the Normalized Datasets interface, confidence is visualized as a gradient bar showing the distribution of your mappings across quality tiers:
| Color | Meaning |
| ------ | -------------------------- |
| Green | High confidence mappings |
| Yellow | Medium confidence mappings |
| Red | Low confidence mappings |
| Gray | Not yet scored |
This visualization helps you quickly assess the overall health of a dataset's normalizations. A dataset with mostly green indicates well-configured mappings; significant red suggests the dataset needs attention.
## When to re-evaluate
Confidence scores can become stale when:
* You modify transformation expressions
* The source data changes significantly
* New data samples reveal patterns not present in the original evaluation
Re-run evaluations periodically, especially after schema changes or when you notice unexpected query results. The AI will incorporate new data samples and may adjust scores based on current conditions.
## Related content
Run evaluations and interpret results
Review and accept AI-generated mapping recommendations
Reference for the Normalized Datasets interface
Manual validation techniques for mappings
# How Rosetta Stone Works
Source: https://docs.narrative.io/concepts/rosetta-stone/how-it-works
The mechanics of attributes, mappings, and automatic schema normalization
Rosetta Stone normalizes data through two core primitives: **attributes** and **mappings**. Attributes define the common schema—what the normalized data looks like. Mappings translate each provider's native columns into that common schema.
## The two core primitives
### Attributes
An attribute is a standardized field definition in the common schema. Each attribute specifies:
| Property | Description |
| --------------- | ----------------------------------------------------------------------------------------- |
| **Name** | A unique identifier (e.g., `hl7_gender`, `event_timestamp`) |
| **Description** | Human-readable explanation of what the attribute represents |
| **Type** | The data type: `string`, `long`, `double`, `boolean`, `timestamptz`, `object`, or `array` |
| **Validations** | Rules that data must satisfy (as an array of validation strings) |
**Example: Unique Identifier**
The `unique_identifier` attribute captures identity data from various sources. It's defined as an object with three properties:
```json theme={null}
{
"id": 100,
"name": "unique_identifier",
"type": "object",
"properties": {
"type": {
"type": "string"
},
"value": {
"type": "string"
},
"context": {
"type": "string"
}
},
"required": ["type", "value"]
}
```
This structure allows different identity types to be represented consistently, whether the source data contains mobile advertising IDs, hashed emails, or cookie identifiers.
**Example: Gender**
The `hl7_gender` attribute normalizes gender data using the HL7 standard. It's a string type with restricted enum values:
```json theme={null}
{
"id": 200,
"name": "hl7_gender",
"type": "string",
"enum": ["male", "female", "other", "unknown"],
"description": "Gender using HL7 administrative gender codes"
}
```
### Mappings
A mapping connects a specific column in a dataset to an attribute. Each mapping includes:
| Property | Description |
| -------------------- | -------------------------------------------- |
| **Source column** | The column in the provider's dataset |
| **Target attribute** | The Rosetta Stone attribute to map to |
| **Transformation** | An optional expression to convert the data |
| **Dataset** | The specific dataset this mapping applies to |
**Example: Gender mapping**
A provider stores gender as `"M"` or `"F"` in a column called `sex`. The mapping would be:
```json theme={null}
{
"source_column": "sex",
"target_attribute": "hl7_gender",
"transformation": "CASE WHEN sex = 'M' THEN 'male' WHEN sex = 'F' THEN 'female' ELSE 'unknown' END"
}
```
The transformation expression converts the provider's format to the standard enum values.
## The normalization pipeline
Rosetta Stone normalizes data through a three-stage pipeline:
### Stage 1: Schema inference
When data is uploaded to Narrative, the system analyzes it to understand its structure:
1. **Column detection**: Identifies column names and data types
2. **Pattern recognition**: Detects common patterns (dates, identifiers, categorical data)
3. **Attribute suggestion**: Uses machine learning to suggest which attributes each column maps to
This automated analysis provides a starting point—suggestions that can be accepted, modified, or rejected.
### Stage 2: Mapping creation
Mappings are created through a combination of machine learning and human curation:
1. **Auto-generated mappings**: The system proposes mappings based on schema inference
2. **Human review**: Data owners review suggestions and refine as needed
3. **Transformation definition**: Complex mappings include transformation expressions
This iterative process ensures accuracy while minimizing manual effort. As more data flows through the system, the ML models improve their suggestions.
### AI-assisted quality evaluation
After mappings are created, you can use AI to evaluate their quality and suggest improvements:
1. **Confidence scoring**: AI analyzes each mapping and assigns a confidence score (0-100%) indicating how likely the mapping is to produce correct results
2. **Issue identification**: The system highlights potential problems with transformations, such as missing case handling or type mismatches
3. **Suggestion generation**: AI proposes new mappings for columns that aren't yet normalized
This ongoing refinement helps maintain data quality as source schemas evolve or new data patterns emerge. See [Confidence Scoring](/concepts/rosetta-stone/confidence-scoring) for details on how the AI evaluation works.
### Stage 3: Query-time translation
When you query the `narrative.rosetta_stone` table:
1. **Query analysis**: The system identifies which attributes you're requesting
2. **Dataset discovery**: Finds all datasets with mappings for those attributes
3. **Query translation**: Rewrites your query for each dataset's native schema
4. **Execution**: Runs the translated queries against source data
5. **Normalization**: Applies transformations and unions results
6. **Return**: Delivers data in the consistent, normalized format
This happens transparently—you write one query, and the platform handles the complexity of accessing multiple heterogeneous sources.
### Multiple mappings to the same attribute
When a dataset has multiple columns mapped to the same attribute, the normalization process:
1. **Evaluates each mapping** independently
2. **Collects results** into an array
3. **Expands the array** to produce one output row per value
4. **Filters NULL values** during expansion
This enables maximum data utilization—if a provider captures email addresses in multiple columns, queries receive all available values without needing to know the source schema structure.
For example, if a dataset has `email_1` and `email_2` both mapped to `raw_email`, a query selecting that attribute returns one row per non-null email address. See [Multiple columns mapped to the same attribute](/guides/rosetta-stone/edge-cases#multiple-columns-mapped-to-the-same-attribute) for detailed examples and when to use alternatives like `COALESCE`.
## Normalization examples
### Date normalization
Different providers store dates in various formats:
| Provider | Column | Sample value |
| ---------- | ------------ | ---------------------- |
| Provider A | `event_date` | `01/15/2024` |
| Provider B | `timestamp` | `2024-01-15T14:30:00Z` |
| Provider C | `dt` | `15-Jan-2024` |
All three map to the `event_timestamp` attribute. The mappings include transformations that parse each format and output ISO 8601:
```sql theme={null}
-- Provider A mapping transformation
TO_TIMESTAMP(event_date, 'MM/DD/YYYY')
-- Provider B mapping transformation
timestamp -- Already ISO 8601, no transformation needed
-- Provider C mapping transformation
TO_TIMESTAMP(dt, 'DD-Mon-YYYY')
```
When you query `event_timestamp`, you receive consistent ISO 8601 timestamps regardless of source.
### Gender normalization
Providers represent gender in many ways:
| Provider | Column | Values |
| ---------- | ------------- | -------------------- |
| Provider A | `gender` | `"male"`, `"female"` |
| Provider B | `sex` | `"M"`, `"F"` |
| Provider C | `gender_code` | `1`, `2`, `0` |
| Provider D | `gndr` | `"m"`, `"f"`, `"nb"` |
Each mapping includes a transformation to the `hl7_gender` enum:
```sql theme={null}
-- Provider A: Already matches, minimal transformation
LOWER(gender)
-- Provider B: Letter codes to words
CASE sex WHEN 'M' THEN 'male' WHEN 'F' THEN 'female' ELSE 'unknown' END
-- Provider C: Numeric codes to words
CASE gender_code WHEN 1 THEN 'male' WHEN 2 THEN 'female' ELSE 'unknown' END
-- Provider D: Handle non-binary
CASE LOWER(gndr) WHEN 'm' THEN 'male' WHEN 'f' THEN 'female' WHEN 'nb' THEN 'other' ELSE 'unknown' END
```
## Validation and quality
Mappings aren't just translations—they're quality gates. Each mapping can enforce validations:
**Type checking**: Ensures values can be cast to the target type
**Enum validation**: Confirms values match allowed enum members
**Range checking**: Verifies numeric values fall within acceptable bounds
**Pattern matching**: Validates strings match expected formats (e.g., email patterns)
When data fails validation, the system can:
* Reject the record
* Map to a default value (like `unknown` for invalid gender)
* Flag the record for review
This ensures the normalized view contains clean, consistent data.
## Related content
Understand the business problem Rosetta Stone solves
Deep dive into the type system and attribute hierarchy
How AI evaluates mapping quality
Step-by-step guide to creating mappings
# The Normalization Model
Source: https://docs.narrative.io/concepts/rosetta-stone/normalization-model
How Rosetta Stone structures attributes, types, and validations
Rosetta Stone's normalization model defines how data is structured, typed, and validated across the platform. Understanding this model helps you work effectively with attributes and create accurate mappings.
## The attribute hierarchy
Attributes exist at different scopes within the platform:
### Global attributes
Global attributes are available to all organizations on the platform. They represent standardized concepts that are commonly used across data collaboration scenarios:
* **Identity attributes**: `email_sha256`, `phone_sha256`, `unique_identifier`
* **Demographic attributes**: `hl7_gender`, `age`, `birth_year`
* **Temporal attributes**: `event_timestamp`, `nio_last_modified`
* **Geographic attributes**: `country_code`, `postal_code`, `geo_coordinates`
Global attributes are maintained by Narrative and follow industry standards where applicable (e.g., HL7 for healthcare, ISO for country codes).
### Organization attributes
Organizations can create custom attributes for concepts specific to their domain or use case. Organization attributes:
* Are visible only within your organization (and to partners you explicitly share with)
* Can extend or specialize global attributes
* Follow the same type system and validation rules
### Attribute composition
Attributes can reference other attributes, enabling complex data structures:
```json theme={null}
{
"id": 150,
"name": "hashed_identity",
"type": "object",
"properties": {
"identifier": {
"$ref": 100
},
"hash_algorithm": {
"type": "string",
"enum": ["sha256", "md5", "sha1"]
}
},
"required": ["identifier", "hash_algorithm"]
}
```
In this example, `"$ref": 100` references the `unique_identifier` attribute (ID 100). This allows reuse of standardized definitions while building domain-specific structures.
## The type system
Attributes use a type system that supports both primitive and complex data.
### Primitive types
| Type | Description | Example values |
| ------------- | -------------------------------------- | ------------------------------- |
| `string` | Text data of variable length | `"hello"`, `"user@example.com"` |
| `long` | Whole numbers (64-bit integer) | `42`, `-17`, `0` |
| `double` | Decimal numbers (64-bit float) | `3.14`, `-0.001`, `1000.0` |
| `boolean` | True/false values | `true`, `false` |
| `timestamptz` | Date and time with timezone (ISO 8601) | `2024-01-15T14:30:00Z` |
### Enum type
Enums restrict string values to a predefined set. An enum is a `string` type with an `enum` property:
```json theme={null}
{
"id": 200,
"name": "hl7_gender",
"type": "string",
"enum": ["male", "female", "other", "unknown"],
"description": "Gender using HL7 administrative gender codes"
}
```
When mapping to an enum attribute, transformations must produce one of the allowed values. Unrecognized values typically map to a fallback like `unknown`.
### Object type
Objects group related fields into a single attribute using `properties`:
```json theme={null}
{
"id": 401,
"name": "geo_coordinates",
"type": "object",
"properties": {
"latitude": {
"type": "double"
},
"longitude": {
"type": "double"
},
"accuracy_meters": {
"type": "double"
}
},
"required": ["latitude", "longitude"]
}
```
The `required` array specifies which properties must be present. When mapping to an object, the transformation must produce all required fields:
```sql theme={null}
STRUCT(
CAST(lat AS DOUBLE) AS latitude,
CAST(lon AS DOUBLE) AS longitude,
CAST(accuracy AS DOUBLE) AS accuracy_meters
)
```
### Array type
Arrays contain multiple values of the same type, specified with `items`:
```json theme={null}
{
"id": 500,
"name": "interest_categories",
"type": "array",
"items": {
"type": "string"
},
"description": "Categories of user interests"
}
```
Source data might be comma-separated:
```sql theme={null}
SPLIT(interests, ',')
```
### Reference type
References link to other attribute definitions using `$ref` with the numeric attribute ID:
```json theme={null}
{
"id": 600,
"name": "purchase_event",
"type": "object",
"properties": {
"timestamp": {
"$ref": 300
},
"user": {
"$ref": 100
},
"amount": {
"type": "double"
}
},
"required": ["timestamp", "user", "amount"]
}
```
In this example, `$ref: 300` references the `event_timestamp` attribute and `$ref: 100` references the `unique_identifier` attribute. References inherit the type, validations, and semantics of the referenced attribute.
## Validations
Validations enforce data quality at the attribute level. Validations are NQL expressions where `$this` represents the value being validated. These expressions are injected into compiled NQL queries to filter out invalid values.
### Required fields in objects
For object types, the `required` array specifies which properties must be present:
```json theme={null}
{
"id": 700,
"name": "person",
"type": "object",
"properties": {
"first_name": { "type": "string" },
"middle_name": { "type": "string" },
"last_name": { "type": "string" }
},
"required": ["first_name", "last_name"]
}
```
In this example, `middle_name` is optional because it's not in the `required` array.
### Range constraints
Numeric fields can have minimum and maximum bounds using comparison operators with `$this`:
```json theme={null}
{
"id": 201,
"name": "age",
"type": "long",
"validations": ["$this >= 0", "$this <= 150"]
}
```
Values outside the range are rejected.
### String length
Use the `LENGTH()` function to constrain string length:
```json theme={null}
{
"id": 400,
"name": "country_code",
"type": "string",
"validations": ["LENGTH($this) = 2"]
}
```
### Pattern matching
Use `LIKE` for pattern validation or combine with length checks:
```json theme={null}
{
"id": 101,
"name": "email_sha256",
"type": "string",
"is_join_key": true,
"validations": ["LENGTH($this) = 64"]
}
```
### Combining validations
Multiple validations can be combined in a single array:
```json theme={null}
{
"id": 800,
"name": "date_range",
"type": "object",
"properties": {
"start_date": { "type": "timestamptz" },
"end_date": { "type": "timestamptz" }
},
"required": ["start_date", "end_date"],
"validations": ["end_date >= start_date"]
}
```
## Schema presets
Schema presets are curated collections of attributes designed for common use cases.
### Available presets
| Preset | Description | Key attributes |
| ------------ | ------------------------- | --------------------------------------------------- |
| Demographics | Consumer demographic data | `hl7_gender`, `age`, `birth_year`, `country_code` |
| Identity | User identification | `unique_identifier`, `email_sha256`, `phone_sha256` |
| Events | Timestamped occurrences | `event_timestamp`, `event_type`, `event_properties` |
| Location | Geographic data | `geo_coordinates`, `country_code`, `postal_code` |
| Marketing | Campaign and engagement | `campaign_id`, `channel`, `conversion_timestamp` |
### Using a preset
When configuring a new dataset, you can select a preset to automatically suggest mappings for standard attributes. This accelerates the mapping process for common data types.
### Creating custom presets
Organizations can create private presets that bundle:
* A set of attributes (global or organization-specific)
* Default transformation templates
* Validation rules
Custom presets standardize mapping across teams and datasets.
## The narrative.rosetta\_stone table
The `narrative.rosetta_stone` table is a virtual table that provides unified access to all normalized data.
### How it works
When you query `narrative.rosetta_stone`:
1. The query planner identifies which attributes you're selecting
2. It finds all datasets with active mappings for those attributes
3. It rewrites queries for each dataset using the mapping transformations
4. Results are unioned and returned in the normalized format
### Querying the table
```sql theme={null}
SELECT
unique_identifier,
hl7_gender,
event_timestamp
FROM narrative.rosetta_stone
WHERE
event_timestamp > CURRENT_TIMESTAMP - INTERVAL '30' DAY
AND hl7_gender.gender = 'female'
```
This query automatically accesses all datasets with mappings for these three attributes, regardless of how the source data is structured.
## Scoping your queries
When querying Rosetta Stone, you can control which data sources are included by choosing an appropriate scope level.
### Global scope
Use `narrative.rosetta_stone` when you want to query all normalized data available to you:
* Includes data from all companies that have shared data with you
* Best for broad analysis across your entire data ecosystem
* Returns the widest possible result set
### Company scope
Use `company_data._rosetta_stone` or `._rosetta_stone` when you want data from a specific company:
* Limits results to datasets owned by that company
* Useful when you need data from a specific partner or your own organization
* Reduces the search space for faster queries
### Dataset scope
Use `company_data.._rosetta_stone` when you need to work with a specific dataset:
* Required when combining normalized attributes with non-normalized columns from the same dataset
* Gives you precise control over exactly which data is queried
* Enables joining Rosetta Stone attributes back to source-specific columns
### Choosing the right scope
| I want to... | Use this scope |
| ----------------------------------------------------- | --------------------------------------------- |
| Query all available normalized data | Global (`narrative.rosetta_stone`) |
| Query data from my company only | Company (`company_data._rosetta_stone`) |
| Query data from a specific partner | Company (`partner_slug._rosetta_stone`) |
| Join normalized and original columns from one dataset | Dataset (`company_data."123"._rosetta_stone`) |
| Control exactly which datasets contribute to results | Dataset |
For complete syntax reference, see [Rosetta Stone table references](/nql/general/syntax#rosetta-stone-table-references).
### System columns
The table includes system columns for traceability:
| Column | Description |
| ------------------------ | --------------------------- |
| `_nio_source_dataset_id` | ID of the source dataset |
| `_nio_source_row_id` | Original row identifier |
| `_nio_mapping_version` | Version of the mapping used |
## Data quality implications
Normalization through Rosetta Stone improves data quality in several ways:
**Consistency**: All data adheres to the same type system and validations, regardless of source.
**Completeness**: Missing or malformed data is flagged during mapping, enabling targeted data quality improvements.
**Comparability**: Data from different sources can be meaningfully combined because it shares a common semantic model.
**Traceability**: System columns maintain lineage back to source data.
### Handling validation failures
When data fails validation during mapping, the system can:
1. **Reject**: Exclude the record from the normalized view
2. **Default**: Map to a default value (e.g., `unknown` for invalid enums)
3. **Flag**: Include the record but mark it for review
The behavior is configured per mapping.
## Related content
Understand the normalization pipeline
Complete type system documentation
Create mappings for your data
Best practices for designing attributes
# Rosetta Stone Overview
Source: https://docs.narrative.io/concepts/rosetta-stone/overview
How Narrative enables data collaboration across organizations with different schemas
In the landscape of data management, every organization collects and stores data in its own "language"—a format shaped by internal systems, legacy decisions, and unique business requirements. This diversity creates a significant barrier to data collaboration: before two organizations can work together, someone must decode and translate each party's data into a common format.
Rosetta Stone eliminates this barrier.
## The data collaboration problem
Consider a simple field like gender. Three different data providers might store this information as:
| Provider | Column name | Values |
| ---------- | ------------- | -------------------- |
| Provider A | `gender` | `"male"`, `"female"` |
| Provider B | `sex` | `"M"`, `"F"` |
| Provider C | `gender_code` | `1`, `2` |
Without normalization, consuming data from all three providers requires:
* Understanding each provider's schema
* Writing custom ETL logic for each integration
* Maintaining these transformations as schemas evolve
* Repeating this work for every new partnership
This approach doesn't scale. As organizations add more data partners, the complexity grows exponentially.
## What Rosetta Stone is
Rosetta Stone is Narrative's universal translator for data collaboration. It takes data from providers in their native format and normalizes it to a common schema—enabling you to query, combine, and analyze data from multiple sources without writing custom integrations.
The system works through two core primitives:
* **Attributes**: Standardized field definitions that form the common schema (like `hl7_gender` with values `male`, `female`, `other`, `unknown`)
* **Mappings**: Translations that connect each provider's columns to the appropriate attributes, including any necessary transformations
When you query through Rosetta Stone, the platform automatically translates your request to each provider's native schema, executes the query, and returns normalized results.
## Real-world example: Weather data
Imagine you need weather data from multiple providers for a logistics optimization project. Each provider structures their data differently:
**Provider A (European):**
* Temperature in Celsius
* Timestamps in `DD/MM/YYYY HH:mm` format
* Wind speed in kilometers per hour
**Provider B (US-based):**
* Temperature in Fahrenheit
* Timestamps in `MM-DD-YYYY h:mm AM/PM` format
* Wind speed in miles per hour
**Provider C (Scientific):**
* Temperature in Kelvin
* Timestamps in ISO 8601 format
* Wind speed in meters per second
Without Rosetta Stone, you'd need to:
1. Learn each provider's schema
2. Write conversion logic for each field
3. Handle edge cases (what does Provider A do with negative temperatures?)
4. Maintain this code as providers update their formats
With Rosetta Stone, you simply query the normalized attributes (`temperature_celsius`, `event_timestamp`, `wind_speed_kph`), and the platform handles all translations automatically.
## The query planner analogy
Rosetta Stone functions like a sophisticated query planner that operates across organizational boundaries. When you request data:
1. You specify what you need using standardized attribute names
2. Rosetta Stone identifies which providers have the relevant data
3. The system translates your query to each provider's native format
4. Results are normalized and returned in a consistent structure
This abstraction means you can add new data providers without changing your queries—the platform handles the translation layer.
## Key benefits
**No ETL pipelines per partnership**
Traditional data collaboration requires building custom integrations for each partner. Rosetta Stone eliminates this by providing a universal translation layer.
**Schema changes handled transparently**
When a provider updates their schema, Narrative updates the mappings. Your queries continue working without modification.
**Query once, access many**
A single query against Rosetta Stone can retrieve and combine data from dozens of providers, each with their own schemas.
**Data quality enforcement**
Mappings include validations that ensure data meets quality standards before it enters the normalized view—catching issues like out-of-range values or malformed dates.
## Related content
Understand attributes, mappings, and the normalization pipeline
Explore the type system and data quality enforcement
Learn how to create mappings for your data
Hands-on tutorial for normalizing your first dataset
# API Key Permissions
Source: https://docs.narrative.io/concepts/security/api-key-permissions
Understand how scoped permissions control what API keys can do and protect your resources
API key permissions control what each key can do within the Narrative platform. By assigning specific permissions to each key, you limit its capabilities to only what's needed—reducing risk and making your integrations easier to reason about.
## Why scope API key permissions
**Principle of least privilege.** A key that can only read datasets cannot accidentally modify or delete them. Scoping permissions limits the blast radius of a compromised or misconfigured key to only the resources it was granted access to.
**Operational clarity.** When each key has a clear, narrow purpose, it's easy to understand what a key is for just by looking at its permissions. A key with `Read Datasets` and `Read Queries` is obviously for analytics, not for data ingestion or workflow management.
**Auditability.** Scoped keys make audit logs more meaningful. When an action occurs, the key's permissions tell you not just *who* performed it, but what category of operation was intended—helping you quickly identify unexpected behavior.
## How permissions work
Every API key permission is a combination of two things:
* **Access level** — What the key can do: **Read** (view and list resources) or **Write** (create, update, and delete resources)
* **Resource** — Which functional area of the platform the permission applies to, such as Datasets, Connections, or Jobs
For example, a key with `Read Datasets` permission can view dataset metadata, samples, and statistics, but cannot create new datasets or modify existing ones. Adding `Write Datasets` would grant the ability to create, update, and delete datasets as well.
## How permissions compose
API keys can hold multiple permissions. Permissions are **additive**—each permission grants additional capabilities, and there are no deny rules. A key's effective access is the union of all its assigned permissions.
If a key attempts an operation that requires a permission it doesn't have, the API returns a `401 Unauthorized` response. This applies even if the key has other permissions—each resource and access level is checked independently.
## Choosing the right scope
When deciding which permissions to assign to a key, consider:
* **Think about the key's purpose.** What specific tasks will this key perform? Start with the minimum permissions needed for those tasks and add more only if required.
* **Separate concerns across keys.** Use different keys for different systems or workflows. A key for your analytics dashboard shouldn't also have permissions to manage workflows or modify datasets.
* **Review permissions periodically.** As your integration evolves, the permissions a key needs may change. Audit your keys regularly and remove permissions that are no longer necessary.
For a complete list of all available resources and their access levels, see the [Permissions Reference](/reference/security/permissions).
## Related content
All API key permission resources and their available access levels
Create and manage API keys with scoped permissions
How Narrative protects your data through architecture and access controls
Configure API key authentication for the TypeScript SDK
# Data Pseudonymization
Source: https://docs.narrative.io/concepts/security/data-pseudonymization
How Narrative uses hashed identifiers to enable privacy-preserving data collaboration
Data collaboration often involves matching records across different organizations—connecting a customer in your database to their activity in a partner's dataset. But sharing raw email addresses or phone numbers creates privacy and compliance risks. Pseudonymization solves this problem by replacing identifiable information with hashed values that enable matching without exposing the underlying data.
## Why pseudonymization matters
When organizations collaborate on data, they need a way to identify common records without sharing sensitive information. Consider two companies that want to find overlapping customers:
* **Company A** has email addresses for their customers
* **Company B** has email addresses for their customers
* Both want to find the intersection without revealing their full customer lists
By hashing email addresses before sharing, both companies can find matches (identical hashes indicate identical emails) without either party seeing the other's raw data.
### Regulatory compliance
Privacy regulations like GDPR, CCPA, and HIPAA recognize pseudonymization as a data protection technique. While pseudonymized data is still considered personal data under these regulations, it receives favorable treatment because:
* The data cannot be attributed to a specific individual without additional information
* It reduces risk if the data is exposed or breached
* It demonstrates a commitment to data minimization principles
### Reduced breach impact
If hashed data is exposed, attackers cannot directly use it to contact or identify individuals. While hashing is not encryption and determined attackers could attempt to reverse common values, it significantly raises the barrier compared to raw PII.
***
## How hashing works
A hash function is a one-way mathematical transformation that converts input data into a fixed-length string of characters. The key properties that make hashing useful for pseudonymization:
### Deterministic
The same input always produces the same output. This is essential for matching—if two organizations hash the same email address, they get identical hashes.
```
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3 (same hash)
```
### One-way (non-reversible)
You cannot mathematically reverse a hash to recover the original input. There is no "unhash" function.
```
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3 → ??? (cannot reverse)
```
### Collision-resistant
It is computationally infeasible to find two different inputs that produce the same hash. This ensures that matching hashes truly indicate matching inputs.
### Sensitive to input changes
Even tiny changes to the input produce completely different hashes. This is why pre-formatting (lowercase, whitespace removal) is critical—`JohnDoe@gmail.com` and `johndoe@gmail.com` produce entirely different hashes.
```
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
JohnDoe@gmail.com → 8f5a8e5e9a8f5e8a9e5f8a5e9a8f5e8a9e5f8a5e9a8f5e8a9e5f8a5e9a8f5e8a (completely different)
```
***
## Supported hash algorithms
Narrative supports three widely-used hash algorithms. Each produces a different output length and has different characteristics:
| Algorithm | Output | Status | Notes |
| ----------- | ---------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- |
| **MD5** | 128-bit (32 hex chars) | Legacy | Fast, widely supported. Cryptographically broken but acceptable for non-security use cases like identifier matching. |
| **SHA-1** | 160-bit (40 hex chars) | Legacy | More secure than MD5. Also cryptographically deprecated but acceptable for identifier matching. |
| **SHA-256** | 256-bit (64 hex chars) | Recommended | Part of the SHA-2 family. Currently considered secure and is the preferred choice for new implementations. |
For maximum compatibility when matching across datasets, generate all three hash formats. Different organizations may have standardized on different algorithms.
***
## Pseudonymization vs. anonymization
These terms are often confused but have important distinctions:
| Aspect | Pseudonymization | Anonymization |
| ----------------------- | ------------------------------------ | ------------------------------------ |
| **Reversibility** | Possible with additional information | Irreversible by design |
| **Matching capability** | Yes—same input produces same output | No—cannot match records |
| **Regulatory status** | Still personal data (GDPR) | Not personal data |
| **Use case** | Data collaboration, matching | Aggregate analytics, public datasets |
Hashing is a pseudonymization technique, not anonymization. The hashed value still represents a specific individual—you just cannot determine who without additional context or resources.
### Why not anonymize?
True anonymization would prevent record matching entirely, defeating the purpose of data collaboration. Pseudonymization strikes a balance: it protects individual privacy while enabling the business value of finding common records across datasets.
***
## Security considerations
While hashing provides meaningful privacy protection, it's important to understand its limitations:
### Dictionary attacks
Common email addresses (like `john@gmail.com`) could theoretically be discovered by hashing a dictionary of likely values and comparing. This is why:
* Narrative never exposes raw hash values to unauthorized parties
* Access controls govern who can query hashed identifiers
* The platform's security model adds additional protections
### Not a substitute for access controls
Pseudonymization reduces risk but doesn't eliminate the need for proper data governance. Always combine hashing with:
* Role-based access controls
* Query logging and auditing
* Data sharing agreements
***
## Related content
Per-partner pseudonymous identifiers for secure collaboration
Step-by-step guide to formatting and hashing your data
How Narrative protects data throughout the platform
# Hashing
Source: https://docs.narrative.io/concepts/security/hashing
Understanding cryptographic hash functions and their role in data protection
Hashing is the process of transforming input data into a fixed-length output using a cryptographic hash function. In data collaboration, hashing enables organizations to share and match data without exposing sensitive information like email addresses or phone numbers.
## Why hashing matters for data collaboration
When organizations want to find overlapping customers or match records across datasets, they need a common key. But sharing raw email addresses or other personally identifiable information (PII) creates privacy and compliance risks.
Hashing solves this by:
* Converting PII into non-reversible pseudonyms
* Enabling deterministic matching (same input = same output)
* Allowing data collaboration without exposing underlying identifiers
***
## Properties of hash functions
Hash functions have specific properties that make them useful for data protection:
### Deterministic (consistent)
The same input always produces the same output, regardless of when or where the hash is computed. This enables matching—if two organizations hash the same email address, they get identical hashes.
```
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3 (always same)
```
### One-way (irreversible)
You cannot mathematically reverse a hash to recover the original input. There is no "unhash" function. This protects the underlying PII.
```
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3 → ??? (cannot reverse)
```
### Collision-resistant
Different inputs produce different outputs. While theoretical collisions exist (the output space is finite), well-designed hash functions make them computationally infeasible to find.
### Computationally efficient
Hashing billions of records is practical. The algorithms are designed to be fast while maintaining security properties.
### Sensitive to input
Even tiny changes produce completely different outputs. This is why input normalization matters—`johndoe@gmail.com` and `JohnDoe@Gmail.com` produce entirely different hashes.
***
## Common hash algorithms
Three hash algorithms are commonly used for pseudonymization in data collaboration:
| Algorithm | Output Length | Status | Example Output |
| ----------- | ------------- | ----------- | ------------------------------------------------------------------ |
| **MD5** | 32 hex chars | Legacy | `0c036b871e3c66c1724f68fd007c4718` |
| **SHA-1** | 40 hex chars | Legacy | `fafe72dfca878eb2084fb7478a44d279f7895b9b` |
| **SHA-256** | 64 hex chars | Recommended | `ed2e59c337d01185f388a4e9334d6f2e5cb29652f222afe4b692582d2e1c3fce` |
For maximum interoperability, generate all three hash formats when preparing data for collaboration. Different partners may have standardized on different algorithms.
### Algorithm considerations
* **MD5 and SHA-1** are cryptographically broken for security purposes but remain acceptable for identifier matching where collision attacks aren't a concern
* **SHA-256** (part of the SHA-2 family) is currently considered secure and is recommended for new implementations
* All three produce deterministic outputs suitable for matching
***
## Input normalization
Hash functions are sensitive to their input—any difference produces a completely different output. Before hashing, normalize your data:
### Case sensitivity
Standardize to lowercase for case-insensitive identifiers like email addresses:
```
MD5("narrative") → a936fba0061bf85904722500de008e51
MD5("NARRATIVE") → 2402a932631e086f028721943b7350c8 (different!)
MD5("Narrative") → 0c036b871e3c66c1724f68fd007c4718 (different!)
```
### Whitespace
Remove leading and trailing whitespace:
```
MD5(" johndoe@gmail.com ") → different from MD5("johndoe@gmail.com")
```
### Phone number formatting
Normalize to E.164 format before hashing:
```
Original: (415) 555-1234
Normalized: +14155551234
Then hash: SHA256("+14155551234")
```
***
## Security considerations
### Dictionary attacks
While hash functions are irreversible, attackers can create dictionaries mapping common inputs to their hash outputs. If someone has a dictionary of all possible email hashes, they can look up your hash to find the original email.
This is most concerning for inputs with limited possibilities:
* **Phone numbers:** Only \~10 billion 10-digit numbers—feasible to hash them all
* **Common emails:** Popular email patterns could be pre-computed
* **Short strings:** Limited character combinations
Hashing alone may not provide sufficient protection for high-value identifiers with limited input spaces. Consider additional protections like Narrative ID for sensitive use cases.
### Mitigation strategies
Narrative's platform adds protections beyond simple hashing:
* Access controls limit who can query hashed identifiers
* [Narrative ID](/concepts/security/narrative-id) provides per-partner encoding
* Query logging enables auditing of data access
### Not a substitute for access controls
Hashing reduces risk but doesn't eliminate the need for proper data governance. Always combine hashing with:
* Role-based access controls
* Query logging and auditing
* Data sharing agreements
***
## Related content
Broader context for pseudonymization techniques
Understanding personally identifiable information
Enhanced identifier protection
Step-by-step hashing guide
# Narrative ID
Source: https://docs.narrative.io/concepts/security/narrative-id
Per-partner pseudonymous identifiers for privacy-safe data collaboration
Data collaboration requires matching records across organizations—finding the same customer in both your database and a partner's dataset. But sharing raw identifiers creates privacy risks, and even standard hashing has limitations: if two companies hash the same email with SHA-256, they produce identical hashes that could be correlated across partnerships. Narrative ID solves this by creating partner-specific pseudonymous identifiers that enable matching without exposing underlying data or allowing cross-partner correlation.
## Why Narrative ID matters
### Beyond simple hashing
Traditional pseudonymization through hashing converts identifiers into fixed outputs:
```
johndoe@gmail.com → 06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
```
This hash is the same everywhere. If Company A and Company B both hash their customer emails, they can match records—but so could any third party with access to both datasets. The hash becomes a de facto identifier.
Narrative ID takes a different approach: the same identifier produces **different** encoded values for each partner.
```
johndoe@gmail.com → (encoded for Partner A) → NID_a8f3e2b1c9d4...
johndoe@gmail.com → (encoded for Partner B) → NID_7c2d9e5f1a8b...
```
These two Narrative IDs represent the same person but cannot be correlated without going through Narrative's translation system. This provides an additional layer of privacy protection beyond standard pseudonymization.
### Key benefits
| Aspect | Standard Hashing | Narrative ID |
| -------------------------------- | --------------------------------------- | ------------------------------------------------------- |
| **Cross-partner correlation** | Possible (same hash everywhere) | Prevented (per-partner encoding) |
| **Translation between partners** | Not possible | Supported via Narrative platform |
| **Identifier exposure risk** | Hash could leak to unauthorized parties | Encoded IDs only meaningful within their encoding space |
| **Matching capability** | Direct hash comparison | Translation-based matching |
***
## How Narrative ID works
### Secure encoding methodology
When a clear text identifier (such as an email address or hashed email) is encoded into a Narrative ID, the encoding process transforms it into a value that:
* **Conceals the original data**: The encoded value reveals nothing about the underlying identifier
* **Is deterministic within an encoding space**: The same identifier always produces the same Narrative ID for a given partner
```
Clear text identifier → Secure encoding with partner key → Narrative ID
```
### Per-partner encoding spaces
Each partner on the Narrative platform has their own **encoding space**—a unique cryptographic context for generating Narrative IDs. When an identifier is encoded for a specific partner, the result is tied to that partner's encoding space.
This means:
* **Partner A's Narrative ID** for `johndoe@gmail.com` differs from **Partner B's Narrative ID** for the same email
* Neither partner can determine the other's Narrative ID without translation
* A Narrative ID only has meaning within its encoding space
### Translation between partners
Narrative's software provides the ability to translate Narrative IDs from one partner's encoding space to another. This enables secure collaboration:
1. **Partner A** encodes their customer identifiers into their encoding space
2. **Partner A** shares data with Narrative IDs (not raw identifiers)
3. **Narrative** translates the Narrative IDs to **Partner B's** encoding space
4. **Partner B** can now match against their own Narrative IDs
The translation happens within the Narrative platform—either in the Narrative cloud or in a customer-hosted data plane—ensuring that clear text identifiers are never exposed during the collaboration.
```sql theme={null}
-- Translate Narrative IDs from Partner A's space to Partner B's space
SELECT
NARRATIVE_ID_TRANSLATE(partner_a_narrative_id, 'partner_b_key_id') AS partner_b_narrative_id,
other_columns
FROM partner_a_data
```
***
## Key concepts
### Encoding spaces
An encoding space is the cryptographic namespace associated with a specific partner. Think of it as a private room where identifiers are transformed into Narrative IDs using that partner's unique encoding key.
Key properties:
* **Isolated**: Each partner's encoding space is independent
* **Consistent**: The same identifier always maps to the same Narrative ID within an encoding space
* **Non-transferable**: A Narrative ID from one encoding space cannot be directly used in another
### Match tables
A **match table** is a pre-generated lookup table that pairs Narrative IDs with their corresponding clear text identifiers. Match tables enable partners to use Narrative IDs independently of the Narrative platform for certain workflows.
Use cases for match tables:
* Offline matching processes
* Integration with systems that cannot connect to the Narrative platform directly
* Performance optimization for high-volume lookups
Match tables are generated within the secure environment and maintain the privacy guarantees of the Narrative ID system.
***
## Security considerations
### What Narrative ID protects against
* **Identifier exposure**: Raw identifiers (emails, phone numbers) are never shared between partners
* **Cross-partner correlation**: Without translation through Narrative, partners cannot determine if they share common records
* **Unauthorized access**: Even if Narrative IDs are intercepted, they reveal nothing about the underlying individuals
### Defense in depth
Narrative ID is one layer in a comprehensive security approach:
* **Access controls** govern who can query and translate Narrative IDs
* **Audit logging** tracks all encoding and translation operations
* **Data plane isolation** keeps raw data within controlled environments
### Not a substitute for governance
While Narrative ID provides strong privacy protections, organizations should still maintain:
* Data sharing agreements with partners
* Clear policies on what data can be collaborated on
* Compliance documentation for regulatory requirements
***
## Related content
Step-by-step guide to encoding and translating identifiers
How hashing protects privacy in data collaboration
NARRATIVE\_ID\_ENCODE and NARRATIVE\_ID\_TRANSLATE reference
Platform-wide security architecture
# Personally Identifiable Information (PII)
Source: https://docs.narrative.io/concepts/security/pii
Understanding PII, regulatory definitions, and how to handle it in data collaboration
Personally Identifiable Information (PII) is data that can be used to identify a specific individual, either on its own or when combined with other information. As data collection has become ubiquitous through digital technologies, understanding and properly handling PII has become essential for compliance and trust.
## What is PII?
The National Institute of Standards and Technology (NIST) defines PII as:
> Information which can be used to distinguish or trace the identity of an individual (e.g., name, social security number, biometric records, etc.) alone, or when combined with other personal or identifying information which is linked or linkable to a specific individual (e.g., date and place of birth, mother's maiden name, etc.).
The key concept is **identifiability**—whether data can be linked back to a specific person.
***
## Common examples of PII
While no comprehensive list exists, these attributes are universally recognized as PII:
### Direct identifiers
Data that identifies an individual on its own:
* Full name
* Email address
* Phone number
* Social Security number
* Passport or driver's license number
* Physical address
### Indirect identifiers
Data that can identify individuals when combined:
* Date of birth
* Place of birth
* Gender
* Race or ethnicity
* Employer
* Job title
### Digital identifiers
Context-dependent identifiers from online activity:
* IP addresses
* Device identifiers (IDFA, GAID)
* Cookie IDs
* Precise geolocation
Whether something constitutes PII can depend on context. An IP address might not identify an individual in isolation, but combined with other data, it could. Err on the side of caution when handling potentially identifying data.
***
## Regulatory definitions
Privacy regulations worldwide define personal data similarly but with important nuances.
### GDPR (European Union)
The General Data Protection Regulation, effective since 2018, is the most comprehensive privacy framework globally:
> 'Personal data' means **any information relating to an identified or identifiable natural person** ('data subject'); an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural or social identity of that natural person.
Key points:
* Broad definition includes online identifiers
* Covers indirect identification
* Applies to EU residents regardless of where data is processed
### CCPA (California)
The California Consumer Privacy Act, effective 2020 and amended by CPRA:
> "Personal information" means **information that identifies, relates to, describes, is capable of being associated with, or could reasonably be linked, directly or indirectly**, with a particular consumer or household.
Key points:
* Includes household-level data
* Covers information "capable of being associated" with individuals
* Applies to California residents
### Other regulations
Many jurisdictions have enacted similar laws:
* Australia's Privacy Act of 1988
* UK Data Protection Act / UK GDPR
* Switzerland's Federal Act on Data Protection
* Brazil's LGPD
* Canada's PIPEDA
Each law has nuances—consult legal counsel for specific requirements.
***
## PII in data collaboration
### The challenge
Data collaboration often requires matching records across organizations—finding common customers, enriching profiles, or measuring campaign effectiveness. These use cases typically need identifiers to match on, but sharing raw PII creates legal and reputational risk.
### The solution: pseudonymization
[Pseudonymization](/concepts/security/data-pseudonymization) transforms PII into non-identifying values while preserving the ability to match records. The most common technique is [hashing](/concepts/security/hashing), which creates a deterministic but irreversible transformation.
| Original Record | Pseudonymized Record |
| ------------------------- | ----------------------- |
| `email: john@example.com` | `hashed_email: 5ab6...` |
| `phone: +14155551234` | `hashed_phone: 8f2a...` |
| `age: 36` | `age: 36` |
| `gender: male` | `gender: male` |
The pseudonymized record:
* Can still be matched to other datasets
* Doesn't expose the underlying PII
* Reduces compliance risk
### Narrative's approach
Narrative requires PII to be hashed before upload:
* Email addresses must be hashed (MD5, SHA-1, or SHA-256)
* Phone numbers must be hashed after E.164 normalization
* Raw PII is not accepted in the platform
This ensures that data collaboration can occur without PII exposure.
***
## Best practices
### Minimize collection
Only collect PII that's necessary for your specific use case. More data means more risk.
### Pseudonymize early
Hash identifiers as early as possible in your data pipeline—ideally at the point of collection.
### Maintain consent records
Document the legal basis for processing personal data, whether consent, legitimate interest, or contractual necessity.
### Implement access controls
Limit who can access PII and pseudonymized data within your organization.
### Plan for data subject requests
Be prepared to respond to access, deletion, and correction requests required by privacy regulations.
***
## Related content
How pseudonymization protects PII
Technical details of hash functions
European data protection regulation
California privacy law
Prepare your data for the platform
# Single Sign-On (SSO)
Source: https://docs.narrative.io/concepts/security/sso
Understand how SSO works with Narrative I/O and why it matters for enterprise security
Single Sign-On (SSO) allows your team to access Narrative using your organization's existing identity provider. Instead of managing separate login credentials, your team authenticates through the same system they use for other enterprise applications.
## Why use SSO
Organizations choose SSO for three main reasons:
**Simplified access management.** When employees join your organization, granting Narrative access is as simple as adding them to the appropriate group in your identity provider. When they leave, removing their IdP access immediately revokes their Narrative access. No separate password resets or account deactivations required.
**Enhanced security.** SSO integrates with your existing security policies, including multi-factor authentication requirements enforced by your identity provider. Your security team maintains centralized control over authentication standards across all enterprise applications, including Narrative.
**Streamlined user experience.** Your team uses one set of credentials for everything. No more password fatigue from managing application-specific logins—just a consistent authentication experience that matches how they access other enterprise tools.
## How SAML 2.0 authentication works
Narrative uses SAML 2.0, the industry standard for enterprise single sign-on. SAML (Security Assertion Markup Language) enables secure communication between your identity provider and Narrative.
When a user attempts to access Narrative:
1. **User initiates login** — They enter your organization's slug on the Narrative login page
2. **Redirect to your IdP** — Narrative redirects them to your identity provider (Okta, Azure AD, etc.)
3. **User authenticates** — They log in using your organization's credentials and MFA requirements
4. **Access granted** — Your IdP confirms their identity, and Narrative grants access
This flow ensures your identity provider remains the single source of truth for user authentication. Narrative never sees or stores your users' passwords.
## Supported identity providers
Narrative supports any SAML 2.0-compliant identity provider. We provide specific configuration guidance for:
* **Okta** — Full support with detailed attribute mapping
* **Microsoft Azure AD (Entra ID)** — Full support with detailed attribute mapping
* **OneLogin** — Full support with detailed attribute mapping
Using a different provider? Our support team can assist with configuration for any SAML-compliant IdP.
## Key features
| Feature | Description |
| ----------------------------- | --------------------------------------------------------- |
| **SAML 2.0** | Industry-standard secure authentication protocol |
| **Just-in-time provisioning** | Automatically create Narrative accounts on first login |
| **SSO enforcement** | Optionally require SSO for all users—no password fallback |
| **Role-based access** | Assign admin or standard roles to organization members |
| **Self-service admin portal** | Your admins manage SSO configuration and members directly |
## Availability
SSO is available to enterprise customers with qualifying contracts. Contact your Narrative account manager to confirm eligibility and provision your organization.
## Related content
Step-by-step instructions for configuring SSO with your identity provider
Manage API keys for programmatic access to Narrative
# Webhooks
Source: https://docs.narrative.io/concepts/webhooks/overview
How webhooks deliver push-based notifications for job state changes
A webhook subscription is a registration that tells Narrative to send an HTTP POST request to your URL whenever a [job](/reference/architecture/job-types) changes state. Instead of polling the jobs API to check whether something finished, you receive a notification the moment it happens.
## Why webhooks exist
Many Narrative operations are asynchronous. When you create a materialized view, refresh a dataset, or run an inference job, the platform returns a job ID and processes the work in the background. To know when the job finishes, you have two options:
**Polling** means repeatedly calling the jobs API to check the current state. This works, but it's wasteful—most requests return the same "still running" response—and introduces latency between when the job completes and when your system finds out.
**Webhooks** invert the flow. You register a URL once, and Narrative pushes an event to you the moment a job transitions to a state you care about. No wasted API calls, no delay.
Webhooks are especially valuable when you need to:
* **Trigger downstream workflows** — start a data pipeline step as soon as the previous job completes
* **Alert on failures** — notify your team immediately when a job fails, rather than discovering it during the next manual check
* **Integrate with external systems** — forward job status to Slack, PagerDuty, or your own orchestration layer
## How job webhooks work
When you create a webhook subscription, you specify:
1. **A URL** — the HTTPS endpoint that receives event payloads
2. **Filters** (optional) — which jobs you care about, by job ID, job type, or job state
When a job transitions to a matching state, Narrative sends an HTTP POST request to your URL with a JSON payload describing the event.
### Filtering
Filters let you control which events trigger a notification. You can combine them to match exactly the events you need:
| Filter | Effect |
| ----------- | ------------------------------------------------------------------------- |
| `job_ids` | Only notify for specific job IDs |
| `job_types` | Only notify for specific job types (e.g., `materialize-view`, `forecast`) |
| `states` | Only notify for specific state transitions (e.g., `completed`, `failed`) |
If you omit all filters, the subscription fires for every job state change in your company.
## How delivery works
```mermaid theme={null}
sequenceDiagram
participant J as Job System
participant W as Webhook Service
participant Y as Your Endpoint
J->>W: Job state changed
W->>W: Match against subscriptions
W->>Y: HTTP POST with event payload
Y->>W: 200 OK
```
1. A job transitions to a new state (e.g., `running` to `completed`)
2. The webhook service checks all active subscriptions for that company
3. For each matching subscription, it sends an HTTP POST to the registered URL
4. Your endpoint should respond with a `2xx` status code to acknowledge receipt
Each event includes an [idempotency key](/reference/glossary#idempotency-key) so your endpoint can detect and discard duplicate deliveries.
## Webhook lifecycle
Subscriptions have two statuses:
| Status | Description |
| ---------- | ---------------------------------------------------------- |
| `active` | The subscription is live and will receive events |
| `archived` | The subscription is disabled and no longer receives events |
When you no longer need a subscription, archive it with a `DELETE` request. Archived subscriptions are retained for audit purposes but stop generating events.
## Security
Each subscription is assigned a **shared secret** (a UUID) when it's created. This secret is returned in the creation response and should be stored securely. Use it to verify that incoming webhook requests genuinely originated from Narrative rather than a third party.
All webhook endpoints must use **HTTPS** to ensure payloads are encrypted in transit.
Narrative also supports **app webhooks** for application-level events. App webhooks use a different subscription type (`webhook_subscription_app`) and are scoped to a specific application rather than job state changes. This documentation focuses on job webhooks, which are the most common use case.
***
## Related content
Step-by-step guide to creating and managing webhook subscriptions
Complete field reference for webhook requests and responses
Poll-based alternative for monitoring job progress
All job types that can trigger webhook events
# Workflow Orchestration
Source: https://docs.narrative.io/concepts/workflows/workflow-orchestration
How workflows automate multi-step data pipelines and relate to the jobs system
A workflow is a declarative pipeline that executes multiple data operations in sequence. Instead of manually triggering each step and waiting for it to finish before starting the next, you define the full pipeline once and let the system handle orchestration.
## Why workflows exist
Workflows solve problems that arise when data pipelines involve multiple dependent steps:
**Manual orchestration is error-prone.** If you need to create a materialized view, wait for it to finish, then create a second view that reads from the first, doing this manually introduces risk. You might trigger the second step before the first completes, or forget a step entirely.
**Complex pipelines need coordination.** As pipelines grow beyond two or three steps, tracking which step is running, what's finished, and what's next becomes increasingly difficult—especially when multiple team members are involved.
**Repeatable processes should be automated.** If you're running the same sequence of operations daily or weekly, defining it once as a workflow eliminates repetitive manual work and ensures consistency.
## Workflows, tasks, and jobs
Understanding the relationship between these three concepts is key to working with the system effectively.
### The three layers
**Workflows** define *what* should happen—a named, versioned sequence of operations. A workflow is a blueprint, not an execution. You create a workflow once and trigger it many times.
**Tasks** are the individual steps within a workflow. Each task calls a specific operation (like creating a materialized view or refreshing one) with specific parameters. Tasks execute sequentially in the order they're defined.
**Jobs** are how tasks actually execute. The workflow system is an orchestration layer built *on top of* the existing [jobs system](/concepts/architecture/query-processing). When a task runs, it creates a job—the same kind of job that's created when you run a query or refresh a view manually.
### How execution flows
When you trigger a workflow, here's what happens:
1. The system creates a **run**—a specific execution instance with its own `run_id`
2. The first task starts and creates a **job** in the jobs system
3. The orchestrator waits for that job to complete
4. Once the job succeeds, the next task starts and creates its own job
5. This continues until all tasks complete or one fails
```mermaid theme={null}
flowchart LR
subgraph Workflow
direction LR
T1("Task 1 job_id_1") --> T2("Task 2 job_id_2") --> T3("Task 3 job_id_3")
end
```
Each task creates its own independent job. This means you can monitor execution at two levels:
* **Workflow level** — check the overall run status to see if the pipeline succeeded or failed
* **Job level** — inspect individual jobs for detailed timing, errors, and output
Jobs created by workflows are automatically tagged with `workflow_enqueued`. On the **Jobs** page, you can filter by the **Workflow Enqueued** status or search by `workflow_id` or `workflow_run_id` to distinguish workflow-generated jobs from manually triggered ones.
## Task dependencies and data passing
Tasks in a workflow execute sequentially—each task waits for the previous one to complete before starting. This sequential model is simple but powerful: later tasks can reference data created by earlier ones.
For example, a workflow might:
1. Create a materialized view called `active_users`
2. Create a second materialized view that queries `company_data.active_users`
The workflow guarantees that step 1 finishes (and the dataset exists) before step 2 begins. Without workflows, you'd need to poll the jobs API and build this coordination logic yourself.
### Referencing earlier outputs
When a task creates a dataset (via `CreateMaterializedViewIfNotExists`), subsequent tasks can reference it using the fully qualified path `company_data.`. The workflow's sequential execution guarantees the dataset exists by the time later tasks need it.
### Task output and workflow context
Beyond dataset references, tasks produce structured JSON output after execution — dataset IDs, snapshot IDs, row counts, and other metadata. This output enables more flexible data passing between tasks.
The **export** mechanism captures task output into a **workflow context** (`$context`), an object that accumulates data as the workflow progresses. Variable expressions (`${…}`) then inject context values or previous task output into subsequent task parameters.
For full syntax details, see the [Specification Syntax](/reference/workflows/specification-syntax#task-output) reference.
### Two approaches to data passing
Workflows support two ways to pass data between tasks:
**Dataset references** — When a task creates a dataset, later tasks query it by name (`company_data.`). This is the simplest approach and works well when tasks consume each other's data directly via NQL.
**Output and context** — When tasks need to pass metadata (dataset IDs, row counts, status information) rather than query data, use `export` and `${…}` variable expressions. This is useful for logging, conditional DML operations, or any case where a downstream task needs a value produced by an upstream task.
Most workflows use dataset references. Output and context become valuable when you need to pass specific values — like inserting a dataset ID into a log table or using a row count in a subsequent operation.
## Error handling
Workflows use **fail-fast** behavior. If any task fails:
1. The workflow immediately stops execution
2. Remaining tasks are skipped
3. The run status becomes `failed`
This design prevents cascading failures—if step 1 fails to create a dataset, there's no point in running step 2 which depends on it.
There are no automatic retries. When a task fails, you need to investigate the cause, fix the underlying issue, and trigger a new run. You can inspect the failed task's job for detailed error information.
## Supported operations
Workflows currently support these operations:
| Task | What it does |
| -------------------------------------- | ------------------------------------------------------------ |
| `CreateMaterializedViewIfNotExists` | Creates a new materialized view from an NQL query |
| `RefreshMaterializedView` | Refreshes an existing materialized view with the latest data |
| `ExecuteDml` | Executes a DML statement (INSERT, UPDATE, DELETE) |
| `RunModelInference` | Runs a model inference job |
| `CreateRosettaStoneMappingsIfNotExist` | Creates Rosetta Stone attribute mappings for a dataset |
These operations cover the most common pipeline patterns: building datasets, keeping them fresh, modifying data, running AI inference, and managing schema mappings. Direct `ExecuteNQL` (arbitrary SELECT queries) is not supported—use `CreateMaterializedViewIfNotExists` to execute a query and persist the results.
For full parameter details on each task, see the [Task Reference](/reference/workflows/tasks).
## Scheduling
Workflows can be triggered manually via the API or the platform UI, or run automatically on a schedule. Scheduled workflows use cron expressions to define when they run—hourly, daily, weekly, or on any custom cadence.
Scheduling is particularly valuable for refresh pipelines. Rather than remembering to refresh your materialized views every morning, a scheduled workflow handles it automatically and ensures the correct refresh order.
## Current limitations
The workflow system is designed for sequential, deterministic pipelines. It intentionally keeps things simple:
* **Sequential execution only** — tasks run one at a time, in order. There is no parallel task execution.
* **No conditional logic** — every task in the workflow runs. Tasks can pass data via context, but there is no if/else branching based on output values.
* **No loops** — you cannot iterate over a list of items or repeat tasks.
* **No automatic retries** — failed tasks must be investigated and rerun manually.
These constraints keep the system predictable and easy to reason about. For most data pipeline use cases—creating datasets, refreshing views, running DML—sequential execution with fail-fast error handling is sufficient.
***
## Related content
Step-by-step guide to creating, managing, and running workflows
Complete reference for all supported workflow tasks and parameters
How materialized views work—the most common operation in workflows
The compute environment where workflow tasks execute
# Cookbooks
Source: https://docs.narrative.io/cookbooks/index
Complete, working examples for real-world use cases
Cookbooks provide complete, tested solutions for common scenarios. Each recipe includes all the code and configuration you need, along with explanations of what it accomplishes and when to use it.
## NQL Patterns
Reusable query patterns for common data tasks.
Frequently used query patterns and templates
Optimized patterns for large-scale data processing
Multi-way joins and advanced data combination techniques
Enrich records with AI-generated attributes using AI\_COMPLETE
## Model Inference
AI-powered data processing patterns.
Build a pipeline that enriches records using AI inference
Process large volumes of data efficiently
Generate dataset documentation with AI
Convert schedules to CRON expressions
## Use Cases
End-to-end solutions for specific industries and scenarios.
Share and analyze retail data across partners
Enrich customer lists with age and gender data
A structured framework for choosing and measuring identity data providers
## Contributing recipes
Have a useful pattern or integration to share? Cookbooks are meant to grow with real-world usage. Contact us to contribute your own recipes.
# Data Enrichment with AI_COMPLETE
Source: https://docs.narrative.io/cookbooks/nql/ai-enrichment
Enrich records with AI-generated attributes using in-query LLM inference
This cookbook demonstrates how to use the `AI_COMPLETE` function to enrich records with AI-generated structured attributes directly within NQL. The entire inference pipeline runs inside your Snowflake data plane—your data never leaves your infrastructure.
`AI_COMPLETE` is available on **Snowflake data planes only**.
## What you will build
A materialized view that takes raw customer support tickets and enriches each record with:
* A standardized issue category
* A priority level
* A sentiment score
* Extracted product mentions
The same pattern applies to any enrichment task: product classification, lead scoring, content tagging, entity extraction, and more.
## Prerequisites
* A [Snowflake data plane](/concepts/primitives/data-planes) with AI model access configured (see setup below)
* A dataset with text content to enrich
* Familiarity with [NQL syntax](/nql/general/syntax) and [materialized views](/concepts/nql/materialized-views)
### One-time Snowflake setup
`AI_COMPLETE` runs through [Snowflake Cortex](https://docs.snowflake.com/en/guides-overview-ai-features), so the customer Snowflake account must grant Cortex access to the Narrative application and allow cross-region model invocation. A Snowflake account admin runs these statements once:
```sql theme={null}
-- Allow Cortex to route requests to models hosted in any region
ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION';
-- Grant the Cortex role to the Narrative application
GRANT DATABASE ROLE snowflake.cortex_user TO APPLICATION ;
```
Replace `` with the name of the Narrative Snowflake Native App installed in the account (for example, `NARRATIVE_DATA_COLLABORATION_HMI`).
The `GRANT DATABASE ROLE ... TO APPLICATION` statement is a temporary workaround required by current Snowflake Native App permissioning. Snowflake may change how Native Apps access Cortex in the future, at which point this setup step may change. Contact Narrative support if `AI_COMPLETE` queries begin failing with permission errors.
`CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION'` lets Cortex invoke models that are hosted outside the account's home region. Narrative recommends `ANY_REGION` because model availability varies by region. If your compliance posture requires region pinning, consult [Snowflake's cross-region inference documentation](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cross-region-inference) for the available alternatives.
To confirm the grant succeeded, run a minimal query with a literal prompt:
```sql theme={null}
CREATE MATERIALIZED VIEW "test_ai_complete" AS
WITH test_prompt AS (SELECT 'respond with ok' AS prompt)
SELECT
AI_COMPLETE(
'openai-gpt-5-nano',
test_prompt.prompt,
'{}',
'{"type": "json", "schema": {"type": "object", "properties": {"reply": {"type": "string"}}, "required": ["reply"], "additionalProperties": false}}',
TRUE
) AS model_response
FROM test_prompt
```
If this query fails with a privileges error, re-run the grant statements above.
## Step 1: Define the output schema
Before writing NQL, design the JSON Schema that describes the structured attributes you want the model to produce. This schema constrains the model's output, guaranteeing predictable, typed results.
```json theme={null}
{
"type": "json",
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "The primary issue category",
"enum": [
"billing",
"technical",
"account_access",
"feature_request",
"general_inquiry"
]
},
"priority": {
"type": "string",
"description": "Urgency level based on the issue description",
"enum": ["low", "medium", "high", "critical"]
},
"sentiment": {
"type": "number",
"description": "Customer sentiment from -1 (very negative) to 1 (very positive)"
},
"product_mentioned": {
"type": "string",
"description": "The specific product or feature referenced, or 'none' if not applicable"
}
},
"required": ["category", "priority", "sentiment", "product_mentioned"],
"additionalProperties": false
}
}
```
**Key schema design decisions:**
| Decision | Why |
| ----------------------------------- | ------------------------------------------------------------ |
| `enum` on `category` and `priority` | Restricts output to known values, preventing free-form drift |
| `number` for `sentiment` | Enables numeric filtering and aggregation downstream |
| `additionalProperties: false` | Prevents the model from adding unexpected fields |
| `description` on each field | Guides the model's interpretation of what each field means |
Keep schemas focused on the fields your downstream queries will actually use. Every additional field increases token usage and inference cost.
## Step 2: Build prompt strings in a CTE
`AI_COMPLETE` requires its `prompt` parameter to be a column reference, not a string literal. Use a Common Table Expression (CTE) to assemble prompts from your source columns.
```sql theme={null}
WITH prompts AS (
SELECT
company_data."support_tickets".ticket_id,
company_data."support_tickets".customer_id,
company_data."support_tickets".created_at,
'Analyze the following customer support ticket and extract structured attributes.'
|| ' Ticket subject: ' || company_data."support_tickets".subject
|| ' Ticket body: ' || company_data."support_tickets".body
AS prompt_text
FROM company_data."support_tickets"
WHERE company_data."support_tickets".created_at > CURRENT_TIMESTAMP - INTERVAL '7' DAY
)
SELECT prompts.ticket_id, prompts.prompt_text
FROM prompts
```
**Tips for building effective prompts:**
* Prefix each field with a label (e.g., `' Ticket subject: '`) so the model can distinguish between data elements
* Filter rows before calling `AI_COMPLETE` to avoid unnecessary inference costs
* Use `COALESCE` to handle nulls: `COALESCE(company_data."support_tickets".subject, 'No subject')`
## Step 3: Call AI\_COMPLETE
Pass the prompt column to `AI_COMPLETE` along with your schema. The response is a JSON string that you parse in the next step.
```sql theme={null}
WITH prompts AS (
SELECT
company_data."support_tickets".ticket_id,
company_data."support_tickets".customer_id,
company_data."support_tickets".created_at,
'Analyze the following customer support ticket and extract structured attributes.'
|| ' Ticket subject: ' || company_data."support_tickets".subject
|| ' Ticket body: ' || company_data."support_tickets".body
AS prompt_text
FROM company_data."support_tickets"
WHERE company_data."support_tickets".created_at > CURRENT_TIMESTAMP - INTERVAL '7' DAY
),
enriched AS (
SELECT
prompts.ticket_id,
prompts.customer_id,
prompts.created_at,
AI_COMPLETE(
'openai-gpt-5',
prompts.prompt_text,
'{"temperature": 0}',
'{"type": "json", "schema": {"type": "object", "properties": {"category": {"type": "string", "description": "The primary issue category", "enum": ["billing", "technical", "account_access", "feature_request", "general_inquiry"]}, "priority": {"type": "string", "description": "Urgency level", "enum": ["low", "medium", "high", "critical"]}, "sentiment": {"type": "number", "description": "Sentiment from -1 (very negative) to 1 (very positive)"}, "product_mentioned": {"type": "string", "description": "Product or feature referenced, or none"}}, "required": ["category", "priority", "sentiment", "product_mentioned"], "additionalProperties": false}}',
TRUE
) AS ai_response
FROM prompts
)
SELECT enriched.ticket_id, enriched.ai_response
FROM enriched
```
**Parameter choices:**
| Parameter | Value | Reason |
| ------------------ | ---------------------- | -------------------------------------------------------------------- |
| `model` | `'openai-gpt-5'` | Choose the model that best fits your accuracy and cost requirements |
| `model_parameters` | `'{"temperature": 0}'` | Temperature 0 produces deterministic, consistent classifications |
| `show_details` | `TRUE` | Returns the full `structured_output` array for reliable JSON parsing |
## Step 4: Parse the response into columns
Use `PARSE_JSON` and path notation to extract individual fields from the AI response into typed columns.
```sql theme={null}
SELECT
enriched.ticket_id,
enriched.customer_id,
enriched.created_at,
CAST(
PARSE_JSON(enriched.ai_response)['structured_output'][0]['raw_message']['category'] AS STRING
) AS category,
CAST(
PARSE_JSON(enriched.ai_response)['structured_output'][0]['raw_message']['priority'] AS STRING
) AS priority,
CAST(
PARSE_JSON(enriched.ai_response)['structured_output'][0]['raw_message']['sentiment'] AS DOUBLE
) AS sentiment,
CAST(
PARSE_JSON(enriched.ai_response)['structured_output'][0]['raw_message']['product_mentioned'] AS STRING
) AS product_mentioned
FROM enriched
```
The path `['structured_output'][0]['raw_message']` navigates to the model's schema-validated output. Each field within `raw_message` corresponds to a property in your `response_format` schema.
## Complete example
Here is the full query wrapped as a materialized view that refreshes weekly:
```sql theme={null}
CREATE MATERIALIZED VIEW "support_tickets_enriched"
DISPLAY_NAME = 'Support Tickets - AI Enriched'
DESCRIPTION = 'Tickets enriched with AI-classified category, priority, sentiment, and product mentions'
TAGS = ('ai_enrichment', 'support', 'classification')
WRITE_MODE = 'overwrite'
REFRESH_SCHEDULE = '@weekly'
AS
WITH prompts AS (
SELECT
company_data."support_tickets".ticket_id,
company_data."support_tickets".customer_id,
company_data."support_tickets".created_at,
'Analyze the following customer support ticket and extract structured attributes.'
|| ' Ticket subject: ' || COALESCE(company_data."support_tickets".subject, 'No subject')
|| ' Ticket body: ' || COALESCE(company_data."support_tickets".body, 'No body')
AS prompt_text
FROM company_data."support_tickets"
WHERE company_data."support_tickets".created_at > CURRENT_TIMESTAMP - INTERVAL '30' DAY
),
ai_results AS (
SELECT
prompts.ticket_id,
prompts.customer_id,
prompts.created_at,
AI_COMPLETE(
'openai-gpt-5',
prompts.prompt_text,
'{"temperature": 0}',
'{"type": "json", "schema": {"type": "object", "properties": {"category": {"type": "string", "description": "The primary issue category", "enum": ["billing", "technical", "account_access", "feature_request", "general_inquiry"]}, "priority": {"type": "string", "description": "Urgency level", "enum": ["low", "medium", "high", "critical"]}, "sentiment": {"type": "number", "description": "Sentiment from -1 (very negative) to 1 (very positive)"}, "product_mentioned": {"type": "string", "description": "Product or feature referenced, or none"}}, "required": ["category", "priority", "sentiment", "product_mentioned"], "additionalProperties": false}}',
TRUE
) AS ai_response
FROM prompts
)
SELECT
ai_results.ticket_id,
ai_results.customer_id,
ai_results.created_at,
CAST(
PARSE_JSON(ai_results.ai_response)['structured_output'][0]['raw_message']['category'] AS STRING
) AS category,
CAST(
PARSE_JSON(ai_results.ai_response)['structured_output'][0]['raw_message']['priority'] AS STRING
) AS priority,
CAST(
PARSE_JSON(ai_results.ai_response)['structured_output'][0]['raw_message']['sentiment'] AS DOUBLE
) AS sentiment,
CAST(
PARSE_JSON(ai_results.ai_response)['structured_output'][0]['raw_message']['product_mentioned'] AS STRING
) AS product_mentioned
FROM ai_results
```
## Adapting this pattern
The CTE → AI\_COMPLETE → PARSE\_JSON pattern works for any enrichment task. Here are variations for common scenarios.
### Product classification
Classify products into a taxonomy from their name and description:
```sql theme={null}
WITH product_prompts AS (
SELECT
company_data."products".product_id,
'Classify this product into a category.'
|| ' Brand: ' || COALESCE(company_data."products".brand_name, 'Unknown')
|| ' Product name: ' || company_data."products".product_name
|| ' Description: ' || COALESCE(company_data."products".description, 'No description')
AS prompt_text
FROM company_data."products"
),
classified AS (
SELECT
product_prompts.product_id,
AI_COMPLETE(
'openai-gpt-5',
product_prompts.prompt_text,
'{"temperature": 0}',
'{"type": "json", "schema": {"type": "object", "properties": {"primary_category": {"type": "string", "description": "Top-level product category"}, "sub_category": {"type": "string", "description": "More specific product category"}, "confidence": {"type": "number", "description": "Classification confidence from 0 to 1"}}, "required": ["primary_category", "sub_category", "confidence"], "additionalProperties": false}}',
TRUE
) AS ai_response
FROM product_prompts
)
SELECT
classified.product_id,
CAST(
PARSE_JSON(classified.ai_response)['structured_output'][0]['raw_message']['primary_category'] AS STRING
) AS primary_category,
CAST(
PARSE_JSON(classified.ai_response)['structured_output'][0]['raw_message']['sub_category'] AS STRING
) AS sub_category,
CAST(
PARSE_JSON(classified.ai_response)['structured_output'][0]['raw_message']['confidence'] AS DOUBLE
) AS confidence
FROM classified
```
### Entity extraction
Extract structured entities from free-text fields:
```sql theme={null}
WITH text_prompts AS (
SELECT
company_data."articles".article_id,
'Extract key entities from this text.'
|| ' Text: ' || company_data."articles".body
AS prompt_text
FROM company_data."articles"
),
extracted AS (
SELECT
text_prompts.article_id,
AI_COMPLETE(
'openai-gpt-5',
text_prompts.prompt_text,
'{"temperature": 0}',
'{"type": "json", "schema": {"type": "object", "properties": {"people": {"type": "array", "items": {"type": "string"}, "description": "Names of people mentioned"}, "organizations": {"type": "array", "items": {"type": "string"}, "description": "Organization names mentioned"}, "topics": {"type": "array", "items": {"type": "string"}, "description": "Key topics discussed"}}, "required": ["people", "organizations", "topics"], "additionalProperties": false}}',
TRUE
) AS ai_response
FROM text_prompts
)
SELECT
extracted.article_id,
PARSE_JSON(extracted.ai_response)['structured_output'][0]['raw_message']['people'] AS people,
PARSE_JSON(extracted.ai_response)['structured_output'][0]['raw_message']['organizations'] AS organizations,
PARSE_JSON(extracted.ai_response)['structured_output'][0]['raw_message']['topics'] AS topics
FROM extracted
```
## Best practices
**Prompt design:**
* Label each data element clearly (e.g., `' Brand: '`, `' Description: '`)
* Include a brief instruction at the start of the prompt describing the task
* Use `COALESCE` to handle nullable columns and avoid sending nulls to the model
**Schema design:**
* Use `enum` to constrain categorical outputs to known values
* Set `additionalProperties: false` to prevent unexpected fields
* Add `description` to each property to guide the model
* Use `number` types for scores you want to filter or aggregate
**Performance and cost:**
* Filter rows with `WHERE` before the `AI_COMPLETE` call to reduce inference volume
* Use `FETCH NEXT N ROWS ONLY` to limit batch sizes during development and testing
* Set `temperature` to `0` for deterministic, reproducible results
* Keep schemas focused—fewer fields means fewer tokens per response
***
## Related content
Function syntax and parameter details
How AI inference works within your data plane
Designing JSON schemas for AI responses
Automating queries with scheduled materialized views
# Common Query Patterns
Source: https://docs.narrative.io/cookbooks/nql/common-queries
Copy-paste NQL patterns for everyday data tasks
This cookbook contains ready-to-use NQL query patterns for common data tasks. Copy these patterns and adapt them to your datasets.
***
## Deduplication
### Keep the most recent record per user
```sql theme={null}
SELECT
user_id,
email,
last_seen,
attributes
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
**How it works:** `ROW_NUMBER()` assigns a sequence number within each user partition, ordered by `last_seen` descending. `QUALIFY` filters to keep only the first row (most recent) for each user.
### Keep the first occurrence
```sql theme={null}
SELECT
user_id,
email,
created_at
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at ASC
) = 1
```
### Deduplicate by multiple columns
```sql theme={null}
SELECT
user_id,
email,
phone,
last_updated
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY email, phone
ORDER BY last_updated DESC
) = 1
```
***
## Date range filtering
### Last N days
```sql theme={null}
SELECT user_id, email, event_timestamp
FROM company_data."123"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
### Specific date range
```sql theme={null}
SELECT user_id, email, event_date
FROM company_data."123"
WHERE event_date BETWEEN '2024-01-01' AND '2024-03-31'
```
### Current month
```sql theme={null}
SELECT user_id, email, event_date
FROM company_data."123"
WHERE event_date >= DATE_TRUNC('month', CURRENT_DATE)
```
### Previous month
```sql theme={null}
SELECT user_id, email, event_date
FROM company_data."123"
WHERE
event_date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1' MONTH)
AND event_date < DATE_TRUNC('month', CURRENT_DATE)
```
### Rolling 12 months
```sql theme={null}
SELECT user_id, email, event_timestamp
FROM company_data."123"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '12' MONTH
```
***
## Price-constrained queries
### Maximum price per 1000 rows
```sql theme={null}
SELECT user_id, email, category
FROM company_data."123"
WHERE
category = 'premium'
AND _price_cpm_usd <= 1.00
```
### Tiered pricing selection
```sql theme={null}
SELECT
user_id,
email,
category,
_price_cpm_usd,
CASE
WHEN _price_cpm_usd <= 0.50 THEN 'budget'
WHEN _price_cpm_usd <= 1.00 THEN 'standard'
ELSE 'premium'
END AS price_tier
FROM company_data."123"
WHERE _price_cpm_usd <= 2.00
```
***
## Aggregations
### Count by category
```sql theme={null}
SELECT
category,
COUNT(1) AS count,
APPROX_COUNT_DISTINCT(user_id) AS unique_users
FROM company_data."123"
GROUP BY category
ORDER BY count DESC
```
### Daily aggregation
```sql theme={null}
SELECT
DATE_TRUNC('day', event_timestamp) AS event_date,
COUNT(1) AS events,
APPROX_COUNT_DISTINCT(user_id) AS users
FROM company_data."123"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY DATE_TRUNC('day', event_timestamp)
ORDER BY event_date
```
### Monthly aggregation
```sql theme={null}
SELECT
DATE_TRUNC('month', event_date) AS month,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount,
COUNT(1) AS transaction_count
FROM company_data."123"
GROUP BY DATE_TRUNC('month', event_date)
ORDER BY month
```
### Top N by category
```sql theme={null}
SELECT
category,
product_name,
revenue
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC
) <= 10
```
***
## Identity lookups
### Rosetta Stone unique IDs
```sql theme={null}
SELECT
unique_id,
event_timestamp
FROM narrative.rosetta_stone
WHERE
event_timestamp >= CURRENT_DATE - INTERVAL '90' DAY
```
### Filter by identifier type
```sql theme={null}
SELECT
unique_id.b."value" AS identifier_value
FROM narrative.rosetta_stone
WHERE
unique_id.b."type" = 'email'
AND event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
### Exclude specific identifier types
```sql theme={null}
SELECT
unique_id.b."type",
unique_id.b."value"
FROM narrative.rosetta_stone
WHERE
unique_id.b."type" NOT IN ('cookie', 'tdid')
```
### Access Rosetta Stone from dataset
```sql theme={null}
SELECT
company_data."123"._rosetta_stone.unique_id,
company_data."123".user_attribute
FROM company_data."123"
```
### Company-scoped Rosetta Stone
Query normalized data from a specific company only:
```sql theme={null}
SELECT
company_data._rosetta_stone.unique_id,
company_data._rosetta_stone.event_timestamp
FROM company_data._rosetta_stone
WHERE company_data._rosetta_stone.event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
**How it works:** Using `company_data._rosetta_stone` limits the query to datasets owned by your company. Replace `company_data` with a partner's company slug to query their shared data.
### Combine normalized and original columns
Join Rosetta Stone attributes with non-normalized dataset columns:
```sql theme={null}
SELECT
company_data."123"._rosetta_stone.unique_id,
company_data."123"._rosetta_stone.hl7_gender,
company_data."123".custom_segment,
company_data."123".loyalty_tier
FROM company_data."123"._rosetta_stone
WHERE company_data."123"._rosetta_stone.event_timestamp >= CURRENT_DATE - INTERVAL '90' DAY
```
**How it works:** Dataset-scoped access (`company_data."123"._rosetta_stone`) lets you select both normalized Rosetta Stone attributes and original dataset columns in the same query.
***
## Data quality checks
### Count nulls per column
```sql theme={null}
SELECT
COUNT(1) AS total_rows,
COUNT(email) AS has_email,
COUNT(1) - COUNT(email) AS missing_email,
COUNT(phone) AS has_phone,
COUNT(1) - COUNT(phone) AS missing_phone
FROM company_data."123"
```
### Null percentage
```sql theme={null}
SELECT
ROUND(100.0 * COUNT(email) / COUNT(1), 2) AS email_fill_rate,
ROUND(100.0 * COUNT(phone) / COUNT(1), 2) AS phone_fill_rate,
ROUND(100.0 * COUNT(address) / COUNT(1), 2) AS address_fill_rate
FROM company_data."123"
```
### Find duplicates
```sql theme={null}
SELECT
email,
COUNT(1) AS occurrence_count
FROM company_data."123"
GROUP BY email
HAVING COUNT(1) > 1
ORDER BY occurrence_count DESC
LIMIT 100 ROWS
```
### Value distribution
```sql theme={null}
SELECT
status,
COUNT(1) AS count,
ROUND(100.0 * COUNT(1) / SUM(COUNT(1)) OVER (), 2) AS percentage
FROM company_data."123"
GROUP BY status
ORDER BY count DESC
```
***
## String operations
### Email domain extraction
```sql theme={null}
SELECT
email,
REGEXP_EXTRACT(email, '@(.+)$') AS domain
FROM company_data."123"
WHERE email IS NOT NULL
```
### Email normalization
```sql theme={null}
SELECT
email,
NORMALIZE_EMAIL(email) AS normalized_email
FROM company_data."123"
```
### Phone normalization
```sql theme={null}
SELECT
phone,
NORMALIZE_PHONE('E164', phone, 'US') AS e164_phone
FROM company_data."123"
WHERE phone IS NOT NULL
```
### Name standardization
```sql theme={null}
SELECT
UPPER(TRIM(first_name)) AS first_name,
UPPER(TRIM(last_name)) AS last_name,
CONCAT(UPPER(TRIM(first_name)), ' ', UPPER(TRIM(last_name))) AS full_name
FROM company_data."123"
```
***
## Array operations
### Expand array to rows
```sql theme={null}
SELECT
t.user_id,
tag
FROM company_data."123" t, UNNEST(t.tags) AS tag
```
### Count array elements
```sql theme={null}
SELECT
user_id,
SIZE(tags) AS tag_count,
SIZE(identifiers) AS identifier_count
FROM company_data."123"
```
### Access first array element
```sql theme={null}
SELECT
user_id,
identifiers[0].type AS primary_id_type,
identifiers[0].value AS primary_id_value
FROM company_data."123"
```
### Filter by array contents
```sql theme={null}
SELECT user_id, email, tags
FROM company_data."123"
WHERE SIZE(tags) > 0
AND tags[0] IN ('premium', 'vip')
```
***
## Sampling
### Deterministic sample
```sql theme={null}
SELECT user_id, email, created_at
FROM company_data."123"
WHERE UNIVERSE_SAMPLE(user_id, 0.1) -- 10% sample
```
### Random sample with LIMIT
```sql theme={null}
SELECT user_id, email, created_at
FROM company_data."123"
ORDER BY DETERMINISTIC_RAND(user_id)
LIMIT 1000 ROWS
```
***
## Materialized view patterns
### Basic view with budget
```sql theme={null}
CREATE MATERIALIZED VIEW "active_users"
AS (
SELECT
user_id,
email,
last_seen
FROM company_data."123"
WHERE
last_seen >= CURRENT_DATE - INTERVAL '30' DAY
AND _price_cpm_usd <= 1.00
)
BUDGET 50 USD
```
### Daily refresh with deduplication
```sql theme={null}
CREATE MATERIALIZED VIEW "daily_users"
REFRESH_SCHEDULE = '@daily'
PARTITIONED_BY event_date DAY
AS (
SELECT
user_id,
email,
DATE_TRUNC('day', last_seen) AS event_date
FROM company_data."123"
WHERE last_seen >= CURRENT_DATE - INTERVAL '7' DAY
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
)
BUDGET 25 USD PER CALENDAR_DAY
```
***
## Related content
Optimization recipes for faster queries
Advanced join patterns
All available functions
Complete filtering tutorial
# Complex Join Patterns
Source: https://docs.narrative.io/cookbooks/nql/complex-joins
Advanced join recipes for multi-dataset scenarios
This cookbook contains advanced join patterns for combining data across multiple datasets. These recipes address common data collaboration scenarios.
***
## Multi-dataset joins
### Three-way join
Combine customers, orders, and products:
```sql theme={null}
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_date,
p.product_name,
p.category,
o.quantity * p.unit_price AS line_total
FROM company_data."100" c
INNER JOIN company_data."200" o
ON c.customer_id = o.customer_id
INNER JOIN company_data."300" p
ON o.product_id = p.product_id
WHERE
o.order_date >= CURRENT_DATE - INTERVAL '30' DAY
```
### Chain of related entities
Follow relationships through multiple tables:
```sql theme={null}
WITH customer_orders AS (
SELECT
c.customer_id,
c.name AS customer_name,
c.segment,
o.order_id,
o.order_date
FROM company_data."100" c
INNER JOIN company_data."200" o
ON c.customer_id = o.customer_id
WHERE o.order_date >= CURRENT_DATE - INTERVAL '90' DAY
),
order_products AS (
SELECT
co.*,
p.product_id,
p.product_name,
p.category
FROM customer_orders co
INNER JOIN company_data."300" op
ON co.order_id = op.order_id
INNER JOIN company_data."400" p
ON op.product_id = p.product_id
)
SELECT
customer_id,
customer_name,
segment,
category,
APPROX_COUNT_DISTINCT(product_id) AS unique_products,
COUNT(1) AS total_purchases
FROM order_products
GROUP BY customer_id, customer_name, segment, category
```
***
## Identity-based joins with Rosetta Stone
### Join datasets via resolved identities
```sql theme={null}
SELECT
d1.user_attribute AS attribute_from_dataset1,
d2.user_attribute AS attribute_from_dataset2
FROM company_data."123" d1
INNER JOIN company_data."456" d2
ON d1._rosetta_stone.unique_id = d2._rosetta_stone.unique_id
WHERE
d1._price_cpm_usd <= 1.00
AND d2._price_cpm_usd <= 1.00
```
### Enrich with Rosetta Stone data
```sql theme={null}
SELECT
d.user_id,
d.email,
rs.unique_id,
rs.event_timestamp AS identity_timestamp
FROM company_data."123" d
LEFT JOIN narrative.rosetta_stone rs
ON d._rosetta_stone.unique_id = rs.unique_id
WHERE
rs.event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
### Match by identifier type
```sql theme={null}
SELECT
d1.email,
d2.phone,
d1.last_seen
FROM company_data."123" d1
INNER JOIN company_data."456" d2
ON d1._rosetta_stone.unique_id = d2._rosetta_stone.unique_id
WHERE
d1._rosetta_stone.unique_id.b."type" = 'email'
```
***
## Temporal joins
### Event-session matching
Match events to sessions based on timestamp:
```sql theme={null}
SELECT
e.event_id,
e.event_type,
e.event_timestamp,
s.session_id,
s.session_start,
s.session_end
FROM company_data."100" e
INNER JOIN company_data."200" s
ON e.user_id = s.user_id
AND e.event_timestamp BETWEEN s.session_start AND s.session_end
```
### Point-in-time lookup
Get the state of a record at a specific point in time:
```sql theme={null}
SELECT
t.transaction_id,
t.transaction_date,
t.amount,
p.price AS price_at_transaction
FROM company_data."100" t
INNER JOIN (
SELECT
product_id,
price,
effective_date,
COALESCE(
LEAD(effective_date) OVER (PARTITION BY product_id ORDER BY effective_date),
DATE '9999-12-31'
) AS end_date
FROM company_data."200"
) p
ON t.product_id = p.product_id
AND t.transaction_date >= p.effective_date
AND t.transaction_date < p.end_date
```
### Time-windowed join
Join events that occurred within a time window:
```sql theme={null}
SELECT
a.event_id AS first_event,
b.event_id AS follow_up_event,
a.user_id,
a.event_timestamp AS first_timestamp,
b.event_timestamp AS follow_up_timestamp
FROM company_data."100" a
INNER JOIN company_data."100" b
ON a.user_id = b.user_id
AND b.event_timestamp > a.event_timestamp
AND b.event_timestamp <= a.event_timestamp + INTERVAL '24' HOUR
WHERE
a.event_type = 'page_view'
AND b.event_type = 'purchase'
```
***
## Self-joins
### Find related records within same dataset
```sql theme={null}
SELECT
e1.employee_id,
e1.name AS employee_name,
e1.department,
e2.name AS manager_name
FROM company_data."123" e1
LEFT JOIN company_data."123" e2
ON e1.manager_id = e2.employee_id
```
### Compare records over time
```sql theme={null}
SELECT
curr.user_id,
curr.metric_date,
curr.value AS current_value,
prev.value AS previous_value,
curr.value - prev.value AS change
FROM company_data."123" curr
LEFT JOIN company_data."123" prev
ON curr.user_id = prev.user_id
AND prev.metric_date = curr.metric_date - INTERVAL '1' DAY
WHERE
curr.metric_date = CURRENT_DATE
```
### Find duplicates
```sql theme={null}
SELECT
a.record_id AS record_1,
b.record_id AS record_2,
a.email
FROM company_data."123" a
INNER JOIN company_data."123" b
ON a.email = b.email
AND a.record_id < b.record_id
```
***
## Anti-joins and exclusions
### Find records without matches
Find customers who haven't made purchases:
```sql theme={null}
SELECT
c.customer_id,
c.email,
c.signup_date
FROM company_data."100" c
WHERE NOT EXISTS (
SELECT 1
FROM company_data."200" o
WHERE c.customer_id = o.customer_id
)
```
### Find new records not in reference
Find users not in suppression list:
```sql theme={null}
SELECT
u.user_id,
u.email
FROM company_data."100" u
WHERE NOT EXISTS (
SELECT 1
FROM company_data."200" suppression
WHERE u.email = suppression.email
)
```
### Exclude recent interactions
Find users without recent activity:
```sql theme={null}
SELECT
u.user_id,
u.email,
u.last_login
FROM company_data."100" u
WHERE NOT EXISTS (
SELECT 1
FROM company_data."200" a
WHERE u.user_id = a.user_id
AND a.activity_date >= CURRENT_DATE - INTERVAL '90' DAY
)
```
***
## Enrichment patterns
### Latest record enrichment
Enrich with the most recent related record:
```sql theme={null}
SELECT
c.customer_id,
c.name,
latest_order.order_id,
latest_order.order_date,
latest_order.amount
FROM company_data."100" c
LEFT JOIN (
SELECT
customer_id,
order_id,
order_date,
amount
FROM company_data."200"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) = 1
) latest_order ON c.customer_id = latest_order.customer_id
```
### Aggregated enrichment
Enrich with summary statistics:
```sql theme={null}
SELECT
c.customer_id,
c.name,
c.segment,
COALESCE(stats.order_count, 0) AS order_count,
COALESCE(stats.total_spent, 0) AS total_spent,
COALESCE(stats.avg_order, 0) AS avg_order_value
FROM company_data."100" c
LEFT JOIN (
SELECT
customer_id,
COUNT(1) AS order_count,
SUM(amount) AS total_spent,
AVG(amount) AS avg_order
FROM company_data."200"
GROUP BY customer_id
) stats ON c.customer_id = stats.customer_id
```
### Multiple attribute enrichment
Enrich from multiple sources:
```sql theme={null}
WITH customer_base AS (
SELECT customer_id, name, email
FROM company_data."100"
),
order_summary AS (
SELECT
customer_id,
COUNT(1) AS orders,
SUM(amount) AS total
FROM company_data."200"
GROUP BY customer_id
),
engagement_summary AS (
SELECT
customer_id,
COUNT(1) AS interactions,
MAX(interaction_date) AS last_interaction
FROM company_data."300"
GROUP BY customer_id
)
SELECT
c.customer_id,
c.name,
c.email,
COALESCE(o.orders, 0) AS order_count,
COALESCE(o.total, 0) AS total_spent,
COALESCE(e.interactions, 0) AS engagement_count,
e.last_interaction
FROM customer_base c
LEFT JOIN order_summary o ON c.customer_id = o.customer_id
LEFT JOIN engagement_summary e ON c.customer_id = e.customer_id
```
***
## Cross-dataset deduplication
### Deduplicate across datasets
Merge records from multiple datasets, keeping the most complete:
```sql theme={null}
WITH all_records AS (
SELECT
email,
name,
phone,
'dataset_1' AS source,
updated_at
FROM company_data."100"
UNION ALL
SELECT
email,
name,
phone,
'dataset_2' AS source,
updated_at
FROM company_data."200"
),
ranked AS (
SELECT
email,
name,
phone,
source,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY
CASE WHEN name IS NOT NULL THEN 1 ELSE 2 END,
CASE WHEN phone IS NOT NULL THEN 1 ELSE 2 END,
updated_at DESC
) AS rn
FROM all_records
)
SELECT email, name, phone, source
FROM ranked
WHERE rn = 1
```
***
## Materialized view with joins
### Create enriched view
```sql theme={null}
CREATE MATERIALIZED VIEW "enriched_customers"
REFRESH_SCHEDULE = '@daily'
AS (
SELECT
c.customer_id,
c.email,
c.segment,
COALESCE(metrics.order_count, 0) AS lifetime_orders,
COALESCE(metrics.total_value, 0) AS lifetime_value,
metrics.last_order_date
FROM company_data."100" c
LEFT JOIN (
SELECT
customer_id,
COUNT(1) AS order_count,
SUM(amount) AS total_value,
MAX(order_date) AS last_order_date
FROM company_data."200"
GROUP BY customer_id
) metrics ON c.customer_id = metrics.customer_id
WHERE
c._price_cpm_usd <= 1.00
)
BUDGET 100 USD PER CALENDAR_DAY
```
***
## Related content
Complete guide to join operations
Understanding join performance
Basic query patterns
Query optimization recipes
# Geospatial POI Joins
Source: https://docs.narrative.io/cookbooks/nql/geospatial-poi-joins
Join datasets on point of interest geometries using ST_Intersects
Geospatial joins let you match records based on geographic overlap rather than shared keys. This is useful for foot traffic analysis, proximity targeting, and visitation attribution---any scenario where you need to find which observations fall within a set of points of interest.
***
## POI polygon-to-polygon join
Use `ST_Intersects` to join two datasets that both have POI geometry columns. This finds all records from one dataset whose geographic polygon overlaps with polygons in another dataset.
```sql theme={null}
SELECT
ls."_rosetta_stone"."geographic_location"."latitude",
ls."_rosetta_stone"."geographic_location"."longitude",
ls."_rosetta_stone"."mobile_id_unique_identifier"."value" AS maid_value,
ls."_rosetta_stone"."event_timestamp"
FROM
company_data."observations" ls
INNER JOIN company_data."target_locations" t
ON ST_Intersects(
t."_rosetta_stone"."geographic_poi"."poi",
ls."_rosetta_stone"."geographic_poi"."poi"
)
WHERE
ls."_rosetta_stone"."geographic_location"."latitude" IS NOT NULL
AND ls."_rosetta_stone"."geographic_location"."longitude" IS NOT NULL
AND ls."_rosetta_stone"."mobile_id_unique_identifier"."value" IS NOT NULL
AND ls."_rosetta_stone"."event_timestamp" BETWEEN '2026-02-01' AND '2026-04-01'
AND ls."_rosetta_stone"."nio_last_modified_at" >= '2026-02-01'
```
Key points:
* Both datasets must have a `geographic_poi.poi` attribute mapped through Rosetta Stone
* `ST_Intersects` returns true when the two geometries share any area in common
* Select non-geometry columns only---latitude, longitude, timestamps, and identifiers
***
## Point-to-polygon join
When one dataset has raw coordinates instead of POI polygons, use `ST_SafePoint` and `ST_Circle` to create a geometry on the fly, then join with `ST_Intersects`.
```sql theme={null}
SELECT
obs."_rosetta_stone"."mobile_id_unique_identifier"."value" AS maid_value,
obs."_rosetta_stone"."event_timestamp",
pois.name AS poi_name
FROM company_data."observations" obs
INNER JOIN company_data."poi_locations" pois
ON ST_Intersects(
pois."_rosetta_stone"."geographic_poi"."poi",
ST_Circle(
ST_SafePoint(
obs."_rosetta_stone"."geographic_location"."longitude",
obs."_rosetta_stone"."geographic_location"."latitude"
),
50
)
)
WHERE
obs."_rosetta_stone"."event_timestamp" >= CURRENT_DATE - INTERVAL '30' DAY
AND obs."_rosetta_stone"."nio_last_modified_at" >= CURRENT_DATE - INTERVAL '30' DAY
```
`ST_Circle` takes a point and a radius in meters. In this example, each observation is expanded into a 50-meter circle and checked for overlap with the POI polygons.
***
## Combining geospatial and temporal filters
Most geospatial queries also need time-based filtering. Always include both an `event_timestamp` range and a `nio_last_modified_at` filter to scope the data appropriately.
```sql theme={null}
SELECT
ls."_rosetta_stone"."geographic_location"."latitude",
ls."_rosetta_stone"."geographic_location"."longitude",
ls."_rosetta_stone"."mobile_id_unique_identifier"."value" AS maid_value,
ls."_rosetta_stone"."event_timestamp"
FROM
company_data."observations" ls
INNER JOIN company_data."target_locations" t
ON ST_Intersects(
t."_rosetta_stone"."geographic_poi"."poi",
ls."_rosetta_stone"."geographic_poi"."poi"
)
WHERE
ls."_rosetta_stone"."event_timestamp" BETWEEN '2026-02-01' AND '2026-04-01'
AND ls."_rosetta_stone"."nio_last_modified_at" >= '2026-02-01'
```
* `event_timestamp` filters the actual observation time
* `nio_last_modified_at` ensures query efficiency by limiting the data scan window
***
## Common pitfalls
### GEOMETRY type cannot be in SELECT
Geometry columns cannot be returned in query results. Use them only in JOIN conditions or WHERE clauses.
**This fails:**
```sql theme={null}
SELECT
ls."_rosetta_stone"."geographic_poi"."poi" AS poi_geometry
FROM company_data."observations" ls
```
**Instead, select non-geometry columns** like latitude, longitude, or identifiers. See [Unsupported Type Errors](/guides/nql/troubleshooting/unsupported-type-error) for details.
### Argument order in ST\_Intersects
Place the reference/POI dataset geometry as the **first** argument and the observation geometry as the **second** argument. Reversing the order can cause internal errors.
```sql theme={null}
-- Correct: reference geometry first
ON ST_Intersects(
t."_rosetta_stone"."geographic_poi"."poi",
ls."_rosetta_stone"."geographic_poi"."poi"
)
-- Incorrect: observation geometry first (may cause errors)
ON ST_Intersects(
ls."_rosetta_stone"."geographic_poi"."poi",
t."_rosetta_stone"."geographic_poi"."poi"
)
```
### Always use \_rosetta\_stone
Access columns through the `_rosetta_stone` path. Queries that omit `_rosetta_stone` may appear to work in some cases, but this is not a supported pattern and will produce unreliable results.
```sql theme={null}
-- Correct
ls."_rosetta_stone"."geographic_poi"."poi"
-- Incorrect: missing _rosetta_stone
ls."geographic_poi"."poi"
```
***
## Related content
Understanding location polygons and geospatial formats
ST\_SafePoint, ST\_Circle, ST\_Intersects, and more
Advanced join recipes for multi-dataset scenarios
Resolving GEOMETRY type errors in SELECT statements
# Performance Patterns
Source: https://docs.narrative.io/cookbooks/nql/performance-patterns
Optimization recipes for faster, more efficient NQL queries
This cookbook contains patterns for optimizing NQL query performance. Apply these techniques to reduce execution time and costs.
***
## Filter early and aggressively
### Apply filters before joins
Filter data in subqueries or CTEs before joining to reduce the amount of data processed:
```sql theme={null}
-- Better: Filter before joining
WITH active_users AS (
SELECT user_id, email
FROM company_data."100"
WHERE
status = 'active'
AND created_at >= CURRENT_DATE - INTERVAL '90' DAY
),
recent_orders AS (
SELECT user_id, order_id, amount
FROM company_data."200"
WHERE order_date >= CURRENT_DATE - INTERVAL '30' DAY
)
SELECT
u.user_id,
u.email,
o.order_id,
o.amount
FROM active_users u
INNER JOIN recent_orders o ON u.user_id = o.user_id
```
```sql theme={null}
-- Avoid: Filtering after joining
SELECT
u.user_id,
u.email,
o.order_id,
o.amount
FROM company_data."100" u
INNER JOIN company_data."200" o ON u.user_id = o.user_id
WHERE
u.status = 'active'
AND u.created_at >= CURRENT_DATE - INTERVAL '90' DAY
AND o.order_date >= CURRENT_DATE - INTERVAL '30' DAY
```
### Use price filtering early
Add `_price_cpm_usd` filters to limit data costs:
```sql theme={null}
SELECT user_id, email, category, event_date
FROM company_data."123"
WHERE
_price_cpm_usd <= 1.00 -- Apply price filter early
AND category = 'target'
AND event_date >= CURRENT_DATE - INTERVAL '30' DAY
```
***
## Use QUALIFY instead of subqueries
### Deduplication with QUALIFY
QUALIFY is more efficient than subquery-based deduplication:
```sql theme={null}
-- Better: QUALIFY
SELECT user_id, email, last_seen
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
```sql theme={null}
-- Avoid: Subquery approach
SELECT user_id, email, last_seen
FROM (
SELECT
user_id,
email,
last_seen,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY last_seen DESC) AS rn
FROM company_data."123"
) sub
WHERE rn = 1
```
### Combined filtering with QUALIFY
Apply multiple window conditions efficiently:
```sql theme={null}
SELECT
user_id,
email,
category
FROM company_data."123"
QUALIFY
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1
AND SUM(1) OVER (PARTITION BY category) >= 100 -- Only categories with 100+ rows
```
***
## Optimize joins
### Use fully qualified column names
Explicit column references help the query optimizer:
```sql theme={null}
SELECT
company_data."100".user_id,
company_data."100".email,
company_data."200".order_id
FROM company_data."100"
INNER JOIN company_data."200"
ON company_data."100".user_id = company_data."200".customer_id
```
### Avoid OR in join conditions
OR conditions prevent efficient join algorithms:
```sql theme={null}
-- Avoid: OR in join condition
SELECT t1.id, t1.value, t2.related_value
FROM company_data."100" t1
INNER JOIN company_data."200" t2
ON t1.id = t2.id OR t1.alt_id = t2.alt_id
```
```sql theme={null}
-- Better: Use COALESCE
SELECT t1.id, t1.value, t2.related_value
FROM company_data."100" t1
INNER JOIN company_data."200" t2
ON COALESCE(t1.id, t1.alt_id) = COALESCE(t2.id, t2.alt_id)
```
```sql theme={null}
-- Or: Union separate queries
SELECT t1.id, t1.value, t2.related_value
FROM company_data."100" t1
INNER JOIN company_data."200" t2 ON t1.id = t2.id
UNION
SELECT t1.id, t1.value, t2.related_value
FROM company_data."100" t1
INNER JOIN company_data."200" t2 ON t1.alt_id = t2.alt_id
WHERE t1.id IS NULL OR t2.id IS NULL
```
### Aggregate before joining
Pre-aggregate large tables to reduce join complexity:
```sql theme={null}
-- Better: Aggregate first
SELECT
c.customer_id,
c.name,
order_stats.total_orders,
order_stats.total_amount
FROM company_data."100" c
INNER JOIN (
SELECT
customer_id,
COUNT(1) AS total_orders,
SUM(amount) AS total_amount
FROM company_data."200"
GROUP BY customer_id
) order_stats ON c.customer_id = order_stats.customer_id
```
***
## Partitioning strategies
### Partition by date for time-series data
```sql theme={null}
CREATE MATERIALIZED VIEW "daily_events"
PARTITIONED_BY event_date DAY
AS (
SELECT
user_id,
event_type,
DATE_TRUNC('day', event_timestamp) AS event_date
FROM company_data."123"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '90' DAY
)
BUDGET 100 USD
```
### Monthly partitions for longer retention
```sql theme={null}
CREATE MATERIALIZED VIEW "monthly_summary"
PARTITIONED_BY report_month MONTH
AS (
SELECT
DATE_TRUNC('month', event_date) AS report_month,
category,
COUNT(1) AS event_count,
SUM(amount) AS total_amount
FROM company_data."123"
GROUP BY DATE_TRUNC('month', event_date), category
)
BUDGET 50 USD
```
***
## Budget management
### Set appropriate budget limits
Match budget to expected data volume:
```sql theme={null}
-- Daily budget for daily refreshes
CREATE MATERIALIZED VIEW "daily_data"
REFRESH_SCHEDULE = '@daily'
AS (SELECT user_id, email, event_date FROM company_data."123" WHERE event_date = CURRENT_DATE - INTERVAL '1' DAY)
LIMIT 10 USD PER CALENDAR_DAY
-- Monthly budget for larger datasets
CREATE MATERIALIZED VIEW "monthly_data"
REFRESH_SCHEDULE = '@monthly'
AS (SELECT user_id, email, created_at FROM company_data."123")
LIMIT 500 USD PER CALENDAR_MONTH
```
### Combine price and date filters
Maximize value by filtering on both price and recency:
```sql theme={null}
SELECT user_id, email, event_timestamp
FROM company_data."123"
WHERE
_price_cpm_usd <= 0.75
AND event_timestamp >= CURRENT_DATE - INTERVAL '14' DAY
```
***
## Efficient aggregations
### Use APPROX\_COUNT\_DISTINCT instead of COUNT(DISTINCT ...)
`APPROX_COUNT_DISTINCT` is significantly faster and cheaper than `COUNT(DISTINCT ...)`. It returns exact results for low-cardinality columns and near-exact results at scale. Use it for any query that doesn't require a guaranteed exact count.
```sql theme={null}
-- Better: Use APPROX_COUNT_DISTINCT for unique counts
SELECT
category,
APPROX_COUNT_DISTINCT(user_id) AS unique_users
FROM company_data."123"
GROUP BY category
```
```sql theme={null}
-- Avoid: COUNT(DISTINCT ...) is slower and more expensive
SELECT
category,
COUNT(DISTINCT user_id) AS unique_users
FROM company_data."123"
GROUP BY category
```
Reserve `COUNT(DISTINCT ...)` for cases where an exact count drives business logic—for example, threshold comparisons in `HAVING` or `CASE WHEN` clauses:
```sql theme={null}
-- Exact count is appropriate here because the threshold matters
SELECT category, COUNT(DISTINCT user_id) AS unique_users
FROM company_data."123"
GROUP BY category
HAVING COUNT(DISTINCT user_id) >= 5
```
### Pre-aggregate for common queries
Create summary views for frequently queried aggregations:
```sql theme={null}
CREATE MATERIALIZED VIEW "daily_category_stats"
REFRESH_SCHEDULE = '@daily'
AS (
SELECT
DATE_TRUNC('day', event_timestamp) AS event_date,
category,
COUNT(1) AS events,
APPROX_COUNT_DISTINCT(user_id) AS users,
SUM(amount) AS total
FROM company_data."123"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY DATE_TRUNC('day', event_timestamp), category
)
BUDGET 25 USD PER CALENDAR_DAY
```
***
## Limit result sets
### Always use LIMIT during exploration
```sql theme={null}
-- While exploring data
SELECT user_id, email, category
FROM company_data."123"
WHERE category = 'test'
LIMIT 100 ROWS
-- Or with offset for pagination
LIMIT 100 OFFSET 100 ROWS
```
***
## Efficient date handling
### Use DATE\_TRUNC for grouping
```sql theme={null}
SELECT
DATE_TRUNC('day', event_timestamp) AS day,
COUNT(1) AS events
FROM company_data."123"
GROUP BY DATE_TRUNC('day', event_timestamp)
```
### Avoid functions on indexed columns in WHERE
```sql theme={null}
-- Better: Compare directly
WHERE event_date >= '2024-01-01'
-- Avoid: Function on column
WHERE EXTRACT(YEAR FROM event_date) = 2024
```
***
## CTEs for complex queries
### Use CTEs for readability and potential optimization
```sql theme={null}
WITH
base_data AS (
SELECT user_id, event_date, amount
FROM company_data."123"
WHERE
event_date >= CURRENT_DATE - INTERVAL '30' DAY
AND _price_cpm_usd <= 1.00
),
user_metrics AS (
SELECT
user_id,
COUNT(1) AS event_count,
SUM(amount) AS total_amount
FROM base_data
GROUP BY user_id
),
ranked_users AS (
SELECT
user_id,
event_count,
total_amount,
RANK() OVER (ORDER BY total_amount DESC) AS rank
FROM user_metrics
)
SELECT user_id, event_count, total_amount, rank
FROM ranked_users
WHERE rank <= 100
```
***
## Anti-patterns to avoid
### Use explicit column lists (wildcards not supported)
NQL requires explicit column lists—`SELECT *` and `COUNT(*)` are not supported. See [Explicit Column Selection](/nql/general/explicit-columns) for why this design choice improves cost transparency, governance, and query optimization.
```sql theme={null}
-- Not supported in NQL
SELECT * FROM company_data."123"
-- Required: Select only needed columns
SELECT user_id, email, created_at FROM company_data."123"
-- Not supported: COUNT(*)
SELECT COUNT(*) FROM company_data."123"
-- Required: Use COUNT(1) for row counts
SELECT COUNT(1) FROM company_data."123"
```
### Don't use LIKE with leading wildcards when possible
```sql theme={null}
-- Slow: Leading wildcard
WHERE email LIKE '%@gmail.com'
-- Better: Use REGEXP_EXTRACT or suffix column
WHERE REGEXP_EXTRACT(email, '@(.+)$') = 'gmail.com'
```
### Don't nest too many subqueries
```sql theme={null}
-- Avoid: Deeply nested subqueries
SELECT user_id, email FROM (
SELECT user_id, email FROM (
SELECT user_id, email FROM (
SELECT user_id, email FROM company_data."123"
) a
) b
) c
-- Better: Use CTEs
WITH step1 AS (SELECT user_id, email FROM company_data."123"),
step2 AS (SELECT user_id, email FROM step1),
step3 AS (SELECT user_id, email FROM step2)
SELECT user_id, email FROM step3
```
***
## Related content
Detailed optimization techniques
Understanding join performance implications
Copy-paste query patterns
Advanced join recipes
# Batch Processing with Model Inference
Source: https://docs.narrative.io/cookbooks/sdk/batch-inference-patterns
Process large volumes of data efficiently using inference
This cookbook demonstrates patterns for efficiently processing large datasets with Model Inference. You'll learn chunking strategies, parallel execution, rate limiting, error recovery, and result aggregation.
## What this recipe accomplishes
* Process large datasets in manageable chunks
* Execute inference requests in parallel
* Handle rate limits and backoff
* Recover from failures without losing progress
* Aggregate and report results
## Prerequisites
* SDK installed and configured (see [Authentication](/guides/sdk/authentication))
* A data plane ID where inference will run
* Understanding of [Running Model Inference](/guides/sdk/running-model-inference)
## Complete example
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
// Types
interface BatchItem {
id: string;
content: string;
}
interface ProcessedItem {
id: string;
result: unknown;
success: boolean;
error?: string;
processingTime: number;
}
interface BatchProgress {
total: number;
processed: number;
succeeded: number;
failed: number;
startTime: number;
}
// Configuration
const CONFIG = {
dataPlaneId: process.env.DATA_PLANE_ID!,
model: 'anthropic.claude-haiku-4.5' as const, // Use faster model for batch
concurrency: 5, // Parallel requests
chunkSize: 50, // Items per chunk
maxRetries: 3,
retryDelayMs: 2000,
pollIntervalMs: 2000,
jobTimeoutMs: 60000,
};
// Initialize API
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY!,
});
// Output schema (customize for your use case)
const outputSchema = {
type: 'object',
properties: {
category: { type: 'string' },
score: { type: 'number', minimum: 0, maximum: 1 },
summary: { type: 'string', maxLength: 100 },
},
required: ['category', 'score'],
};
/**
* Process a single item with retries
*/
async function processItem(
item: BatchItem,
retryCount = 0
): Promise {
const startTime = Date.now();
try {
// Submit inference request
const job = await api.runModelInference({
data_plane_id: CONFIG.dataPlaneId,
model: CONFIG.model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: `Process this item:\n\nID: ${item.id}\nContent: ${item.content}` }
]
},
],
inference_config: {
output_format_schema: outputSchema,
temperature: 0.2,
},
tags: ['batch-processing', `item-${item.id}`],
});
// Wait for completion
const result = await waitForJob(job.id);
return {
id: item.id,
result: result,
success: true,
processingTime: Date.now() - startTime,
};
} catch (error) {
// Retry on transient errors
if (retryCount < CONFIG.maxRetries && isRetryableError(error)) {
const delay = CONFIG.retryDelayMs * Math.pow(2, retryCount);
console.log(`Retrying item ${item.id} in ${delay}ms (attempt ${retryCount + 1})`);
await sleep(delay);
return processItem(item, retryCount + 1);
}
return {
id: item.id,
result: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
processingTime: Date.now() - startTime,
};
}
}
/**
* Wait for job completion with timeout
*/
async function waitForJob(jobId: string): Promise {
const startTime = Date.now();
while (Date.now() - startTime < CONFIG.jobTimeoutMs) {
const job = await api.getJob(jobId);
if (job.state === 'completed' && job.result) {
return job.result.structured_output;
}
if (job.state === 'failed') {
throw new Error(`Job failed: ${JSON.stringify(job.failures)}`);
}
await sleep(CONFIG.pollIntervalMs);
}
throw new Error(`Job ${jobId} timed out`);
}
/**
* Check if error is retryable
*/
function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
// Rate limit or temporary failures
return (
error.message.includes('rate limit') ||
error.message.includes('timeout') ||
error.message.includes('503') ||
error.message.includes('429')
);
}
return false;
}
/**
* Process items with controlled concurrency
*/
async function processWithConcurrency(
items: BatchItem[],
concurrency: number,
onProgress?: (progress: BatchProgress) => void
): Promise {
const results: ProcessedItem[] = [];
const progress: BatchProgress = {
total: items.length,
processed: 0,
succeeded: 0,
failed: 0,
startTime: Date.now(),
};
// Process in chunks
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency);
// Process chunk in parallel
const chunkResults = await Promise.all(
chunk.map(item => processItem(item))
);
// Update results and progress
for (const result of chunkResults) {
results.push(result);
progress.processed++;
if (result.success) {
progress.succeeded++;
} else {
progress.failed++;
}
}
// Report progress
if (onProgress) {
onProgress(progress);
}
// Rate limiting between chunks
if (i + concurrency < items.length) {
await sleep(500);
}
}
return results;
}
/**
* Chunk an array into smaller arrays
*/
function chunkArray(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Sleep helper
*/
function sleep(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Format duration
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
/**
* Main batch processing function
*/
async function processBatch(items: BatchItem[]): Promise {
console.log(`\n=== Batch Processing ===`);
console.log(`Items: ${items.length}`);
console.log(`Concurrency: ${CONFIG.concurrency}`);
console.log(`Model: ${CONFIG.model}\n`);
const startTime = Date.now();
// Process with progress reporting
const results = await processWithConcurrency(
items,
CONFIG.concurrency,
(progress) => {
const elapsed = Date.now() - progress.startTime;
const rate = progress.processed / (elapsed / 1000);
const eta = progress.processed > 0
? (progress.total - progress.processed) / rate
: 0;
process.stdout.write(
`\rProgress: ${progress.processed}/${progress.total} ` +
`(${progress.succeeded} ok, ${progress.failed} failed) ` +
`Rate: ${rate.toFixed(1)}/s ETA: ${formatDuration(eta * 1000)} `
);
}
);
console.log('\n');
// Aggregate results
const totalTime = Date.now() - startTime;
const succeeded = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
const avgTime = results.reduce((sum, r) => sum + r.processingTime, 0) / results.length;
console.log('=== Results ===');
console.log(`Total time: ${formatDuration(totalTime)}`);
console.log(`Processed: ${results.length}`);
console.log(`Succeeded: ${succeeded.length} (${(succeeded.length / results.length * 100).toFixed(1)}%)`);
console.log(`Failed: ${failed.length}`);
console.log(`Avg processing time: ${formatDuration(avgTime)}`);
console.log(`Throughput: ${(results.length / (totalTime / 1000)).toFixed(2)} items/sec`);
if (failed.length > 0) {
console.log('\nFailed items:');
failed.slice(0, 5).forEach(f => {
console.log(` - ${f.id}: ${f.error}`);
});
if (failed.length > 5) {
console.log(` ... and ${failed.length - 5} more`);
}
}
// Return or store results as needed
return;
}
/**
* Checkpoint-based processing for very large batches
*/
async function processWithCheckpoints(
items: BatchItem[],
checkpointFile: string
): Promise {
const fs = await import('fs').then(m => m.promises);
// Load checkpoint if exists
let processedIds: Set = new Set();
try {
const checkpoint = await fs.readFile(checkpointFile, 'utf-8');
processedIds = new Set(JSON.parse(checkpoint));
console.log(`Resuming from checkpoint: ${processedIds.size} items already processed`);
} catch {
console.log('Starting fresh (no checkpoint found)');
}
// Filter already processed
const remaining = items.filter(item => !processedIds.has(item.id));
console.log(`Remaining items to process: ${remaining.length}`);
// Process in chunks with checkpoints
const chunks = chunkArray(remaining, CONFIG.chunkSize);
for (let i = 0; i < chunks.length; i++) {
console.log(`\nProcessing chunk ${i + 1}/${chunks.length}...`);
const results = await processWithConcurrency(chunks[i], CONFIG.concurrency);
// Update checkpoint
results.forEach(r => processedIds.add(r.id));
await fs.writeFile(checkpointFile, JSON.stringify([...processedIds]));
console.log(`Checkpoint saved: ${processedIds.size} items`);
}
}
// Example usage
async function main(): Promise {
// Generate sample items
const sampleItems: BatchItem[] = Array.from({ length: 100 }, (_, i) => ({
id: `item-${i + 1}`,
content: `Sample content for item ${i + 1}`,
}));
await processBatch(sampleItems);
}
main().catch(console.error);
```
## Key patterns
### Controlled concurrency
Limit parallel requests to avoid overwhelming the system:
```typescript theme={null}
// Process in parallel batches
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency);
await Promise.all(chunk.map(processItem));
}
```
### Exponential backoff
Retry failed requests with increasing delays:
```typescript theme={null}
const delay = baseDelay * Math.pow(2, retryCount);
await sleep(delay);
```
### Progress tracking
Report progress for visibility into long-running jobs:
```typescript theme={null}
onProgress({
processed: count,
total: items.length,
rate: count / elapsedSeconds,
});
```
### Checkpoint recovery
Save progress for resumable processing:
```typescript theme={null}
// Save after each chunk
await fs.writeFile(checkpointFile, JSON.stringify([...processedIds]));
// Resume from checkpoint
const checkpoint = await fs.readFile(checkpointFile, 'utf-8');
const processedIds = new Set(JSON.parse(checkpoint));
```
## Performance tuning
| Parameter | Effect | Recommendation |
| ---------------- | ---------------------- | ---------------------------------------------- |
| `concurrency` | Parallel requests | Start with 5, increase if system handles it |
| `chunkSize` | Items per checkpoint | 50-100 for good checkpoint granularity |
| `model` | Speed vs capability | Use Haiku for simple tasks, Sonnet for complex |
| `pollIntervalMs` | Status check frequency | 2000ms balances responsiveness and API calls |
## Related content
Inference fundamentals
Model selection guide
Monitor job status
Enrichment pipeline example
# Automating Data Enrichment with Model Inference
Source: https://docs.narrative.io/cookbooks/sdk/model-inference-automation
Build a pipeline that enriches dataset records using AI inference
This cookbook demonstrates how to build an automated data enrichment pipeline using Model Inference. You'll learn to fetch records from a dataset, run inference to classify or enrich each record, and handle results at scale.
## What this recipe accomplishes
* Fetch sample records from a dataset
* Run AI inference to enrich each record with classifications
* Handle inference results with proper error handling
* Process records in batches for efficiency
## Prerequisites
* SDK installed and configured (see [Authentication](/guides/sdk/authentication))
* A dataset with records to enrich
* A data plane ID where inference will run
## Complete example
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
// Types for our enrichment
interface RecordToEnrich {
id: string;
text: string;
[key: string]: unknown;
}
interface EnrichmentResult {
category: 'retail' | 'finance' | 'healthcare' | 'technology' | 'other';
confidence: number;
tags: string[];
summary: string;
}
interface EnrichedRecord extends RecordToEnrich {
enrichment: EnrichmentResult;
enriched_at: string;
}
// Configuration
const CONFIG = {
dataPlaneId: process.env.DATA_PLANE_ID!,
datasetId: parseInt(process.env.DATASET_ID!),
model: 'anthropic.claude-sonnet-4.5' as const,
batchSize: 10,
maxRetries: 3,
};
// Initialize API
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY!,
});
// Schema for the enrichment output
const enrichmentSchema = {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['retail', 'finance', 'healthcare', 'technology', 'other'],
description: 'Primary category for this record',
},
confidence: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'Confidence score for the classification',
},
tags: {
type: 'array',
items: { type: 'string' },
maxItems: 5,
description: 'Relevant tags for this record',
},
summary: {
type: 'string',
maxLength: 200,
description: 'Brief summary of the record content',
},
},
required: ['category', 'confidence', 'tags', 'summary'],
};
/**
* Wait for an inference job to complete
*/
async function waitForInference(jobId: string, maxWaitMs = 60000): Promise {
const startTime = Date.now();
const pollInterval = 2000;
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
if (job.state === 'completed' && job.result) {
return job.result.structured_output as EnrichmentResult;
}
if (job.state === 'failed') {
console.error('Inference job failed:', job.failures);
return null;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Job ${jobId} timed out`);
}
/**
* Enrich a single record using inference
*/
async function enrichRecord(record: RecordToEnrich): Promise {
const prompt = `Analyze and categorize this record:
ID: ${record.id}
Content: ${record.text}
Classify into one of: retail, finance, healthcare, technology, or other.
Extract relevant tags and provide a brief summary.`;
try {
const job = await api.runModelInference({
data_plane_id: CONFIG.dataPlaneId,
model: CONFIG.model,
messages: [
{
role: 'system',
content: [
{
type: 'text',
text: 'You are a data classification assistant. Analyze records and provide structured classifications.',
},
],
},
{
role: 'user',
content: [{ type: 'text', text: prompt }],
},
],
inference_config: {
output_format_schema: enrichmentSchema,
temperature: 0.3, // Lower temperature for consistent classifications
},
tags: ['enrichment', `dataset-${CONFIG.datasetId}`],
});
const result = await waitForInference(job.id);
if (result) {
return {
...record,
enrichment: result,
enriched_at: new Date().toISOString(),
};
}
return null;
} catch (error) {
console.error(`Failed to enrich record ${record.id}:`, error);
return null;
}
}
/**
* Process records in batches with rate limiting
*/
async function processBatch(records: RecordToEnrich[]): Promise {
const results: EnrichedRecord[] = [];
// Process in parallel within the batch
const promises = records.map(async (record) => {
const enriched = await enrichRecord(record);
if (enriched) {
results.push(enriched);
}
return enriched;
});
await Promise.all(promises);
return results;
}
/**
* Main enrichment pipeline
*/
async function runEnrichmentPipeline(): Promise {
console.log('Starting enrichment pipeline...');
// Step 1: Fetch records from the dataset
console.log(`Fetching sample from dataset ${CONFIG.datasetId}...`);
const sample = await api.getDatasetSample(CONFIG.datasetId, 100);
const records = sample.records as RecordToEnrich[];
console.log(`Found ${records.length} records to process`);
// Step 2: Process in batches
const allEnriched: EnrichedRecord[] = [];
for (let i = 0; i < records.length; i += CONFIG.batchSize) {
const batch = records.slice(i, i + CONFIG.batchSize);
const batchNumber = Math.floor(i / CONFIG.batchSize) + 1;
const totalBatches = Math.ceil(records.length / CONFIG.batchSize);
console.log(`Processing batch ${batchNumber}/${totalBatches}...`);
const enriched = await processBatch(batch);
allEnriched.push(...enriched);
console.log(` Enriched ${enriched.length}/${batch.length} records`);
// Rate limiting between batches
if (i + CONFIG.batchSize < records.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Step 3: Report results
console.log('\n=== Enrichment Complete ===');
console.log(`Total records processed: ${records.length}`);
console.log(`Successfully enriched: ${allEnriched.length}`);
console.log(`Failed: ${records.length - allEnriched.length}`);
// Category distribution
const categoryCount = allEnriched.reduce((acc, r) => {
acc[r.enrichment.category] = (acc[r.enrichment.category] || 0) + 1;
return acc;
}, {} as Record);
console.log('\nCategory distribution:');
Object.entries(categoryCount)
.sort((a, b) => b[1] - a[1])
.forEach(([category, count]) => {
console.log(` ${category}: ${count}`);
});
// Average confidence
const avgConfidence = allEnriched.reduce((sum, r) => sum + r.enrichment.confidence, 0) / allEnriched.length;
console.log(`\nAverage confidence: ${(avgConfidence * 100).toFixed(1)}%`);
}
// Run the pipeline
runEnrichmentPipeline()
.then(() => console.log('\nPipeline finished successfully'))
.catch((error) => {
console.error('Pipeline failed:', error);
process.exit(1);
});
```
## How it works
1. **Fetch records**: The pipeline retrieves sample records from your dataset using `getDatasetSample()`
2. **Enrich with inference**: Each record is sent to Model Inference with a prompt asking for classification and tagging
3. **Structured output**: The JSON Schema ensures every response includes category, confidence, tags, and summary
4. **Batch processing**: Records are processed in parallel batches with rate limiting between batches
5. **Error handling**: Failed enrichments are tracked and reported without stopping the pipeline
## Customizing the recipe
### Different enrichment tasks
Change the schema and prompt for your use case:
```typescript theme={null}
// Sentiment analysis
const sentimentSchema = {
type: 'object',
properties: {
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
score: { type: 'number', minimum: -1, maximum: 1 },
key_phrases: { type: 'array', items: { type: 'string' } }
},
required: ['sentiment', 'score']
};
```
### Writing results back
Store enriched records in a new dataset:
```typescript theme={null}
// Create output dataset
const outputDataset = await api.createDataset({
name: 'enriched_records',
display_name: 'Enriched Records',
schema: { /* schema matching EnrichedRecord */ }
});
// Write enriched data
// ... upload enriched records
```
## Related content
Inference fundamentals
Advanced batch patterns
Monitor job status
Dataset operations
# Automating Data Workflows
Source: https://docs.narrative.io/cookbooks/sdk/typescript-automation
Build an automated data pipeline using the TypeScript SDK
This recipe demonstrates a complete automation workflow: uploading data, waiting for ingestion, running a query, and exporting results.
## What this recipe accomplishes
* Upload a CSV file to a dataset
* Monitor the ingestion job until completion
* Execute an NQL query against the ingested data
* Export results to a local file
## Prerequisites
* Node.js 18 or later
* TypeScript configured in your project
* A Narrative API key with read/write permissions
* An existing dataset to upload data to
## Complete example
Create a file called `data-pipeline.ts`:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
import fs from 'fs';
import path from 'path';
// Configuration
const DATASET_ID = 12345; // Replace with your dataset ID
const INPUT_FILE = './data/customer_events.csv';
const OUTPUT_FILE = './output/aggregated_events.csv';
// Initialize the SDK
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
/**
* Wait for a job to complete with exponential backoff
*/
async function waitForJob(jobId: string, maxWaitMs = 300000): Promise {
const startTime = Date.now();
let pollInterval = 2000;
const maxInterval = 30000;
console.log(`Waiting for job ${jobId}...`);
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
console.log(` Status: ${job.state}`);
if (job.state === 'completed') {
console.log('Job completed successfully');
return;
}
if (job.state === 'failed') {
throw new Error(`Job failed: ${JSON.stringify(job.failures)}`);
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
pollInterval = Math.min(pollInterval * 1.5, maxInterval);
}
throw new Error(`Job ${jobId} timed out after ${maxWaitMs}ms`);
}
/**
* Upload a file to a dataset
*/
async function uploadFile(datasetId: number, filePath: string): Promise {
const fileName = path.basename(filePath);
const fileContent = fs.readFileSync(filePath);
console.log(`Uploading ${fileName}...`);
// Get upload URL
const uploadUrl = await api.getUploadUrl(datasetId, fileName);
// Upload to S3
await api.uploadFileToS3(uploadUrl.url, fileContent, uploadUrl.headers);
// Confirm upload
await api.confirmUpload(datasetId, uploadUrl.upload_id);
console.log(`Upload confirmed: ${uploadUrl.upload_id}`);
return uploadUrl.upload_id;
}
/**
* Run an aggregation query
*/
async function runQuery(datasetId: number): Promise {
console.log('Running aggregation query...');
const result = await api.executeNql({
nql: `
SELECT
sha256_hashed_email,
COUNT(1) as event_count,
MAX(event_timestamp) as last_seen,
SUM(event_value) as total_value
FROM company_data."dataset_${datasetId}"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY sha256_hashed_email
ORDER BY event_count DESC
LIMIT 10000
`,
data_plane_id: null,
});
if (result.state === 'failed') {
throw new Error(`Query failed: ${JSON.stringify(result.failures)}`);
}
console.log(`Query completed: ${result.result.rows} rows`);
// For this example, we'll return mock data since actual row data
// would come from the result or a subsequent data retrieval
return [];
}
/**
* Export results to CSV
*/
function exportToCsv(data: any[], outputPath: string): void {
if (data.length === 0) {
console.log('No data to export');
return;
}
const headers = Object.keys(data[0]);
const rows = data.map(row =>
headers.map(h => String(row[h] ?? '')).join(',')
);
const csv = [headers.join(','), ...rows].join('\n');
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, csv);
console.log(`Exported ${data.length} rows to ${outputPath}`);
}
/**
* Main pipeline function
*/
async function runPipeline() {
console.log('Starting data pipeline...\n');
try {
// Step 1: Upload data
console.log('=== Step 1: Upload Data ===');
if (fs.existsSync(INPUT_FILE)) {
const uploadId = await uploadFile(DATASET_ID, INPUT_FILE);
// Wait for ingestion (if there's an associated job)
// Note: You may need to track the ingestion job separately
console.log('Upload complete\n');
} else {
console.log(`Skipping upload: ${INPUT_FILE} not found\n`);
}
// Step 2: Run query
console.log('=== Step 2: Run Query ===');
const queryResults = await runQuery(DATASET_ID);
console.log('Query complete\n');
// Step 3: Export results
console.log('=== Step 3: Export Results ===');
exportToCsv(queryResults, OUTPUT_FILE);
console.log('Export complete\n');
console.log('Pipeline finished successfully!');
} catch (error) {
console.error('Pipeline failed:', error);
process.exit(1);
}
}
// Run the pipeline
runPipeline();
```
## Running the pipeline
Set your environment variable and run:
```bash theme={null}
export NARRATIVE_API_KEY="your-api-key-here"
npx ts-node data-pipeline.ts
```
## Scheduling with cron
To run automatically, add to crontab:
```bash theme={null}
# Run daily at 2 AM
0 2 * * * cd /path/to/project && NARRATIVE_API_KEY="your-key" npx ts-node data-pipeline.ts >> /var/log/pipeline.log 2>&1
```
Or use Node.js scheduling:
```typescript theme={null}
import cron from 'node-cron';
// Run every day at 2 AM
cron.schedule('0 2 * * *', () => {
runPipeline();
});
```
## Adding error notifications
Send alerts on failure:
```typescript theme={null}
async function runPipelineWithNotification() {
try {
await runPipeline();
await sendNotification('Pipeline completed successfully');
} catch (error) {
await sendNotification(`Pipeline failed: ${error.message}`);
throw error;
}
}
async function sendNotification(message: string) {
// Example: Send to Slack
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: message }),
});
}
```
## Parallel processing
Process multiple datasets concurrently:
```typescript theme={null}
async function processMultipleDatasets(datasetIds: number[]) {
const results = await Promise.allSettled(
datasetIds.map(async (id) => {
console.log(`Processing dataset ${id}...`);
const data = await runQuery(id);
exportToCsv(data, `./output/dataset_${id}.csv`);
return { id, rows: data.length };
})
);
const succeeded = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');
console.log(`Completed: ${succeeded.length}/${results.length}`);
if (failed.length > 0) {
console.error('Failed datasets:', failed);
}
}
```
## Handling incremental updates
Track last run time for incremental processing:
```typescript theme={null}
const STATE_FILE = './state/last_run.json';
function getLastRunTime(): string | null {
try {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
return state.lastRun;
} catch {
return null;
}
}
function saveLastRunTime(timestamp: string): void {
const dir = path.dirname(STATE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(STATE_FILE, JSON.stringify({ lastRun: timestamp }));
}
async function runIncrementalQuery(datasetId: number) {
const lastRun = getLastRunTime();
const now = new Date().toISOString();
const whereClause = lastRun
? `WHERE event_timestamp > '${lastRun}'`
: '';
const result = await api.executeNql({
nql: `
SELECT user_id, email, event_timestamp, event_value
FROM company_data."dataset_${datasetId}"
${whereClause}
LIMIT 100000
`,
data_plane_id: null,
});
saveLastRunTime(now);
return result;
}
```
## Related content
Get started with the SDK
Detailed upload guide
Monitor async operations
Query patterns and best practices
# Auto-generating Dataset Descriptions
Source: https://docs.narrative.io/cookbooks/use-cases/ai-dataset-descriptions
Use Model Inference to automatically generate human-readable dataset descriptions
This cookbook demonstrates how to use Model Inference to automatically generate descriptions for datasets based on their metadata and sample data. This is useful for documenting datasets, improving discoverability, and maintaining data catalogs.
## What this recipe accomplishes
* Fetch dataset metadata and column information
* Analyze sample data to understand content patterns
* Generate a comprehensive, human-readable description
* Update the dataset with the generated description
## Prerequisites
* SDK installed and configured (see [Authentication](/guides/sdk/authentication))
* A dataset that needs a description
* A data plane ID where inference will run
## Complete example
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
// Types for the generated description
interface GeneratedDescription {
title: string;
summary: string;
column_descriptions: Array<{
column_name: string;
data_type: string;
description: string;
sample_values: string;
}>;
use_cases: string[];
data_quality_notes: string[];
}
// Configuration
const CONFIG = {
dataPlaneId: process.env.DATA_PLANE_ID!,
datasetId: parseInt(process.env.DATASET_ID!),
model: 'anthropic.claude-sonnet-4.5' as const,
};
// Initialize API
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY!,
});
// Schema for the description output
const descriptionSchema = {
type: 'object',
properties: {
title: {
type: 'string',
maxLength: 100,
description: 'A clear, descriptive title for the dataset',
},
summary: {
type: 'string',
maxLength: 500,
description: 'A comprehensive summary of what this dataset contains and its purpose',
},
column_descriptions: {
type: 'array',
items: {
type: 'object',
properties: {
column_name: { type: 'string' },
data_type: { type: 'string' },
description: { type: 'string', maxLength: 200 },
sample_values: { type: 'string', maxLength: 100 },
},
required: ['column_name', 'data_type', 'description'],
},
description: 'Description of each column in the dataset',
},
use_cases: {
type: 'array',
items: { type: 'string' },
maxItems: 5,
description: 'Potential use cases for this dataset',
},
data_quality_notes: {
type: 'array',
items: { type: 'string' },
maxItems: 3,
description: 'Notes about data quality or considerations',
},
},
required: ['title', 'summary', 'column_descriptions', 'use_cases'],
};
/**
* Wait for inference job to complete
*/
async function waitForInference(jobId: string): Promise {
const maxWaitMs = 90000; // 90 seconds for potentially long analysis
const startTime = Date.now();
const pollInterval = 3000;
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
if (job.state === 'completed' && job.result) {
return job.result.structured_output as GeneratedDescription;
}
if (job.state === 'failed') {
console.error('Inference job failed:', job.failures);
return null;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Job ${jobId} timed out`);
}
/**
* Format sample data for the prompt
*/
function formatSampleForPrompt(
sample: Record[],
maxRows = 5
): string {
if (sample.length === 0) return 'No sample data available';
const rows = sample.slice(0, maxRows);
const columns = Object.keys(rows[0]);
let formatted = 'Sample rows:\n';
formatted += columns.join(' | ') + '\n';
formatted += columns.map(() => '---').join(' | ') + '\n';
rows.forEach(row => {
formatted += columns.map(col => {
const val = row[col];
const str = val === null ? 'NULL' : String(val);
return str.length > 30 ? str.substring(0, 27) + '...' : str;
}).join(' | ') + '\n';
});
return formatted;
}
/**
* Generate description for a dataset
*/
async function generateDatasetDescription(
datasetId: number
): Promise {
console.log(`Fetching metadata for dataset ${datasetId}...`);
// Step 1: Get dataset metadata
const dataset = await api.getDataset(datasetId);
// Step 2: Get sample data
console.log('Fetching sample data...');
const sample = await api.getDatasetSample(datasetId, 20);
const sampleRecords = sample.records as Record[];
// Step 3: Extract schema information
const schemaInfo = dataset.schema?.properties
? Object.entries(dataset.schema.properties).map(([name, prop]: [string, any]) => ({
name,
type: prop.type || 'unknown',
description: prop.description || '',
}))
: [];
// Step 4: Build the prompt
const prompt = `Analyze this dataset and generate a comprehensive description.
Dataset Name: ${dataset.name}
Display Name: ${dataset.display_name || 'Not set'}
Current Description: ${dataset.description || 'None'}
Schema (${schemaInfo.length} columns):
${schemaInfo.map(col => `- ${col.name} (${col.type}): ${col.description || 'No description'}`).join('\n')}
${formatSampleForPrompt(sampleRecords)}
Total rows in sample: ${sampleRecords.length}
Based on this information:
1. Generate a clear, descriptive title
2. Write a comprehensive summary explaining what this dataset contains
3. Describe each column based on its name, type, and sample values
4. Suggest potential use cases
5. Note any data quality observations`;
console.log('Running inference to generate description...');
// Step 5: Run inference
const job = await api.runModelInference({
data_plane_id: CONFIG.dataPlaneId,
model: CONFIG.model,
messages: [
{
role: 'system',
content: [
{
type: 'text',
text: `You are a data documentation expert. Analyze datasets and generate clear,
accurate descriptions that help users understand the data. Be specific and factual,
basing your analysis on the actual schema and sample data provided.`,
},
],
},
{
role: 'user',
content: [{ type: 'text', text: prompt }],
},
],
inference_config: {
output_format_schema: descriptionSchema,
max_tokens: 2000,
temperature: 0.3,
},
tags: ['description-generation', `dataset-${datasetId}`],
});
console.log(`Inference job created: ${job.id}`);
return await waitForInference(job.id);
}
/**
* Update dataset with generated description
*/
async function updateDatasetDescription(
datasetId: number,
description: GeneratedDescription
): Promise {
// Format the full description
const fullDescription = `${description.summary}
## Columns
${description.column_descriptions.map(col =>
`**${col.column_name}** (${col.data_type}): ${col.description}${col.sample_values ? ` Examples: ${col.sample_values}` : ''}`
).join('\n\n')}
## Use Cases
${description.use_cases.map(uc => `- ${uc}`).join('\n')}
${description.data_quality_notes?.length ? `
## Data Quality Notes
${description.data_quality_notes.map(note => `- ${note}`).join('\n')}` : ''}`;
console.log('Updating dataset with generated description...');
await api.updateDataset({
dataset_id: datasetId,
display_name: description.title,
description: fullDescription,
});
console.log('Dataset updated successfully');
}
/**
* Main function
*/
async function main(): Promise {
console.log('=== Dataset Description Generator ===\n');
const description = await generateDatasetDescription(CONFIG.datasetId);
if (!description) {
console.error('Failed to generate description');
process.exit(1);
}
console.log('\n=== Generated Description ===\n');
console.log(`Title: ${description.title}`);
console.log(`\nSummary:\n${description.summary}`);
console.log(`\nColumns:`);
description.column_descriptions.forEach(col => {
console.log(` - ${col.column_name}: ${col.description}`);
});
console.log(`\nUse Cases:`);
description.use_cases.forEach(uc => console.log(` - ${uc}`));
// Optionally update the dataset
const shouldUpdate = process.env.UPDATE_DATASET === 'true';
if (shouldUpdate) {
await updateDatasetDescription(CONFIG.datasetId, description);
} else {
console.log('\nSet UPDATE_DATASET=true to apply this description to the dataset');
}
}
// Run
main()
.then(() => console.log('\nDone'))
.catch((error) => {
console.error('Error:', error);
process.exit(1);
});
```
## How it works
1. **Fetch metadata**: The script retrieves the dataset's schema and existing metadata
2. **Get sample data**: A sample of records helps the model understand actual data patterns
3. **Build context**: Schema information and sample data are formatted into a comprehensive prompt
4. **Generate description**: Model Inference analyzes the data and generates structured documentation
5. **Update dataset**: The generated description can be applied back to the dataset
## Variations
### Batch processing multiple datasets
```typescript theme={null}
async function describeAllDatasets(): Promise {
const datasets = await api.getDatasets();
for (const dataset of datasets.records) {
if (!dataset.description || dataset.description.length < 50) {
console.log(`Processing: ${dataset.name}`);
const description = await generateDatasetDescription(dataset.id);
if (description) {
await updateDatasetDescription(dataset.id, description);
}
}
}
}
```
### Description quality scoring
```typescript theme={null}
const qualitySchema = {
type: 'object',
properties: {
completeness_score: { type: 'number', minimum: 0, maximum: 1 },
clarity_score: { type: 'number', minimum: 0, maximum: 1 },
improvements: { type: 'array', items: { type: 'string' } }
},
required: ['completeness_score', 'clarity_score']
};
```
## Related content
Inference fundamentals
Dataset operations
Define response schemas
How inference works
# Demographic Enrichment
Source: https://docs.narrative.io/cookbooks/use-cases/demographic-enrichment
Enrich customer lists with age and gender data using identity matching and automated refresh
A mobile gaming company has rapidly grown to 2 million users but knows very little about who those users are. Since the game doesn't require account registration, they lack basic demographic information needed for effective ad targeting and audience insights. Through Narrative, they can enrich their user base with age and gender data from identity providers, then automate monthly refreshes for new users.
## What you will build
This cookbook walks through creating a demographic enrichment pipeline that:
1. Uploads a seed dataset of mobile advertising IDs (MAIDs) from your customer base
2. Matches those MAIDs against demographic data providers using Rosetta Stone
3. Enriches records with raw age values (not pre-bucketed segments) and gender
4. Automates recurring enrichment for new users added each month
The result is a continuously refreshed customer dataset with demographic attributes that you own outright and can use across any platform.
## Prerequisites
Before starting, ensure you have:
* A dataset of customer identifiers (MAIDs, hashed emails, or other supported identifier types)
* Access to demographic data through data collaboration or marketplace providers
* Familiarity with [NQL syntax](/nql/general/syntax)
* Understanding of [materialized views](/concepts/nql/materialized-views)
* Understanding of [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids)
***
## Step 1: Upload your seed dataset
First, upload your customer identifier file to Narrative. This dataset serves as the seed list you want to enrich.
Your dataset should include at minimum:
* A unique identifier column (MAID, hashed email, etc.)
* An identifier type column if you have mixed identifier types
Example schema for a MAID-based seed file:
| Column | Type | Description |
| ------------------ | ------ | -------------------------------------------------------- |
| `maid` | string | Mobile advertising ID (IDFA or GAID) |
| `maid_type` | string | Identifier type: `idfa` or `gaid` |
| `internal_user_id` | string | Your internal user ID for joining back to source systems |
| `first_seen_date` | date | When the user first appeared in your system |
Upload your seed dataset through the Narrative UI or [SDK](/guides/sdk/uploading-data). Map the identifier column to the `unique_identifier` Rosetta Stone attribute for identity matching.
### Normalize identifiers with Rosetta Stone
Ensure your identifier column is mapped to Rosetta Stone's `unique_identifier` attribute:
```sql theme={null}
-- Verify your dataset has Rosetta Stone mappings
SELECT
seed."_rosetta_stone"."unique_identifier"."value" AS maid,
seed."_rosetta_stone"."unique_identifier"."type" AS maid_type,
seed.internal_user_id
FROM company_data."your_seed_dataset" seed
LIMIT 10
```
***
## Step 2: Identify available demographic sources
Query the Rosetta Stone to understand which demographic attributes are available across your data collaborations:
```sql theme={null}
-- Preview available demographic data
SELECT
rs."unique_identifier"."type" AS id_type,
rs."demographics"."age" AS age,
rs."demographics"."gender" AS gender,
COUNT(1) AS record_count
FROM narrative.rosetta_stone rs
WHERE
rs."demographics"."age" IS NOT NULL
OR rs."demographics"."gender" IS NOT NULL
GROUP BY
rs."unique_identifier"."type",
rs."demographics"."age",
rs."demographics"."gender"
LIMIT 100
```
This shows you the identifier types and demographic coverage across available providers.
***
## Step 3: Match and enrich with demographics
Create a materialized view that joins your seed dataset with demographic data through Rosetta Stone identity matching.
```sql theme={null}
CREATE MATERIALIZED VIEW "customers_demographics_enriched"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Customer Demographics - Enriched'
DESCRIPTION = 'Seed customer list enriched with age and gender from data providers'
TAGS = ('enrichment', 'demographics', 'audience')
WRITE_MODE = 'overwrite'
AS
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."value" AS maid,
seed."_rosetta_stone"."unique_identifier"."type" AS maid_type,
seed.first_seen_date,
demo."demographics"."age" AS age,
demo."demographics"."gender" AS gender,
demo."_ni_event_timestamp" AS demo_observed_at
FROM company_data."your_seed_dataset" seed
INNER JOIN narrative.rosetta_stone demo
ON seed."_rosetta_stone"."unique_identifier"."value" = demo."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = demo."unique_identifier"."type"
WHERE
demo."demographics"."age" IS NOT NULL
AND demo."demographics"."age" BETWEEN 13 AND 100
AND demo."event_timestamp" > CURRENT_TIMESTAMP - INTERVAL '180' DAY
```
**Key patterns used:**
| Pattern | Purpose |
| ------------------------------------------------------ | ------------------------------------------------- |
| `INNER JOIN narrative.rosetta_stone` | Matches seed identifiers to demographic records |
| `unique_identifier.value` and `unique_identifier.type` | Ensures exact identifier matching |
| `demographics.age BETWEEN 13 AND 100` | Filters to valid age ranges |
| `INTERVAL '180' DAY` | Uses recent demographic observations for accuracy |
| `REFRESH_SCHEDULE = '@monthly'` | Automates recurring enrichment |
Unlike pre-bucketed audience segments (e.g., "25-34"), Rosetta Stone provides raw age values. This gives you flexibility to create your own segments, perform precise analytics, or build custom targeting strategies.
***
## Step 4: Handle multiple demographic observations
A single identifier may have demographic data from multiple sources or observation times. To get the most recent or most common value:
### Most recent observation
```sql theme={null}
CREATE MATERIALIZED VIEW "customers_demographics_latest"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Customer Demographics - Latest Observation'
DESCRIPTION = 'Enriched demographics using most recent observation per customer'
TAGS = ('enrichment', 'demographics', 'deduplicated')
WRITE_MODE = 'overwrite'
AS
SELECT
internal_user_id,
maid,
maid_type,
first_seen_date,
age,
gender,
demo_observed_at
FROM (
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."value" AS maid,
seed."_rosetta_stone"."unique_identifier"."type" AS maid_type,
seed.first_seen_date,
demo."demographics"."age" AS age,
demo."demographics"."gender" AS gender,
demo."_ni_event_timestamp" AS demo_observed_at,
ROW_NUMBER() OVER (
PARTITION BY seed.internal_user_id
ORDER BY demo."_ni_event_timestamp" DESC
) AS row_num
FROM company_data."your_seed_dataset" seed
INNER JOIN narrative.rosetta_stone demo
ON seed."_rosetta_stone"."unique_identifier"."value" = demo."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = demo."unique_identifier"."type"
WHERE
demo."demographics"."age" IS NOT NULL
AND demo."demographics"."age" BETWEEN 13 AND 100
)
WHERE row_num = 1
```
### Consensus across sources
When you want the most common demographic value across multiple providers:
```sql theme={null}
CREATE MATERIALIZED VIEW "customers_demographics_consensus"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Customer Demographics - Consensus'
DESCRIPTION = 'Enriched demographics using most frequently observed values'
TAGS = ('enrichment', 'demographics', 'consensus')
WRITE_MODE = 'overwrite'
AS
WITH age_consensus AS (
SELECT
seed.internal_user_id,
demo."demographics"."age" AS age,
COUNT(1) AS age_count,
ROW_NUMBER() OVER (
PARTITION BY seed.internal_user_id
ORDER BY COUNT(1) DESC
) AS age_rank
FROM company_data."your_seed_dataset" seed
INNER JOIN narrative.rosetta_stone demo
ON seed."_rosetta_stone"."unique_identifier"."value" = demo."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = demo."unique_identifier"."type"
WHERE demo."demographics"."age" IS NOT NULL
GROUP BY seed.internal_user_id, demo."demographics"."age"
),
gender_consensus AS (
SELECT
seed.internal_user_id,
demo."demographics"."gender" AS gender,
COUNT(1) AS gender_count,
ROW_NUMBER() OVER (
PARTITION BY seed.internal_user_id
ORDER BY COUNT(1) DESC
) AS gender_rank
FROM company_data."your_seed_dataset" seed
INNER JOIN narrative.rosetta_stone demo
ON seed."_rosetta_stone"."unique_identifier"."value" = demo."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = demo."unique_identifier"."type"
WHERE demo."demographics"."gender" IS NOT NULL
GROUP BY seed.internal_user_id, demo."demographics"."gender"
)
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."value" AS maid,
seed."_rosetta_stone"."unique_identifier"."type" AS maid_type,
ac.age,
gc.gender
FROM company_data."your_seed_dataset" seed
LEFT JOIN age_consensus ac
ON seed.internal_user_id = ac.internal_user_id
AND ac.age_rank = 1
LEFT JOIN gender_consensus gc
ON seed.internal_user_id = gc.internal_user_id
AND gc.gender_rank = 1
```
***
## Step 5: Automate incremental enrichment
For growing customer bases, create an incremental view that only processes new users added since the last refresh.
### Create an incremental seed view
First, identify new users not yet in your enriched dataset:
```sql theme={null}
CREATE MATERIALIZED VIEW "customers_new_since_last_enrichment"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'New Customers - Pending Enrichment'
DESCRIPTION = 'Customers added since last demographic enrichment'
TAGS = ('enrichment', 'incremental', 'staging')
WRITE_MODE = 'overwrite'
AS
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."value" AS maid,
seed."_rosetta_stone"."unique_identifier"."type" AS maid_type,
seed.first_seen_date
FROM company_data."your_seed_dataset" seed
WHERE NOT EXISTS (
SELECT 1
FROM company_data."customers_demographics_enriched" enriched
WHERE seed.internal_user_id = enriched.internal_user_id
)
```
### Enrich and merge new users
```sql theme={null}
CREATE MATERIALIZED VIEW "customers_demographics_incremental"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Customer Demographics - Incremental Update'
DESCRIPTION = 'Demographic enrichment for newly added customers'
TAGS = ('enrichment', 'demographics', 'incremental')
WRITE_MODE = 'append'
AS
SELECT
new_users.internal_user_id,
new_users.maid,
new_users.maid_type,
new_users.first_seen_date,
demo."demographics"."age" AS age,
demo."demographics"."gender" AS gender,
demo."_ni_event_timestamp" AS demo_observed_at
FROM company_data."customers_new_since_last_enrichment" new_users
INNER JOIN narrative.rosetta_stone demo
ON new_users.maid = demo."unique_identifier"."value"
AND new_users.maid_type = demo."unique_identifier"."type"
WHERE
demo."demographics"."age" IS NOT NULL
AND demo."demographics"."age" BETWEEN 13 AND 100
```
Use `WRITE_MODE = 'append'` for incremental views that add new records to an existing dataset without overwriting historical data.
***
## Step 6: Create audience segments
With enriched demographics, create targetable audience segments:
### Age-based segments
```sql theme={null}
CREATE MATERIALIZED VIEW "audience_segment_age_buckets"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Audience Segments - Age Buckets'
DESCRIPTION = 'Customers segmented by age ranges for targeting'
TAGS = ('audience', 'segmentation', 'age')
WRITE_MODE = 'overwrite'
AS
SELECT
internal_user_id,
maid,
maid_type,
age,
gender,
CASE
WHEN age BETWEEN 18 AND 24 THEN '18-24'
WHEN age BETWEEN 25 AND 34 THEN '25-34'
WHEN age BETWEEN 35 AND 44 THEN '35-44'
WHEN age BETWEEN 45 AND 54 THEN '45-54'
WHEN age >= 55 THEN '55+'
ELSE 'unknown'
END AS age_segment
FROM company_data."customers_demographics_enriched"
WHERE age IS NOT NULL
```
### Gender-specific segments
```sql theme={null}
CREATE MATERIALIZED VIEW "audience_female_25_44"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Audience - Female 25-44'
DESCRIPTION = 'Female customers aged 25-44'
TAGS = ('audience', 'segmentation', 'female', '25-44')
WRITE_MODE = 'overwrite'
AS
SELECT
internal_user_id,
maid,
maid_type
FROM company_data."customers_demographics_enriched"
WHERE
gender = 'female'
AND age BETWEEN 25 AND 44
```
***
## Summary
You have built a demographic enrichment pipeline with these components:
| View | Purpose |
| ------------------------------------- | ------------------------------------------ |
| `customers_demographics_enriched` | Base enrichment with age and gender |
| `customers_demographics_latest` | Deduplicated using most recent observation |
| `customers_demographics_consensus` | Deduplicated using most common values |
| `customers_new_since_last_enrichment` | Identifies users pending enrichment |
| `customers_demographics_incremental` | Processes only new users |
| `audience_segment_age_buckets` | Age-based targeting segments |
### Use cases
* **Media targeting:** Reach specific demographic segments with relevant creative
* **Audience analytics:** Understand the composition of your customer base
* **Lookalike modeling:** Build seed audiences based on demographic profiles
* **Personalization:** Tailor app experiences or content by age group
* **Attribution:** Analyze conversion rates across demographic segments
### Key advantages
Receive exact age values instead of pre-defined segments, giving you flexibility to create custom ranges
Data is enriched once and can be used across any platform or use case without per-use fees
Monthly schedules keep your enriched data current as new users join
See which providers contribute demographic data and evaluate match quality
### Important considerations
**MAID availability:** iOS App Tracking Transparency and Android privacy changes have reduced MAID availability. Consider supplementing with hashed email or other identifier types for broader coverage. See [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids) for details.
**Privacy compliance:** Demographic data may be subject to GDPR, CCPA, and other privacy regulations depending on source and use case. Ensure your enrichment workflow complies with applicable requirements. See [CCPA compliance](/concepts/compliance/ccpa) and [GDPR compliance](/concepts/compliance/gdpr) for details.
***
## Related content
Learn JOIN patterns for enrichment queries
Step-by-step guide to automated views
Understanding IDFA, GAID, and privacy considerations
Connecting offline data to digital identifiers
# Evaluating Identity Data Providers
Source: https://docs.narrative.io/cookbooks/use-cases/identity-provider-evaluation
A structured framework for choosing between graph enrichment and addressability expansion providers and measuring their incremental value
An organization maturing its identity strategy faces a common challenge: dozens of third-party identity providers offer overlapping capabilities, and it is not always clear which providers deliver incremental value versus redundant coverage. This cookbook provides a structured evaluation process for choosing between [graph enrichment](/concepts/identifiers/graph-enrichment) and [addressability expansion](/concepts/identifiers/addressability) providers and measuring their contribution.
## What this cookbook covers
This cookbook walks through a five-step evaluation process:
1. Define whether you need graph enrichment, addressability expansion, or both
2. Baseline your current identity graph and match rates
3. Select providers for proof-of-concept testing
4. Measure each provider's incremental contribution
5. Evaluate commercial terms and build a layered strategy
The result is a repeatable framework you can apply each time you evaluate a new provider or renegotiate an existing relationship.
## Prerequisites
Before starting, ensure you have:
* Familiarity with [identity graph concepts](/concepts/identifiers/identity-graph)
* Understanding of [graph enrichment](/concepts/identifiers/graph-enrichment) and [addressability expansion](/concepts/identifiers/addressability)
* A first-party dataset with identifier mappings in Narrative
* Familiarity with [NQL syntax](/nql/general/syntax)
* Understanding of [materialized views](/concepts/nql/materialized-views)
***
## Step 1: Define your primary objective
Start by identifying which problem you are solving:
**Choose graph enrichment if:**
* Your audience segments feel fragmented—the same customers appear as multiple profiles
* Your frequency capping is ineffective because you cannot recognize users across devices
* Your attribution is unreliable because conversion paths break across identifier boundaries
* You need to improve person-level or household-level resolution before activation
**Choose addressability expansion if:**
* Your segmentation is sound but match rates on activation platforms are low
* You have strong first-party identity but limited identifier type diversity
* You are expanding to new activation channels (CTV, programmatic audio, digital out-of-home) that use identifier types you do not currently have
* You need immediate lift in campaign reach without changing your targeting logic
**Choose both if:**
* You have fragmented profiles (graph enrichment problem) AND low platform match rates (addressability problem)
* You are building identity infrastructure for the first time and need both resolution and reach
Most organizations eventually need both, but the sequencing matters. Graph enrichment typically comes first because better resolution improves the accuracy of everything downstream, including addressability expansion.
***
## Step 2: Baseline your current state
Before evaluating any provider, measure where you stand today.
### For graph enrichment: measure graph structure
```sql theme={null}
-- Count connected component sizes in your first-party graph
SELECT
component_size,
COUNT(1) AS component_count,
SUM(component_size) AS total_identifiers
FROM (
SELECT
seed.internal_user_id,
COUNT(DISTINCT seed."_rosetta_stone"."unique_identifier"."value") AS component_size
FROM company_data."first_party_graph" seed
GROUP BY seed.internal_user_id
)
GROUP BY component_size
ORDER BY component_size
```
Key metrics to record:
| Metric | What it tells you |
| -------------------------------- | --------------------------------------------------------------- |
| **Singleton percentage** | Share of profiles with only one identifier—these are unresolved |
| **Average component size** | How many identifiers per resolved profile |
| **Deterministic coverage** | Share of linkages based on direct observation vs. inference |
| **Identifier type distribution** | Which identifier types you have and which you lack |
### For addressability expansion: measure match rates
```sql theme={null}
-- Count identifiers by type in your first-party data
SELECT
seed."_rosetta_stone"."unique_identifier"."type" AS id_type,
COUNT(DISTINCT seed."_rosetta_stone"."unique_identifier"."value") AS unique_ids,
COUNT(DISTINCT seed.internal_user_id) AS unique_users
FROM company_data."crm_seed" seed
GROUP BY seed."_rosetta_stone"."unique_identifier"."type"
```
Record your current match rates on each activation platform you use. These become the baseline against which you measure provider lift.
***
## Step 3: Select providers for proof of concept
### For graph enrichment providers
Evaluate candidates on:
* **Linkage methodology.** Deterministic linkages from login events are more reliable than probabilistic linkages from behavioral signals. Understand each provider's methodology mix.
* **Update cadence.** Identity data has a shelf life. Providers that refresh linkages monthly are more valuable than those refreshing quarterly.
* **Incremental coverage.** A provider whose linkages overlap 90% with your existing graph adds less value than one with 40% overlap and 60% net new edges.
### For addressability expansion providers
Evaluate candidates on:
* **Identifier type coverage.** If you need MAIDs for programmatic and hashed phones for social, ensure the provider covers both.
* **Platform-specific match rates.** Ask providers for benchmark match rates on the specific platforms you activate on.
* **Freshness guarantees.** Appended identifiers that are six months old will have significantly lower match rates than those observed within the last 30 days.
### How to evaluate incremental vs. overlapping coverage
Use NQL to measure overlap between a candidate provider and your existing data:
```sql theme={null}
-- Measure identifier overlap with a candidate provider
SELECT
provider."_rosetta_stone"."unique_identifier"."type" AS id_type,
COUNT(DISTINCT provider."_rosetta_stone"."unique_identifier"."value") AS provider_total,
COUNT(DISTINCT CASE
WHEN seed."_rosetta_stone"."unique_identifier"."value" IS NOT NULL
THEN provider."_rosetta_stone"."unique_identifier"."value"
END) AS overlapping,
COUNT(DISTINCT CASE
WHEN seed."_rosetta_stone"."unique_identifier"."value" IS NULL
THEN provider."_rosetta_stone"."unique_identifier"."value"
END) AS net_new
FROM external_provider."candidate_identity_data" provider
LEFT JOIN company_data."first_party_graph" seed
ON provider."_rosetta_stone"."unique_identifier"."value" = seed."_rosetta_stone"."unique_identifier"."value"
AND provider."_rosetta_stone"."unique_identifier"."type" = seed."_rosetta_stone"."unique_identifier"."type"
GROUP BY provider."_rosetta_stone"."unique_identifier"."type"
```
***
## Step 4: Measure incremental contribution
Run a time-boxed proof of concept with each shortlisted provider and measure specific outcomes.
### For graph enrichment: measure structural improvement
| Metric | How to calculate | What good looks like |
| ------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| **Net new edges** | Count linkages the provider adds that you did not have | Higher is better, but quality matters more than volume |
| **Component size change** | Compare average component size before and after | Modest, consistent growth (not sudden large merges) |
| **Singleton reduction** | Change in percentage of single-identifier profiles | Direct measure of resolution improvement |
| **Cross-device reach** | Percentage of profiles with identifiers on 2+ device types | Measures practical multi-device capability |
### For addressability expansion: measure activation lift
| Metric | How to calculate | What good looks like |
| --------------------------------- | --------------------------------------------------------------------- | ------------------------------------------- |
| **Match rate lift by platform** | (New match rate - baseline match rate) / baseline match rate | Varies by platform; 20%+ lift is meaningful |
| **Incremental addressable users** | Users matchable after expansion minus users matchable before | The absolute reach improvement |
| **Cost per incremental match** | Provider cost / incremental matched users | Compare across providers for efficiency |
| **Identifier freshness** | Percentage of appended identifiers observed within last 30/60/90 days | Fresher identifiers match at higher rates |
### Measuring before and after in Narrative
Create materialized views that capture your baseline and post-enrichment states:
```sql theme={null}
-- Baseline: unique users matchable by identifier type
CREATE MATERIALIZED VIEW "identity_baseline"
DISPLAY_NAME = 'Identity Baseline - Pre-Enrichment'
DESCRIPTION = 'Baseline identifier coverage before provider enrichment'
TAGS = ('identity', 'evaluation', 'baseline')
WRITE_MODE = 'overwrite'
AS
SELECT
seed."_rosetta_stone"."unique_identifier"."type" AS id_type,
COUNT(DISTINCT seed.internal_user_id) AS unique_users,
COUNT(DISTINCT seed."_rosetta_stone"."unique_identifier"."value") AS unique_ids
FROM company_data."crm_seed" seed
GROUP BY seed."_rosetta_stone"."unique_identifier"."type"
```
```sql theme={null}
-- Post-enrichment: unique users matchable after provider data is incorporated
CREATE MATERIALIZED VIEW "identity_post_enrichment"
DISPLAY_NAME = 'Identity Post-Enrichment - Provider A'
DESCRIPTION = 'Identifier coverage after incorporating Provider A data'
TAGS = ('identity', 'evaluation', 'post-enrichment')
WRITE_MODE = 'overwrite'
AS
SELECT
combined_id_type AS id_type,
COUNT(DISTINCT internal_user_id) AS unique_users,
COUNT(DISTINCT combined_id_value) AS unique_ids
FROM (
SELECT
seed.internal_user_id,
seed."_rosetta_stone"."unique_identifier"."type" AS combined_id_type,
seed."_rosetta_stone"."unique_identifier"."value" AS combined_id_value
FROM company_data."crm_seed" seed
UNION
SELECT
seed.internal_user_id,
provider."_rosetta_stone"."unique_identifier"."type" AS combined_id_type,
provider."_rosetta_stone"."unique_identifier"."value" AS combined_id_value
FROM company_data."crm_seed" seed
INNER JOIN external_provider."provider_a_data" provider
ON seed."_rosetta_stone"."unique_identifier"."value" = provider."_rosetta_stone"."unique_identifier"."value"
AND seed."_rosetta_stone"."unique_identifier"."type" = provider."_rosetta_stone"."unique_identifier"."type"
)
GROUP BY combined_id_type
```
***
## Step 5: Evaluate commercial terms
Identity data pricing varies significantly. Consider:
| Factor | Graph enrichment considerations | Addressability expansion considerations |
| ------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| **Pricing model** | Flat licensing or per-linkage pricing; understand cost per incremental edge | CPM-based or per-record pricing; understand cost per incremental match |
| **Use case restrictions** | Some providers restrict derived-data usage or require separate licensing for analytics vs. activation | Some providers restrict which platforms you can activate on |
| **Refresh costs** | Ongoing costs for linkage updates; stale linkages degrade value | Ongoing costs for identifier refresh; churned identifiers waste spend |
| **Data sovereignty** | Where linkage data is stored and processed; relevant for GDPR/CCPA compliance | Whether appended identifiers can be stored or must be used in-flight |
| **Pass-through rights** | Whether you can use enriched profiles with downstream partners | Whether expanded identifiers can be shared with agency or platform partners |
***
## The layered identity strategy
Most mature identity strategies follow a phased approach:
### Phase 1: Graph foundation
Build your first-party graph with deterministic linkages from login events, CRM data, and transaction records. This is your highest-confidence data and forms the foundation for everything else.
### Phase 2: Addressability expansion
Once your graph foundation is solid, expand addressability to improve activation reach. This delivers immediate, measurable ROI through higher match rates and broader campaign reach.
### Phase 3: Graph enrichment
With addressability established, invest in graph enrichment to improve structural resolution. Better resolution improves segmentation accuracy, frequency capping, and attribution—benefits that compound over time.
### Phase 4: Continuous measurement
Identity is not a one-time project. Establish ongoing measurement of graph quality, match rates, and provider contribution. Re-evaluate providers annually as the identity landscape shifts with privacy regulations, platform changes, and new identifier standards.
***
## Summary
| Decision point | Key question | Primary metric |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------------------------- |
| **Enrichment vs. expansion** | Is your problem fragmented profiles or low match rates? | Singleton % vs. platform match rates |
| **Provider selection** | Does this provider add incremental value or redundant coverage? | Net new edges or net new matched users |
| **Quality validation** | Are new linkages or identifiers reliable? | Component size distribution or identifier freshness |
| **Commercial fit** | Does the pricing align with the incremental value delivered? | Cost per incremental edge or cost per incremental match |
***
## Related content
Core concepts behind identity graph structure
Strengthening graph structure with third-party linkage data
Improving activation reach by appending identifiers
Delivering audiences to destination platforms
A related pattern: enriching records with demographic attributes
# IP Address to MAID Enrichment
Source: https://docs.narrative.io/cookbooks/use-cases/maid-enrichment
Resolve IP addresses to mobile advertising IDs using Rosetta Stone for cross-device targeting and mobile campaign activation
A company has server logs or web analytics data containing IP addresses, but needs mobile advertising IDs (MAIDs) to activate audiences on mobile channels. By joining their IP dataset against Rosetta Stone, they can resolve IPs to MAIDs observed on the same network, rank them by observation frequency, and build targetable mobile audiences—all without querying individual data providers.
## What you will build
This cookbook walks through creating an IP-to-MAID enrichment pipeline that:
1. Uploads a dataset of IP addresses from your server logs or web analytics
2. Previews MAID data availability by type (IDFA vs GAID) across Rosetta Stone
3. Matches IPs to MAIDs using an identity join against Rosetta Stone
4. Ranks MAIDs by observation frequency to surface the most reliable matches
5. Automates recurring enrichment with a materialized view
The result is a continuously refreshed dataset that maps your IP addresses to mobile advertising IDs for cross-device targeting.
## Prerequisites
Before starting, ensure you have:
* A dataset of IP addresses uploaded to Narrative
* Familiarity with [NQL syntax](/nql/general/syntax)
* Understanding of [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids) (IDFA, GAID)
* Understanding of [IP addresses](/concepts/data-formats/ip-addresses) and their limitations
* Understanding of [materialized views](/concepts/nql/materialized-views)
***
## Step 1: Upload your IP address dataset
Upload your IP address file to Narrative. This dataset serves as the seed list you want to enrich with MAIDs.
Your dataset should include at minimum:
| Column | Type | Description |
| ------------- | --------- | --------------------------------------- |
| `ip` | string | IPv4 or IPv6 address |
| `observed_at` | timestamp | When the IP was observed in your system |
You may also include additional context columns (session ID, page URL, etc.) that you want to carry through to the enriched output.
Upload your dataset through the Narrative UI or [SDK](/guides/sdk/uploading-data). The `ip` column does not need a Rosetta Stone mapping—you will join it directly against the `"ip_address"` attribute in Rosetta Stone.
***
## Step 2: Preview IP-to-MAID data availability
Before running the full enrichment, query Rosetta Stone to understand how much MAID coverage is available by identifier type:
```sql theme={null}
SELECT
rs."mobile_id_unique_identifier"."type" AS maid_type,
APPROX_COUNT_DISTINCT(rs."ip_address") AS unique_ips,
APPROX_COUNT_DISTINCT(rs."mobile_id_unique_identifier"."value") AS unique_maids
FROM narrative.rosetta_stone rs
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
GROUP BY rs."mobile_id_unique_identifier"."type"
```
This shows you the relative volume of IDFA vs GAID data and helps set expectations for match rates.
GAID (Android) typically has higher volume than IDFA (iOS) due to Apple's App Tracking Transparency framework. See [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids) for details on availability trends.
***
## Step 3: Match IPs to MAIDs
Join your IP dataset against Rosetta Stone to find MAIDs observed on the same IP addresses. This is the core enrichment query:
```sql theme={null}
SELECT
cd."ip",
rs."mobile_id_unique_identifier"."value" AS maid,
rs."mobile_id_unique_identifier"."type" AS maid_type,
rs."iso_3166_1_country" AS country_code,
rs."event_timestamp" AS observed_at
FROM narrative.rosetta_stone rs
INNER JOIN company_data."your_ip_dataset" cd
ON rs."ip_address" = cd."ip"
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
```
**Key patterns used:**
| Pattern | Purpose |
| --------------------------------------------- | ----------------------------------------------- |
| `INNER JOIN company_data` | Matches only IPs present in your seed dataset |
| `mobile_id_unique_identifier.value` / `.type` | Extracts MAID value and type (IDFA or GAID) |
| `ip_address` | Rosetta Stone's normalized IP address attribute |
| `INTERVAL '45' DAY` | Limits to recent observations for freshness |
A single IP address may resolve to many MAIDs—especially shared or commercial IPs. Step 4 shows how to rank and limit results to the most reliable matches.
***
## Step 4: Rank MAIDs by observation frequency
A single IP can map to many devices, and not all associations are equally reliable. Use `QUALIFY` with `ROW_NUMBER` and a nested `APPROX_COUNT_DISTINCT` window function to keep only the top N most-frequently-observed MAIDs per IP:
```sql theme={null}
SELECT
cd."ip",
rs."mobile_id_unique_identifier"."value" AS maid,
rs."mobile_id_unique_identifier"."type" AS maid_type,
rs."iso_3166_1_country" AS country_code
FROM narrative.rosetta_stone rs
INNER JOIN company_data."your_ip_dataset" cd
ON rs."ip_address" = cd."ip"
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
QUALIFY ROW_NUMBER() OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip"
ORDER BY APPROX_COUNT_DISTINCT(rs."event_timestamp") OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip",
rs."mobile_id_unique_identifier"."value",
rs."mobile_id_unique_identifier"."type"
) DESC
) <= 3
```
**How the nested window functions work:**
1. The **inner window** (`APPROX_COUNT_DISTINCT ... OVER (PARTITION BY country, ip, maid, maid_type)`) counts distinct observation timestamps for each IP-MAID pair. MAIDs seen more often on an IP address get a higher count.
2. The **outer window** (`ROW_NUMBER ... OVER (PARTITION BY country, ip ORDER BY ... DESC)`) ranks MAIDs within each country + IP group by that observation count, highest first.
3. `QUALIFY ... <= 3` keeps only the top 3 most-frequently-observed MAIDs per IP per country.
This approach prioritizes devices that consistently appear on an IP address, filtering out transient connections.
Adjust the `<= 3` threshold based on your use case. For residential IPs, 1-3 MAIDs is typical. For commercial or CGNAT IPs shared across many users, consider filtering these out entirely or increasing the threshold.
***
## Step 5: Add country filtering and enrichment stats
Extend the query with geographic filtering and a `maid_count` column that shows the total unique MAIDs found per IP before ranking. This helps you assess match quality:
```sql theme={null}
SELECT
cd."ip",
rs."mobile_id_unique_identifier"."value" AS maid,
rs."mobile_id_unique_identifier"."type" AS maid_type,
rs."iso_3166_1_country" AS country_code,
APPROX_COUNT_DISTINCT(rs."mobile_id_unique_identifier"."value") OVER (
PARTITION BY cd."ip"
) AS maid_count
FROM narrative.rosetta_stone rs
INNER JOIN company_data."your_ip_dataset" cd
ON rs."ip_address" = cd."ip"
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
AND rs."iso_3166_1_country" IN ('US', 'CA', 'GB', 'AU')
QUALIFY ROW_NUMBER() OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip"
ORDER BY APPROX_COUNT_DISTINCT(rs."event_timestamp") OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip",
rs."mobile_id_unique_identifier"."value",
rs."mobile_id_unique_identifier"."type"
) DESC
) <= 3
```
The `maid_count` column reveals IP quality:
* **Low count (1-5):** Likely residential IPs with a small number of devices—high-confidence matches
* **High count (50+):** Likely commercial, VPN, or CGNAT IPs—consider excluding these from activation
### MAID type breakdown variation
To see the split between IDFA and GAID per IP, replace the `maid_count` window with a type-specific count:
```sql theme={null}
SELECT
cd."ip",
rs."mobile_id_unique_identifier"."value" AS maid,
rs."mobile_id_unique_identifier"."type" AS maid_type,
rs."iso_3166_1_country" AS country_code,
APPROX_COUNT_DISTINCT(
CASE WHEN rs."mobile_id_unique_identifier"."type" = 'idfa' THEN rs."mobile_id_unique_identifier"."value" END
) OVER (PARTITION BY cd."ip") AS idfa_count,
APPROX_COUNT_DISTINCT(
CASE WHEN rs."mobile_id_unique_identifier"."type" = 'gaid' THEN rs."mobile_id_unique_identifier"."value" END
) OVER (PARTITION BY cd."ip") AS gaid_count
FROM narrative.rosetta_stone rs
INNER JOIN company_data."your_ip_dataset" cd
ON rs."ip_address" = cd."ip"
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
AND rs."iso_3166_1_country" IN ('US', 'CA', 'GB', 'AU')
QUALIFY ROW_NUMBER() OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip"
ORDER BY APPROX_COUNT_DISTINCT(rs."event_timestamp") OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip",
rs."mobile_id_unique_identifier"."value",
rs."mobile_id_unique_identifier"."type"
) DESC
) <= 3
```
***
## Step 6: Automate with a materialized view
Wrap the final query in a materialized view that refreshes automatically:
```sql theme={null}
CREATE MATERIALIZED VIEW "ip_to_maid_enriched"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'IP to MAID Enrichment'
DESCRIPTION = 'IP addresses enriched with top mobile ad IDs ranked by observation frequency'
TAGS = ('enrichment', 'ip', 'maid', 'cross-device')
WRITE_MODE = 'overwrite'
AS
SELECT
cd."ip",
rs."mobile_id_unique_identifier"."value" AS maid,
rs."mobile_id_unique_identifier"."type" AS maid_type,
rs."iso_3166_1_country" AS country_code,
APPROX_COUNT_DISTINCT(rs."mobile_id_unique_identifier"."value") OVER (
PARTITION BY cd."ip"
) AS maid_count
FROM narrative.rosetta_stone rs
INNER JOIN company_data."your_ip_dataset" cd
ON rs."ip_address" = cd."ip"
WHERE
rs."mobile_id_unique_identifier"."value" IS NOT NULL
AND rs."ip_address" IS NOT NULL
AND rs."event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '45' DAY
AND rs."iso_3166_1_country" IN ('US', 'CA', 'GB', 'AU')
QUALIFY ROW_NUMBER() OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip"
ORDER BY APPROX_COUNT_DISTINCT(rs."event_timestamp") OVER (
PARTITION BY rs."iso_3166_1_country", cd."ip",
rs."mobile_id_unique_identifier"."value",
rs."mobile_id_unique_identifier"."type"
) DESC
) <= 3
```
Adjust the `REFRESH_SCHEDULE` based on how frequently your IP dataset changes. `@weekly` works well for server logs that update regularly. Use `@monthly` if your IP list is relatively static.
***
## Summary
You have built an IP-to-MAID enrichment pipeline with these components:
| Query / View | Purpose |
| ------------------------- | ---------------------------------------------------------------------- |
| Data availability preview | Assess MAID volume and type coverage before enrichment |
| IP-to-MAID join | Core matching of IPs to mobile ad IDs via Rosetta Stone |
| Frequency-ranked results | Top N MAIDs per IP based on observation count |
| `ip_to_maid_enriched` | Automated materialized view with country filtering and quality metrics |
### Use cases
* **Cross-device targeting:** Reach users on mobile who were previously only identifiable by IP
* **Mobile campaign activation:** Build MAID-based audiences from web traffic data for DSP activation
* **Device graph enrichment:** Augment your identity graph with IP-to-MAID linkages
* **Measurement and attribution:** Connect web sessions (IP) to mobile app activity (MAID)
### Key advantages
Rosetta Stone queries all available data providers automatically—no need to identify and UNION across individual datasets
Nested window functions surface the most reliable IP-MAID associations, filtering out transient connections
Weekly materialized views keep your MAID mappings current as new observations arrive
The `maid_count` column helps identify commercial or shared IPs that may produce low-quality matches
### Important considerations
**MAID availability:** iOS App Tracking Transparency and Android privacy changes have reduced MAID availability. GAID volume is significantly higher than IDFA. Consider supplementing with hashed email or other identifier types for broader iOS coverage. See [Mobile Ad IDs](/concepts/identifiers/mobile-ad-ids) for details.
**IP addresses as personal data:** Under GDPR, IP addresses are considered personal data. Ensure your enrichment workflow complies with applicable privacy regulations, particularly when processing IPs from EU users. See [GDPR compliance](/concepts/compliance/gdpr) and [CCPA compliance](/concepts/compliance/ccpa) for details.
***
## Related content
Understanding IDFA, GAID, and privacy considerations
IP address types, limitations, and best practices
Learn JOIN patterns for enrichment queries
Window functions, QUALIFY, and other advanced NQL patterns
# Converting Natural Language to CRON
Source: https://docs.narrative.io/cookbooks/use-cases/natural-language-to-cron
Use Model Inference to translate human-readable schedules into CRON expressions
This cookbook demonstrates how to use Model Inference to convert natural language schedule descriptions into valid CRON expressions. This is useful for materialized view refresh scheduling, report generation, and any feature where users need to specify schedules without knowing CRON syntax.
## What this recipe accomplishes
* Accept natural language schedule descriptions
* Convert them to valid CRON expressions
* Provide human-readable confirmation of the schedule
* Handle ambiguous inputs with clarification
## Prerequisites
* SDK installed and configured (see [Authentication](/guides/sdk/authentication))
* A data plane ID where inference will run
## Complete example
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
// Types for CRON conversion
interface CronConversionResult {
cron_expression: string;
human_readable: string;
timezone_note: string;
next_runs: string[];
confidence: number;
ambiguity_warning?: string;
}
// Configuration
const CONFIG = {
dataPlaneId: process.env.DATA_PLANE_ID!,
model: 'anthropic.claude-sonnet-4.5' as const,
};
// Initialize API
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY!,
});
// Schema for CRON conversion output
const cronSchema = {
type: 'object',
properties: {
cron_expression: {
type: 'string',
description: 'Valid CRON expression (5 fields: minute hour day month weekday)',
},
human_readable: {
type: 'string',
description: 'Human-readable description of when this schedule runs',
},
timezone_note: {
type: 'string',
description: 'Note about timezone assumptions',
},
next_runs: {
type: 'array',
items: { type: 'string' },
minItems: 3,
maxItems: 5,
description: 'Next 3-5 execution times in ISO format',
},
confidence: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'Confidence in the interpretation (0-1)',
},
ambiguity_warning: {
type: 'string',
description: 'Warning if the input was ambiguous',
},
},
required: ['cron_expression', 'human_readable', 'timezone_note', 'next_runs', 'confidence'],
};
/**
* Wait for inference job to complete
*/
async function waitForInference(jobId: string): Promise {
const maxWaitMs = 30000;
const startTime = Date.now();
const pollInterval = 2000;
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
if (job.state === 'completed' && job.result) {
return job.result.structured_output as CronConversionResult;
}
if (job.state === 'failed') {
console.error('Inference job failed:', job.failures);
return null;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Job ${jobId} timed out`);
}
/**
* Convert natural language to CRON expression
*/
async function convertToCron(
naturalLanguage: string,
timezone = 'UTC'
): Promise {
const currentDate = new Date().toISOString();
const prompt = `Convert this schedule description to a CRON expression:
"${naturalLanguage}"
Current date/time: ${currentDate}
Assumed timezone: ${timezone}
Requirements:
1. Use standard 5-field CRON format: minute hour day-of-month month day-of-week
2. Use * for "every", specific numbers for fixed times
3. For "weekdays", use 1-5. For "weekends", use 0,6
4. Calculate the next 3-5 run times based on the current date
5. If the input is ambiguous, note this and make a reasonable assumption
Common patterns:
- "every day at 9am" -> 0 9 * * *
- "every Monday at 8am" -> 0 8 * * 1
- "every hour" -> 0 * * * *
- "every 15 minutes" -> */15 * * * *
- "first of every month at midnight" -> 0 0 1 * *
- "weekdays at 6pm" -> 0 18 * * 1-5`;
const job = await api.runModelInference({
data_plane_id: CONFIG.dataPlaneId,
model: CONFIG.model,
messages: [
{
role: 'system',
content: [
{
type: 'text',
text: `You are an expert at CRON expressions. Convert natural language schedule
descriptions into valid CRON expressions. Be precise and handle edge cases carefully.
Always use 5-field CRON format (minute hour day month weekday).`,
},
],
},
{
role: 'user',
content: [{ type: 'text', text: prompt }],
},
],
inference_config: {
output_format_schema: cronSchema,
temperature: 0.1, // Low temperature for consistent, precise output
},
tags: ['cron-conversion'],
});
return await waitForInference(job.id);
}
/**
* Validate a CRON expression format
*/
function validateCronFormat(cron: string): { valid: boolean; error?: string } {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) {
return { valid: false, error: `Expected 5 fields, got ${fields.length}` };
}
const patterns = [
/^(\*|[0-5]?\d)(\/(([0-5]?\d)))?$|^(\*|[0-5]?\d)(,([0-5]?\d))*$|^([0-5]?\d)-([0-5]?\d)$/, // minute
/^(\*|[01]?\d|2[0-3])(\/(([01]?\d|2[0-3])))?$|^(\*|[01]?\d|2[0-3])(,([01]?\d|2[0-3]))*$|^([01]?\d|2[0-3])-([01]?\d|2[0-3])$/, // hour
/^(\*|\?|[1-9]|[12]\d|3[01])$/, // day of month
/^(\*|[1-9]|1[0-2])$/, // month
/^(\*|\?|[0-6])(,[0-6])*$|^[0-6]-[0-6]$/, // day of week
];
for (let i = 0; i < 5; i++) {
// Basic check - just verify non-empty for now
if (!fields[i] || fields[i].length === 0) {
return { valid: false, error: `Field ${i + 1} is empty` };
}
}
return { valid: true };
}
/**
* Interactive CRON converter
*/
async function interactiveConverter(): Promise {
const testSchedules = [
'every day at 9am',
'every Monday and Thursday at 2:30pm',
'first of every month at midnight',
'every 15 minutes during business hours on weekdays',
'every Sunday at 6pm',
'twice a day at 8am and 8pm',
];
console.log('=== Natural Language to CRON Converter ===\n');
for (const schedule of testSchedules) {
console.log(`Input: "${schedule}"`);
const result = await convertToCron(schedule);
if (result) {
const validation = validateCronFormat(result.cron_expression);
console.log(`CRON: ${result.cron_expression}`);
console.log(`Means: ${result.human_readable}`);
console.log(`Valid: ${validation.valid ? 'Yes' : `No - ${validation.error}`}`);
console.log(`Confidence: ${(result.confidence * 100).toFixed(0)}%`);
if (result.ambiguity_warning) {
console.log(`Warning: ${result.ambiguity_warning}`);
}
console.log('Next runs:');
result.next_runs.forEach(run => console.log(` - ${run}`));
} else {
console.log('Failed to convert');
}
console.log('');
}
}
/**
* Use with materialized view scheduling
*/
async function scheduleViewRefresh(
datasetId: number,
scheduleDescription: string
): Promise {
console.log(`Setting refresh schedule for dataset ${datasetId}`);
console.log(`Schedule: "${scheduleDescription}"`);
const result = await convertToCron(scheduleDescription);
if (!result) {
throw new Error('Failed to convert schedule');
}
const validation = validateCronFormat(result.cron_expression);
if (!validation.valid) {
throw new Error(`Invalid CRON expression: ${validation.error}`);
}
if (result.confidence < 0.8) {
console.warn(`Low confidence (${(result.confidence * 100).toFixed(0)}%) - please verify:`);
console.warn(` CRON: ${result.cron_expression}`);
console.warn(` Means: ${result.human_readable}`);
if (result.ambiguity_warning) {
console.warn(` Note: ${result.ambiguity_warning}`);
}
}
// Here you would apply the CRON to the materialized view
// await api.updateMaterializedViewSchedule(datasetId, result.cron_expression);
console.log(`Schedule set: ${result.cron_expression}`);
console.log(`This means: ${result.human_readable}`);
}
// Run the interactive converter
interactiveConverter()
.then(() => console.log('Done'))
.catch((error) => {
console.error('Error:', error);
process.exit(1);
});
```
## How it works
1. **Natural language input**: User provides a schedule description in plain English
2. **Context building**: The prompt includes the current date and examples of CRON patterns
3. **Inference**: Model Inference converts the description to a structured response
4. **Validation**: The CRON expression is validated for correct format
5. **Confirmation**: Human-readable description and next run times provide verification
## Common schedule patterns
| Natural Language | CRON Expression |
| -------------------------- | --------------- |
| Every day at 9am | `0 9 * * *` |
| Every Monday at 8am | `0 8 * * 1` |
| Every hour | `0 * * * *` |
| Every 15 minutes | `*/15 * * * *` |
| First of month at midnight | `0 0 1 * *` |
| Weekdays at 6pm | `0 18 * * 1-5` |
| Every Sunday at noon | `0 12 * * 0` |
## Handling ambiguity
The model includes confidence scores and warnings for ambiguous inputs:
```typescript theme={null}
if (result.confidence < 0.8) {
// Ask user for confirmation
console.log('Please confirm this interpretation:');
console.log(` "${scheduleDescription}" -> ${result.cron_expression}`);
console.log(` Which means: ${result.human_readable}`);
}
if (result.ambiguity_warning) {
// Show the assumption made
console.log(`Note: ${result.ambiguity_warning}`);
}
```
## Related content
Inference fundamentals
Define response schemas
Schedule view refreshes
Create and manage views
# Retail Competitive Intelligence
Source: https://docs.narrative.io/cookbooks/use-cases/retail-collaboration
Build audience segments from competitor store visitors using location data, frequency analysis, and purchase history enrichment
A national retailer is expanding to the West Coast and wants to understand their potential customers before opening new stores. Through Narrative, they can identify shoppers at competitor locations, segment them by loyalty, and enrich their profiles with purchase behavior—all without having any first-party data in those markets.
## What you will build
This cookbook walks through creating a competitive intelligence pipeline that:
1. Identifies mobile device IDs (MAIDs) observed at competitor store locations in California, Nevada, and Oregon
2. Segments visitors into loyalty tiers based on visit frequency (loyal, casual, one-time)
3. Enriches loyal competitor shoppers with CPG purchase history (groceries, personal care, household goods)
The result is targetable audience segments for conquest marketing campaigns.
## Prerequisites
Before starting, ensure you have:
* Access to location data containing `geo_coordinates` and `unique_identifier` (MAID) attributes
* Access to purchase transaction data with category information
* Familiarity with [NQL syntax](/nql/general/syntax)
* Understanding of [materialized views](/concepts/nql/materialized-views)
## Step 1: Define competitor store locations
First, create a reference dataset of competitor store locations. This dataset contains the latitude/longitude coordinates for each store, which you will use as geofence centers.
```sql theme={null}
CREATE MATERIALIZED VIEW "competitor_store_locations"
REFRESH_SCHEDULE = '@monthly'
DISPLAY_NAME = 'Competitor Store Locations - West Coast'
DESCRIPTION = 'Geofenced competitor retail locations in CA, NV, OR'
TAGS = ('competitive_intelligence', 'location', 'west_coast')
WRITE_MODE = 'overwrite'
AS
SELECT
store_id,
brand_name,
latitude,
longitude,
region AS state
FROM company_data.store_directory
WHERE
brand_name IN ('CompetitorA', 'CompetitorB', 'CompetitorC')
AND region IN ('CA', 'NV', 'OR')
```
Replace `company_data.store_directory` with your actual store location dataset. You can also upload a CSV of competitor locations to create this reference table.
## Step 2: Identify competitor store visitors
Query the Rosetta Stone table to find mobile advertising IDs (IDFA/GAID) observed within proximity of competitor store coordinates over the past 90 days.
```sql theme={null}
CREATE MATERIALIZED VIEW "competitor_store_visitors"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'Competitor Store Visitors - Raw'
DESCRIPTION = 'MAIDs observed at competitor retail locations in past 90 days'
TAGS = ('competitive_intelligence', 'location', 'audience')
WRITE_MODE = 'overwrite'
PARTITIONED_BY event_date DAY
AS
SELECT
rs."unique_identifier"."value" AS maid,
rs."unique_identifier"."type" AS maid_type,
stores.store_id,
stores.brand_name,
stores.state,
CAST(rs."event_timestamp" AS TIMESTAMP) AS visit_timestamp,
DATE(rs."event_timestamp") AS event_date
FROM narrative.rosetta_stone rs
JOIN company_data."competitor_store_locations" stores
ON ABS(rs."geo_coordinates"."latitude" - stores.latitude) < 0.002
AND ABS(rs."geo_coordinates"."longitude" - stores.longitude) < 0.002
WHERE
rs."event_timestamp" > CURRENT_TIMESTAMP - INTERVAL '90' DAY
AND rs."unique_identifier"."type" IN ('idfa', 'gaid')
AND rs."unique_identifier"."value" IS NOT NULL
AND rs."geo_coordinates"."latitude" IS NOT NULL
AND rs."region" IN ('CA', 'NV', 'OR')
```
**Key patterns used:**
| Pattern | Purpose |
| -------------------------------------------- | -------------------------------------------------- |
| `unique_identifier.value` | Extracts the MAID value from the identifier object |
| `unique_identifier.type IN ('idfa', 'gaid')` | Filters to mobile advertising IDs only |
| `ABS(latitude - stores.latitude) < 0.002` | Proximity match (\~150-200 meters geofence) |
| `INTERVAL '90' DAY` | Rolling 90-day lookback window |
| `PARTITIONED_BY event_date DAY` | Optimizes queries filtering by date |
The proximity threshold of 0.002 degrees equals approximately 150-200 meters depending on latitude. Adjust this value based on store size and urban density. Larger stores or rural areas may warrant a larger radius.
## Step 3: Segment by visit frequency
Aggregate the visitor data to calculate how many times each MAID visited competitor stores, then segment into loyalty tiers.
```sql theme={null}
CREATE MATERIALIZED VIEW "competitor_visitors_segmented"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'Competitor Visitors - Loyalty Segments'
DESCRIPTION = 'MAIDs segmented by visit frequency: loyal (4+), casual (2-3), one-time (1)'
TAGS = ('competitive_intelligence', 'audience', 'segmentation')
WRITE_MODE = 'overwrite'
AS
SELECT
maid,
maid_type,
COUNT(DISTINCT visit_timestamp) AS total_visits,
COUNT(DISTINCT store_id) AS unique_stores_visited,
COUNT(DISTINCT brand_name) AS unique_brands_visited,
MIN(visit_timestamp) AS first_visit,
MAX(visit_timestamp) AS last_visit,
CASE
WHEN COUNT(DISTINCT visit_timestamp) >= 4 THEN 'loyal'
WHEN COUNT(DISTINCT visit_timestamp) BETWEEN 2 AND 3 THEN 'casual'
ELSE 'one_time'
END AS loyalty_segment
FROM company_data."competitor_store_visitors"
GROUP BY maid, maid_type
```
To create separate audiences for activation, you can also build segment-specific views:
### Loyal shoppers (4+ visits)
```sql theme={null}
CREATE MATERIALIZED VIEW "loyal_competitor_shoppers"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'Loyal Competitor Shoppers'
DESCRIPTION = 'MAIDs with 4+ visits to competitor stores'
TAGS = ('competitive_intelligence', 'audience', 'loyal')
WRITE_MODE = 'overwrite'
AS
SELECT
maid,
maid_type,
COUNT(DISTINCT visit_timestamp) AS total_visits,
MAX(visit_timestamp) AS last_visit
FROM company_data."competitor_store_visitors"
GROUP BY maid, maid_type
HAVING COUNT(DISTINCT visit_timestamp) >= 4
```
### Non-loyal shoppers (1-2 visits)
```sql theme={null}
CREATE MATERIALIZED VIEW "nonloyal_competitor_shoppers"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'Non-Loyal Competitor Shoppers'
DESCRIPTION = 'MAIDs with 1-2 visits to competitor stores'
TAGS = ('competitive_intelligence', 'audience', 'nonloyal')
WRITE_MODE = 'overwrite'
AS
SELECT
maid,
maid_type,
COUNT(DISTINCT visit_timestamp) AS total_visits,
MAX(visit_timestamp) AS last_visit
FROM company_data."competitor_store_visitors"
GROUP BY maid, maid_type
HAVING COUNT(DISTINCT visit_timestamp) <= 2
```
## Step 4: Enrich with purchase history
Join the loyal segment with purchase transaction data to understand CPG category affinity. This creates richer profiles for targeting.
```sql theme={null}
CREATE MATERIALIZED VIEW "loyal_shoppers_enriched"
REFRESH_SCHEDULE = '@weekly'
DISPLAY_NAME = 'Loyal Competitor Shoppers - Purchase Enriched'
DESCRIPTION = 'Loyal competitor shoppers with CPG purchase category flags'
TAGS = ('competitive_intelligence', 'audience', 'enriched')
WRITE_MODE = 'overwrite'
AS
SELECT
loyal.maid,
loyal.maid_type,
loyal.total_visits,
loyal.last_visit AS last_store_visit,
MAX(CASE WHEN purchases.purchase_category = 'groceries' THEN 1 ELSE 0 END) AS has_groceries,
MAX(CASE WHEN purchases.purchase_category = 'personal_care' THEN 1 ELSE 0 END) AS has_personal_care,
MAX(CASE WHEN purchases.purchase_category = 'household_goods' THEN 1 ELSE 0 END) AS has_household_goods,
COUNT(DISTINCT purchases.transaction_id) AS total_purchases,
SUM(purchases.transaction_amount) AS total_spend
FROM company_data."loyal_competitor_shoppers" loyal
LEFT JOIN company_data."purchase_transactions" purchases
ON loyal.maid = purchases."unique_identifier"."value"
AND purchases."event_timestamp" > CURRENT_TIMESTAMP - INTERVAL '180' DAY
GROUP BY
loyal.maid,
loyal.maid_type,
loyal.total_visits,
loyal.last_visit
```
This query:
* Joins loyal shoppers with purchase data over a 180-day window
* Pivots purchase categories into boolean flags for easy filtering
* Calculates aggregate purchase metrics (count and total spend)
* Uses `LEFT JOIN` to retain all loyal shoppers even if no purchase data exists
## Summary
You have built a competitive intelligence pipeline with four materialized views:
| View | Purpose |
| ------------------------------- | ----------------------------------------------- |
| `competitor_store_locations` | Reference data for competitor store geofences |
| `competitor_store_visitors` | Raw visitor log with MAIDs and visit timestamps |
| `competitor_visitors_segmented` | Visitors segmented by loyalty tier |
| `loyal_shoppers_enriched` | Loyal shoppers enriched with purchase behavior |
### Use cases
* **Conquest campaigns**: Target loyal competitor shoppers with promotional offers to drive store switching
* **Lookalike modeling**: Use enriched profiles as seed audiences for prospecting
* **Store placement analysis**: Understand which competitor locations draw specific shopper profiles
* **Assortment planning**: Align product mix with purchase category preferences of local shoppers
### Important considerations
**MAID availability**: iOS App Tracking Transparency and Android privacy changes have reduced MAID availability. Expect smaller audience sizes compared to historical baselines.
**Privacy compliance**: Location-based audiences may be subject to CCPA (California) and other privacy regulations. Ensure your use case complies with applicable data protection requirements. See [CCPA compliance](/concepts/compliance/ccpa) for details.
***
## Related content
Step-by-step guide to creating and managing materialized views
Complete reference for REFRESH\_SCHEDULE, PARTITIONED\_BY, and other options
Optimize spatial and identity joins for large datasets
# Create an Account
Source: https://docs.narrative.io/getting-started/create-account
Sign up for Narrative I/O and set up your workspace
In this tutorial, you'll create your Narrative I/O account and complete the registration process. By the end, you'll be ready to start uploading data.
## Prerequisites
* A valid email address, or
* A Google, GitHub, or LinkedIn account for single sign-on
## What you'll learn
* How to sign up for Narrative I/O
* How to verify your email via magic link (for email signups)
* How to complete your profile
## Steps
Navigate to [app.narrative.io](https://app.narrative.io) to access the sign-up page.
You have two options to create your account:
* **Email**: Enter your email address and click **Continue with email**
* **Single sign-on**: Click **Continue with Google**, **Continue with GitHub**, or **Continue with LinkedIn**
If you sign up with an SSO provider, skip the next step and proceed directly to completing your profile.
If you signed up with an email address, you'll see a confirmation that a magic link was sent to your inbox.
Open your email and find the message from Narrative. Click **Continue** to verify your email address.
The magic link expires in 1 hour. If it expires, return to the sign-up page and request a new link.
Enter your name and the company you work for, then click **Complete Registration**.
Review the Terms of Service. Check the box to confirm you've read and agree to the terms, then click **Agree**.
You'll see a success message confirming your account was created. Click **Login** to access Narrative I/O.
For future logins with an email account, you'll receive a magic link each time—no password required.
## What you accomplished
* Created a Narrative I/O account using email or SSO
* Verified your email address (if using email signup)
* Completed your profile and accepted the Terms of Service
## Troubleshooting
| Issue | Solution |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| Magic link expired | Return to [app.narrative.io](https://app.narrative.io), enter your email, and request a new link |
| Email not received | Check your spam folder; add Narrative to your safe senders list |
| SSO login fails | Ensure you're using the correct provider account; try a different browser |
## Next steps
Add your first dataset to Narrative I/O
# Evaluate Your Mappings
Source: https://docs.narrative.io/getting-started/evaluate-mappings
Use AI to assess and improve your schema mappings
In this tutorial, you'll use AI-powered evaluation to assess your mapping quality and refine your normalizations.
This tutorial is part of the First Steps series. Complete [Normalize with Rosetta Stone](/getting-started/normalize-data) before starting.
This step is optional for getting started. Evaluating mappings is more commonly part of a production workflow when you want to refine and improve your normalizations over time.
## Prerequisites
* A Narrative I/O account
* At least one dataset with active Rosetta Stone mappings
## Steps
Use the workspace dropdown to switch organizations if needed. This keeps your normalized data organized by account.
Navigate to **Normalized Datasets** to review and manage your standardized tables.
Select the dataset you want to inspect. You can review its mappings, quality signals, and transformation setup.
Open the **Actions** menu to access evaluation and update options for this dataset.
Choose **Evaluate Mappings** to validate how columns map to standardized attributes and assess confidence levels.
Click **Evaluate Mappings** to refresh scoring and identify any fields that need attention.
Review the column normalization status to see which columns have been analyzed and which still need review.
Use the **AI** button to auto-analyze column normalizations and speed up the review process.
Open an expression to review the exact logic producing the output. This helps you understand how source values are being transformed.
Edit expressions to refine how values are parsed and formatted for consistency. You can adjust logic for timestamps, addresses, or any other field.
View all transformations to quickly audit every normalized field and its confidence score.
## Next steps
Deep dive into evaluation workflows
Advanced suggestion review techniques
Understand how scores are calculated
Manual testing techniques
# Write Your First Query
Source: https://docs.narrative.io/getting-started/first-nql-query
Create a filtered dataset using Query Builder in Data Studio
In this tutorial, you'll use Query Builder to create a filtered dataset from your data. Query Builder provides a visual interface for selecting data sources, choosing output fields, and applying filters—no code required.
This tutorial is part of the First Steps series. Complete [Normalize with Rosetta Stone](/getting-started/normalize-data) before starting.
## Prerequisites
* A Narrative I/O account
* At least one dataset in My Data
## What you'll learn
* How to access Data Studio
* How to use Query Builder to select data sources
* How to choose output fields
* How to add filters to narrow your data
* How to create and save a dataset
***
## Steps
From the navigation menu, go to **My Data** to view your available datasets. Then navigate to **Data Studio** to start creating a new dataset with Query Builder.
In Query Builder, click **Add** under **Sources** to choose where your dataset should pull data from. Select **My Data** as your source, then pick the dataset you want to work with. This dataset becomes the foundation for your filtered output.
Click **Add** under **Output** to control which columns appear in the final dataset. Click **Add field** to select each column you want to include. Choose the fields that are relevant to your use case—these might include identifiers, timestamps, or any attributes you need for analysis.
After selecting your fields, click **Continue** to confirm your output configuration.
Click **Add** under **Filter** to narrow your dataset to specific records. Choose a field to filter on, then select an operator (such as "Is greater than" or "Equals") and provide a value.
For date fields, you can use the calendar picker to select a specific cutoff date. Click **Add** to apply the filter condition, then confirm to finalize your filter logic.
Once your sources, outputs, and filters are configured, click **Create dataset**. Enter a unique name that makes your dataset easy to find and reference later. Click **Save** to generate the dataset from your query configuration.
Your new filtered dataset is now available in My Data for use in analysis, exports, or collaborations.
***
## Next steps
Learn to filter and transform your data
Explore the complete query syntax
Save query results for recurring use
# Normalize with Rosetta Stone
Source: https://docs.narrative.io/getting-started/normalize-data
Map your data to a standard schema for seamless collaboration
In this tutorial, you'll use Rosetta Stone to normalize your data. Normalization maps your unique schema to Narrative's standard format, enabling queries across datasets from different sources.
This tutorial is part of the First Steps series. Complete [Upload Your Data](/getting-started/upload-data) before starting.
## Prerequisites
* A Narrative I/O account
* At least one uploaded dataset (from the previous tutorial)
## Steps
Open the workspace menu to confirm you're normalizing data in the right account.
Navigate to **Normalized Datasets** to manage the standardized datasets used across your workflows.
Select the dataset you want to review and improve its attribute mappings.
Go to the **Normalize** tab to generate and apply suggested column-to-attribute mappings.
Click **Start analysis** to get AI-suggested normalization strategies for your fields.
Open expression mappings to verify how your data will be transformed. For example, address parts might be combined into a postal code, or values like "M" might be standardized to "male".
Click **Accept** to apply mappings that look correct. This ensures downstream workflows work from consistent fields.
Use **View** to quickly review all high-confidence mappings before applying them at scale.
Click **Accept all high confidence** to apply the best matches and save manual review time.
## Next steps
Query your normalized data with NQL
Understand the normalization model in depth
Advanced mapping techniques and API usage
Handle complex mapping scenarios
# Upload Your Data
Source: https://docs.narrative.io/getting-started/upload-data
Add your first dataset to Narrative I/O
In this tutorial, you'll upload your first dataset to Narrative I/O. By the end, your data will be ready for normalization and querying.
This tutorial is part of the First Steps series. Complete [Create an Account](/getting-started/create-account) before starting.
## Prerequisites
* A Narrative I/O account
* A CSV file to upload
Don't have a dataset handy? Download this [sample CSV of top domains](https://github.com/fffaraz/datasets/blob/master/top500Domains.csv) to follow along.
## What you'll learn
* How to navigate to the Datasets page
* How to upload a CSV file
* How to verify your dataset was created successfully
## Steps
In the left sidebar, click **My Data**, then select **Datasets**.
This takes you to [https://app.narrative.io/platform/my-data/datasets](https://app.narrative.io/platform/my-data/datasets), where you can see all your existing datasets and their statuses.
Click **New Dataset** to begin the upload process.
You'll land on the dataset creation page at [https://app.narrative.io/platform/my-data/new-dataset](https://app.narrative.io/platform/my-data/new-dataset). This is where you'll upload your file and configure your dataset.
Upload your CSV file using one of these methods:
* **Drag and drop**: Drag your file directly onto the upload area
* **File picker**: Click the upload area to browse and select your file
Once you select a file, the platform begins processing:
* The file uploads to Narrative's servers
* The system infers your schema from the data
* Initial validation checks run automatically
Narrative also supports JSON files. For connecting external data sources like cloud storage or databases, see the [Connectors reference](/reference/connectors).
After a successful upload, you're redirected to your dataset's overview page.
The URL follows this pattern: `https://app.narrative.io/platform/my-data/dataset/{id}#overview`
On this page you'll see:
* **Dataset name and metadata**: Basic information about your upload
* **Status indicator**: Confirms the dataset was created successfully
* **Navigation tabs**: Access to Overview, Schema, Records, and Mappings
Your dataset now exists in Narrative and is ready for the next step: normalization.
## Verify it worked
To confirm your upload succeeded:
1. Navigate back to **My Data** → **Datasets**
2. Find your new dataset in the list
3. Check that the status shows the upload is complete
4. Click into the dataset and select the **Records** tab to preview your data
You should see your CSV data displayed with the columns the system inferred from your file.
## What you accomplished
* Navigated to the dataset management interface
* Uploaded a CSV file as a new dataset
* Verified the dataset was created and is ready for normalization
## Troubleshooting
| Issue | Solution |
| ------------------------- | --------------------------------------------------------------- |
| Upload fails or times out | Check file size limits; try a smaller file or split into chunks |
| File format not supported | Ensure your file is a valid CSV or JSON format |
| Dataset shows 0 records | Verify the file has data rows, not just headers |
| Schema looks incorrect | Check that your CSV uses consistent delimiters and quoting |
## Next steps
Map your data to Narrative's standard schema
Set up connectors for cloud storage and databases
# Structuring Audiences for Activation
Source: https://docs.narrative.io/guides/activation/audience-strategies
Strategies for organizing your datasets to deliver audiences to advertising platforms
Before delivering audience data to a connector, you need to structure your data in datasets. This guide covers three common strategies for organizing your data, with recommendations for when to use each approach.
## Prerequisites
Before you begin:
* You have data with deliverable identifiers (hashed email, hashed phone, or mobile ad IDs)
* Your identifiers are properly formatted and hashed (see [Hashing PII for Upload](/guides/ingestion/hashing-pii))
* You understand which identifiers your destination requires (see [Data Activation Overview](/concepts/data-activation/overview))
***
## Strategy 1: One dataset per audience
Create a separate dataset for each audience you want to deliver. The dataset name and description correspond directly to the audience.
### Dataset structure
| Field | Description |
| --------------------- | ---------------------------------------------- |
| `sha256_hashed_email` | The hashed identifier for each audience member |
Example data:
```csv theme={null}
sha256_hashed_email
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3
a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd
b2c3d4e5f6789012345678901234567890123456789012345678901234bcde
```
### When to use this strategy
If your audience creation happens in external systems (CRM, CDP, marketing automation), then export each audience as a separate file for upload.
If you have automated pipelines that create datasets via the [SDK](/guides/sdk/managing-datasets), creating a new dataset per audience is straightforward.
If audiences don't change often, managing them as separate datasets keeps things simple.
### Advantages
* **Simplicity**: Each dataset maps directly to one audience
* **Clear ownership**: Easy to track which dataset powers which audience
* **Selective updates**: Update only the audiences that have changed
### Considerations
* Requires creating a new dataset for each new audience
* Can become difficult to manage with hundreds of audiences
* Duplicated records across datasets if users belong to multiple audiences
***
## Strategy 2: Master dataset with audience identifiers
Maintain a single dataset containing all audience members, with columns that identify which audience each record belongs to.
### Dataset structure
| Field | Description |
| --------------------- | ---------------------------------- |
| `sha256_hashed_email` | The hashed identifier |
| `audience_id` | Unique identifier for the audience |
| `audience_name` | Human-readable audience name |
Example data:
```csv theme={null}
sha256_hashed_email,audience_id,audience_name
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3,1001,high_value_customers
a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd,1001,high_value_customers
b2c3d4e5f6789012345678901234567890123456789012345678901234bcde,1002,recent_purchasers
c3d4e5f6789012345678901234567890123456789012345678901234cdef,1002,recent_purchasers
```
### Delivering specific audiences
Use NQL to filter for a specific audience when delivering:
```sql theme={null}
SELECT
sha256_hashed_email
FROM
company_123.master_audiences
WHERE
audience_id = '1001'
```
### When to use this strategy
If you have dozens or hundreds of audiences that share similar structures, a single dataset is easier to maintain.
Append new rows to a single dataset instead of managing uploads across many datasets.
Adding a new audience is as simple as appending rows with a new `audience_id`.
### Advantages
* **Centralized management**: One dataset to maintain
* **Easy audience additions**: New audiences are just new rows
* **Efficient updates**: Append new data without managing multiple datasets
### Considerations
* Requires NQL filtering to extract specific audiences
* Larger dataset size may affect query performance
* Need to maintain unique audience IDs
***
## Strategy 3: Raw data with dynamic audience creation
Upload a master dataset with all available customer attributes, then use NQL to define audiences dynamically based on any attribute combination.
### Dataset structure
| Field | Description |
| ---------------------------- | ---------------------------- |
| `sha256_hashed_email` | The hashed identifier |
| `age` | Customer age |
| `gender` | Customer gender |
| `estimated_household_income` | Income range or value |
| `last_purchase_date` | Date of most recent purchase |
| `lifetime_value` | Total customer value |
Example data:
```csv theme={null}
sha256_hashed_email,age,gender,estimated_household_income,last_purchase_date,lifetime_value
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3,34,male,85000,2024-01-15,1250.00
a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd,28,female,52000,2024-02-20,890.00
b2c3d4e5f6789012345678901234567890123456789012345678901234bcde,45,female,120000,2024-01-05,3200.00
```
### Creating audiences with NQL
Define audiences on-the-fly using any attribute combination:
**High-income females:**
```sql theme={null}
SELECT
sha256_hashed_email
FROM
company_123.customer_data
WHERE
gender = 'female'
AND estimated_household_income >= 75000
```
**Recent high-value customers:**
```sql theme={null}
SELECT
sha256_hashed_email
FROM
company_123.customer_data
WHERE
last_purchase_date >= '2024-01-01'
AND lifetime_value >= 1000
```
**Age-based segments:**
```sql theme={null}
SELECT
sha256_hashed_email
FROM
company_123.customer_data
WHERE
age BETWEEN 25 AND 44
```
### When to use this strategy
Create any audience segment without re-uploading data or pre-defining audiences.
Send data to Narrative with minimal transformation and let the platform handle segmentation.
Quickly iterate on audience criteria without modifying source data.
### Advantages
* **Maximum flexibility**: Define any audience criteria without changing data
* **Minimal preprocessing**: Upload raw data, segment later
* **Rapid iteration**: Test new audience definitions instantly
### Considerations
* Requires familiarity with NQL for audience creation
* Complex queries may take longer to execute on large datasets
* Attribute data must be normalized consistently
***
## Choosing a strategy
| Factor | Strategy 1 (Per Audience) | Strategy 2 (Master + IDs) | Strategy 3 (Raw Data) |
| ------------------------ | ------------------------- | ------------------------- | --------------------- |
| **Number of audiences** | Few (1-20) | Many (20-100+) | Variable/unknown |
| **Audience definitions** | Fixed | Fixed | Dynamic |
| **Data preprocessing** | Done externally | Partial | Minimal |
| **NQL required** | No | Basic filtering | Advanced queries |
| **Flexibility** | Low | Medium | High |
You can combine strategies. For example, use Strategy 3 for your core customer data while maintaining Strategy 1 datasets for specialized audiences from external sources.
***
## Next steps
After structuring your data:
1. **[Normalize your data](/guides/rosetta-stone/mapping-schemas)**: Map your fields to Rosetta Stone attributes so connectors recognize your identifiers
2. **Configure your connector**: Install and configure the connector for your destination platform
3. **Deliver your audiences**: Use the connector to push your data to the destination
***
## Related content
Understand how connectors deliver data to destinations
Learn about dataset structure and management
NQL basics for filtering audience data
Create and manage datasets with the SDK
# Building a look-alike audience
Source: https://docs.narrative.io/guides/audience-studio/building-a-lookalike-audience
Expand a seed audience by scoring a larger population for users that resemble it, using Look-alike Studio
This guide walks through building a look-alike audience in Look-alike Studio — from picking a seed audience and population dataset to selecting similarity attributes, sizing the output, and finalizing the audience.
A look-alike audience is a new audience derived from an existing **seed audience** by scoring a larger **population dataset** and keeping the users that most resemble the seed. Use it to prospect beyond your known customers without leaving the platform.
## Prerequisites
* An existing seed audience — typically a dataset created with [Audience Studio](/guides/audience-studio/building-an-audience) — with at least one identity attribute (such as `sha256_hashed_email` or `maid`) mapped via [Rosetta Stone](/guides/rosetta-stone/mapping-schemas)
* A separate population dataset on the same data plane, also with at least one identity attribute mapped via Rosetta Stone
* Both datasets must share at least one identity attribute so seed users can be matched against the population
The seed and population must be **different datasets**. Scoring an audience against itself would only return the seed users back.
## Step 1: Pick a seed audience
Navigate to **My Audiences** in the sidebar, then open **Look-alike Studio**.
Choose the existing audience the look-alike model will learn from. Only audiences on the currently selected data plane are shown.
Audiences without identity attributes appear under **Not available** — they have no join keys, so their users can't be matched against a population. Map an identity attribute via Rosetta Stone to use them as a seed.
If you don't have any audiences yet, create one in [Audience Studio](/guides/audience-studio/building-an-audience) first.
## Step 2: Pick a population dataset
The population is the pool of candidate users the model scores against the seed. Users in the population who most resemble the seed become the look-alike audience.
Choose a dataset of candidate users on the same data plane as the seed.
The seed audience itself is disabled — it can't double as the population. Datasets without identity attributes are also disabled, since seed users couldn't be matched within them.
## Step 3: Choose similarity attributes
Look-alike Studio classifies the population dataset's fields into:
* **Identity attributes** — Used as join keys to match seed users within the population. Required on both sides.
* **Feature attributes** — Used to measure similarity between users. You choose which features the model considers.
* **Metadata** — Non-predictive fields excluded from scoring.
Look-alike Studio shows the identity attributes available on the seed and population. If either side has no identity attributes, scoring isn't possible — return to Rosetta Stone and map at least one shared identifier on both datasets.
Pick the features the model should use to compare users — for example, age, interests, purchase categories, or geography. More features can improve quality but increase compute cost.
Use the search field to filter the attribute list by name or path.
Choose features that distinguish your seed users from the general population. Universally common attributes add noise without improving similarity scoring.
## Step 4: Configure the output
Decide how large the look-alike audience should be and whether the original seed users are kept.
Choose one:
* **Limit by size** — Keep the top *N* highest-scoring users from the population. Defaults to **10,000**.
* **Limit by score** — Keep every user whose similarity score meets or exceeds the threshold (0–100%). Defaults to **50%**.
Sizing by score produces a variable-size audience that grows or shrinks with the population's similarity distribution. Sizing by size produces a fixed-size audience regardless of distribution.
Pick one:
* **New users only** — The look-alike audience contains only matched population users. Use this for prospecting.
* **New + original seed users** — Adds the seed users back into the output. Use this when you want a single audience that combines known customers with prospects.
## Step 5: Finalize the audience
Enter:
* **Display name** — The human-readable name shown throughout the platform
* **Unique name** — Auto-slugified from the display name and deduped against existing dataset names. Shown read-only.
* **Description** — Optional context about the audience's purpose
* **Tags** — Optional labels for organization
The summary card recaps every choice you've made:
* Seed audience
* Population dataset
* Similarity attributes selected
* Output limit (size or score threshold)
* Whether seed users are included
If anything is wrong, return to the relevant step from the builder sidebar.
Click **Save details** to lock in the finalize step. The **Create** button becomes available once every step is complete.
Click **Create** to:
1. Run the look-alike scoring query against the population
2. Materialize the top-scoring users (and optionally the seed) as a new audience dataset
3. Add the dataset to **My Audiences**
The new audience can then be delivered to connectors from [Audience Studio](/guides/audience-studio/building-an-audience).
## After creation
A look-alike audience is a regular dataset, so you can:
* **Activate it** — Open it in [Audience Studio](/guides/audience-studio/building-an-audience) to deliver it to connectors
* **Tag or describe it** — Edit metadata from the dataset's detail view
* **Use it as a seed for another look-alike** — Chain models when you want to expand iteratively
## Related content
Build and activate audiences from your data
Create the seed audiences look-alike models learn from
Map identity and feature attributes so datasets can be matched and scored
Structure underlying data for effective activation
# Building an audience
Source: https://docs.narrative.io/guides/audience-studio/building-an-audience
Create a targeted audience from your data and deliver it to advertising platforms using Audience Studio
This guide walks through building an audience in Audience Studio — from selecting a data source and applying filters to choosing delivery destinations and creating the audience.
For a conceptual overview of how Audience Studio works, see [Audience Studio](/concepts/data-activation/audience-studio). For strategies on how to structure your underlying data, see [Structuring Audiences for Activation](/guides/activation/audience-strategies).
## Prerequisites
* A dataset ingested into the platform with [Rosetta Stone attribute mappings](/guides/rosetta-stone/mapping-schemas), including at least one deliverable identifier (such as `sha256_hashed_email` or `sha256_hashed_phone`)
* At least one [connector](/reference/connectors/index) installed with an active profile, if you want to deliver the audience to an external platform
## Step 1: Select a source
Navigate to **My Audiences** in the sidebar, then click **Create Audience** or **Open Audience Studio**.
Select the dataset that contains the data you want to segment. Only datasets on the currently selected data plane are shown.
The dataset must have Rosetta Stone attribute mappings — these determine which fields are available for filtering and which identifiers can be delivered to connectors.
## Step 2: Choose connectors
Select one or more destination platforms where you want to deliver the audience.
Audience Studio checks whether your source dataset contains the identifier types required by each connector. Eligible connectors are highlighted; ineligible ones show a warning explaining which identifiers are missing.
Enable the connectors you want to deliver to. You can select multiple destinations — the audience will be delivered to all of them.
Common destinations include [The Trade Desk](/reference/connectors/the-trade-desk), [Meta](/reference/connectors/meta), [TikTok](/reference/connectors/tiktok), [Google DV360](/reference/connectors/google-dv360), [Pinterest](/reference/connectors/pinterest), and [Amazon S3](/reference/connectors/amazon-s3).
## Step 3: Apply filters
Filters narrow your source data to the audience you want. The available filter controls are determined by your dataset's Rosetta Stone attribute mappings.
Click **Add Filter** and select the attribute you want to filter on. The filter attribute dropdown groups options into **Rosetta Stone** attributes and **Dataset Columns** for clarity.
Depending on the attribute type, you will see different controls:
* **Enum fields** show a checkbox list or searchable dropdown of values
* **Numeric fields** show a comparison operator selector and a value input
* **Timestamp fields** offer curated ranges (last 7/30/90 days), custom lookback, or a date range picker
* **Boolean fields** show a toggle
* **Null checks** — select **Is null** or **Is not null** to filter on the presence or absence of a value
Add additional filters as needed. By default, filters are combined with **AND** logic — all conditions must match. To use **OR** logic (any condition matches), group filters together.
You can also add **frequency** constraints to any filter — for example, "purchased at least 3 times."
Click **Calculate** (or enable auto-calculate) to preview how many records match your current filters. The forecast runs a preview query without creating the audience, helping you verify the size before committing.
Suggested filter values show what percentage of your forecasted audience matches each option, helping you understand the impact of each filter.
## Step 4: Finalize and create
Configure the audience details:
* **Name** (required) — Identifies the audience in the platform and destination systems
* **Description** — Optional context about the audience's purpose
* **Tags** — Optional labels for organization
* **Refresh schedule** — Choose **One-time** for a static audience, or set a recurring schedule (daily, weekly, etc.) to keep the audience current as source data changes
Each selected connector shows its **quick settings** panel. These are platform-specific fields required for delivery — for example:
* **The Trade Desk**: Audience name, advertiser selection, historical data delivery
* **Meta**: Ad account selection
* **TikTok**: Business Center and ad account selection
Fill in the required fields for each connector. A green checkmark indicates the connector is fully configured and validated.
Click **Create Audience**. Audience Studio:
1. Generates and executes an NQL query based on your filters
2. Materializes the results as a new audience dataset
3. Creates connector deliveries for each destination
4. If a refresh schedule is set, configures incremental updates to keep the audience current
The audience appears under **My Audiences** in the sidebar.
## After creation
Once your audience is created, you can:
* **Monitor delivery** — Check connector delivery status from the audience's Connections tab
* **Clone the audience** — Select **Open in Audience Studio** from the action menu to create a new audience pre-populated with the same source, filters, and settings
* **Add more connectors** — Deliver the same audience to additional destinations from the Connections tab
* **Adjust the refresh schedule** — Change how often the audience is rebuilt
## Related content
Conceptual overview of filter types, forecasting, and cloning
Strategies for organizing data for activation
Technical details and identifier requirements for each destination
Prepare identifiers for upload and activation
# Using Narrative ID
Source: https://docs.narrative.io/guides/collaboration/using-narrative-id
Encode and translate identifiers for privacy-safe data collaboration
This guide walks through the practical steps for working with Narrative IDs—encoding your identifiers and translating them for partner collaboration.
## Prerequisites
Before working with Narrative IDs, ensure you have:
* Access to the Narrative platform with appropriate permissions
* An encoding key ID for your organization's encoding space
* Partner encoding key IDs if you need to translate for collaboration
***
## Encoding identifiers
Encoding converts a clear text identifier (such as an email or hashed email) into a Narrative ID within your organization's encoding space.
### When to encode
Encode identifiers when you want to:
* Share data with partners without exposing raw identifiers
* Create privacy-safe join keys for cross-organization matching
* Prepare data for collaboration workflows
### Using NARRATIVE\_ID\_ENCODE
The `NARRATIVE_ID_ENCODE` function creates a Narrative ID from a clear text identifier.
**Syntax:**
```sql theme={null}
NARRATIVE_ID_ENCODE(raw_identifier, key_id)
```
**Parameters:**
| Parameter | Type | Description |
| ---------------- | ------ | ---------------------------------------------------------------------- |
| `raw_identifier` | string | The clear text identifier to encode (email, hashed email, phone, etc.) |
| `key_id` | string | Your organization's encoding key ID |
**Example: Encoding email addresses**
```sql theme={null}
SELECT
NARRATIVE_ID_ENCODE(LOWER(TRIM(email)), 'your_org_key_id') AS narrative_id,
event_timestamp,
event_type
FROM your_company."customer_events"
```
**Example: Encoding already-hashed identifiers**
If your data already contains hashed emails (SHA-256), you can still encode them:
```sql theme={null}
SELECT
NARRATIVE_ID_ENCODE(email_sha256, 'your_org_key_id') AS narrative_id,
purchase_amount,
purchase_date
FROM your_company."transactions"
```
Always normalize identifiers before encoding. For emails, use `LOWER(TRIM(email))` to ensure consistent matching.
### Creating a dataset with Narrative IDs
To prepare a dataset for sharing, encode the identifiers during a materialized view creation:
```sql theme={null}
CREATE MATERIALIZED VIEW your_company."customer_data_encoded" AS
SELECT
NARRATIVE_ID_ENCODE(LOWER(TRIM(email)), 'your_org_key_id') AS narrative_id,
age_bracket,
region,
interest_categories,
event_timestamp
FROM your_company."raw_customer_data"
```
***
## Translating between partners
Translation converts a Narrative ID from one partner's encoding space to another, enabling cross-organization matching without exposing the underlying identifiers.
### When to translate
Translate Narrative IDs when you need to:
* Share data with a specific partner for matching
* Query across datasets from different partners
* Enable a partner to join your data with their own
### Using NARRATIVE\_ID\_TRANSLATE
The `NARRATIVE_ID_TRANSLATE` function converts a Narrative ID from its current encoding space to a target partner's encoding space.
**Syntax:**
```sql theme={null}
NARRATIVE_ID_TRANSLATE(narrative_id, target_key_id)
```
**Parameters:**
| Parameter | Type | Description |
| --------------- | ------ | ----------------------------------------------------------- |
| `narrative_id` | string | An existing Narrative ID to translate |
| `target_key_id` | string | The encoding key ID for the target partner's encoding space |
**Example: Translating for a partner**
```sql theme={null}
SELECT
NARRATIVE_ID_TRANSLATE(narrative_id, 'partner_b_key_id') AS partner_b_narrative_id,
demographic_segment,
activity_score
FROM your_company."audience_segments"
```
### Partner collaboration workflow
Here's a typical workflow for sharing data with a partner:
Create Narrative IDs in your encoding space:
```sql theme={null}
SELECT
NARRATIVE_ID_ENCODE(email_sha256, 'your_org_key_id') AS narrative_id,
customer_segment,
lifetime_value
FROM your_company."customers"
```
Convert your Narrative IDs to the partner's encoding space:
```sql theme={null}
SELECT
NARRATIVE_ID_TRANSLATE(narrative_id, 'partner_key_id') AS partner_narrative_id,
customer_segment,
lifetime_value
FROM your_company."customers_encoded"
```
The partner receives data with Narrative IDs in their encoding space. They can now join against their own data using their Narrative IDs.
### Joining across partner datasets
When querying data that spans multiple partners, use translation in the join condition:
```sql theme={null}
SELECT
a.campaign_name,
a.impressions,
b.conversion_value
FROM partner_a."ad_impressions" a
JOIN partner_b."conversions" b
ON NARRATIVE_ID_TRANSLATE(a.narrative_id, 'partner_b_key_id') = b.narrative_id
WHERE a.event_timestamp >= '2024-01-01'
```
***
## Working with match tables
Match tables are pre-generated lookup tables that pair Narrative IDs with clear text identifiers, enabling offline workflows.
### What match tables provide
* A static mapping between your clear text identifiers and their Narrative IDs
* The ability to work with Narrative IDs outside the Narrative platform
* Faster lookups for high-volume processing
### Generating a match table
Create a match table by selecting both the raw identifier and its encoded Narrative ID:
```sql theme={null}
CREATE MATERIALIZED VIEW your_company."identity_match_table" AS
SELECT DISTINCT
email_sha256 AS clear_text_identifier,
NARRATIVE_ID_ENCODE(email_sha256, 'your_org_key_id') AS narrative_id
FROM your_company."customer_identities"
```
### Using match tables
Once generated, you can export the match table for use in external systems:
```sql theme={null}
SELECT
clear_text_identifier,
narrative_id
FROM your_company."identity_match_table"
```
Match tables contain mappings between clear text identifiers and Narrative IDs. Handle them with the same security precautions as any sensitive data.
***
## Best practices
### Normalization before encoding
Always normalize identifiers before encoding to ensure consistent matching:
| Identifier Type | Normalization |
| --------------- | ------------------------------------------------------- |
| Email | `LOWER(TRIM(email))` |
| Phone | E.164 format or `NORMALIZE_PHONE()` |
| Hashed email | Already normalized (ensure consistent hashing upstream) |
### Performance considerations
* **Encode once, translate as needed**: Encode your raw identifiers once and store the Narrative IDs. Translate only when preparing data for a specific partner
* **Use materialized views**: For frequently accessed data, encode identifiers in a materialized view rather than at query time
* **Batch translations**: When sharing with multiple partners, consider creating partner-specific views
### Encoding vs. hashing
| Use Case | Approach |
| --------------------------- | ------------------------------------------ |
| General pseudonymization | Use standard hashing (SHA-256) |
| Cross-partner collaboration | Use Narrative ID encoding |
| Internal data processing | Either, depending on downstream use |
| Regulatory compliance | Narrative ID provides additional isolation |
***
## Troubleshooting
| Issue | Possible Cause | Solution |
| ---------------------------- | --------------------------------------- | ----------------------------------------------------------- |
| No matches after translation | Identifiers not normalized consistently | Ensure both partners normalize the same way before encoding |
| Invalid key\_id error | Incorrect encoding key ID | Verify the key ID with your Narrative administrator |
| Translation returns NULL | Source Narrative ID is invalid | Check that the source was properly encoded |
***
## Related content
Understand how Narrative ID works
Complete function syntax and parameters
Hashing fundamentals for privacy
# Managing Data Subject Requests
Source: https://docs.narrative.io/guides/compliance/managing-dsrs
Submit and process opt-out and deletion requests to comply with GDPR, CCPA, and other privacy regulations
When you receive a data subject request (DSR) from an individual exercising their privacy rights, you must ensure that request is honored across all systems where their data exists—including any data you've shared with partners through Narrative. This guide shows you how to submit DSRs to the platform so that opted-out identifiers are automatically excluded from queries and propagated to downstream data collaborators.
For background on GDPR data subject rights and CCPA consumer rights, see [GDPR](/concepts/compliance/gdpr) and [CCPA](/concepts/compliance/ccpa).
## Prerequisites
Before you begin, ensure you have:
* A dataset uploaded to Narrative
* The identifier(s) from the DSR (hashed email, MAID, cookie, or other unique identifier)
* Understanding of the request type (opt-out or data erasure)
***
## How DSR management works
When you submit a DSR to Narrative, the platform takes two automatic actions:
1. **Blocks future transactions**: The identifier is anti-joined from all NQL queries in your account, preventing the data subject's information from being queried, returned, or filtered on by any party.
2. **Propagates to data buyers**: If you've previously shared data containing this identifier with other organizations, Narrative notifies those buyers so they can comply with the request wherever they've already received the data.
As a data owner, regulatory frameworks like CCPA and GDPR hold you responsible for complying with privacy requests. Narrative's DSR management helps you fulfill your obligations to both the data subject and your downstream data collaborators.
***
## Submitting a DSR
To register a privacy request with Narrative, create a dataset containing the opted-out identifiers and map it to the **Data Privacy Request Identifier** Rosetta Stone attribute.
### Step 1: Prepare your DSR data
Create a dataset with two fields:
| Field | Description | Example values |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| **Identifier** | The unique identifier from the DSR. Can be any identifier type: hashed email, mobile advertising ID (MAID), cookie, or custom ID. | `a1b2c3d4e5f6...` (SHA-256 email hash) |
| **Request type** | The type of privacy request received. | `opt_out` or `data_erasure` |
Example CSV:
```csv theme={null}
identifier,request_type
06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3,opt_out
e1e8d3e4a336d4f9dc63b70a534ff10834471556,data_erasure
abc123def456,opt_out
```
### Step 2: Upload and create the dataset
In the Narrative UI, go to **Datasets** and click **Create Dataset**.
Upload the CSV file containing your privacy request identifiers.
Map your columns to appropriate data types:
* `identifier`: String
* `request_type`: String (enum: `opt_out`, `data_erasure`)
Save the dataset and ensure it's activated.
### Step 3: Map to the Data Privacy Request Identifier attribute
The critical step is mapping your dataset to the **Data Privacy Request Identifier** Rosetta Stone attribute. This mapping tells the platform that identifiers in this dataset represent privacy requests that should be excluded from queries.
Navigate to your DSR dataset and click the **Rosetta Stone** tab.
Click **Add Mapping** and select the `identifier` column.
Search for and select **Data Privacy Request Identifier** (attribute ID 232).
Click **Create** to activate the mapping.
The mapping to the Data Privacy Request Identifier attribute is what triggers the privacy enforcement. Without this mapping, your DSR dataset will be treated as regular data.
Once mapped, the platform automatically:
* Anti-joins these identifiers from all NQL queries in your account
* Propagates the requests to any organization that has previously purchased data containing these identifiers from you
***
## Setting a retention policy
For optimal data management and compliance, set a 30-day retention policy on datasets containing DSR identifiers. This approach:
* Promotes timely data deletion
* Aligns with many privacy regulation timelines (GDPR requires response within one month; CCPA within 45 days)
* Prevents indefinite accumulation of DSR records
Always tailor your retention policy to the specific regulations of your jurisdiction. Consult your legal team for guidance on appropriate retention periods.
***
## For data buyers
If you subscribe to data containing user identifiers from other organizations, Narrative automatically subscribes you to receive relevant DSRs from those suppliers.
### Where to find DSRs
Privacy requests passed to you appear in **My Data** under **Purchased Data**. These requests include:
* The identifier that was opted out or requested for deletion
* The request type (`opt_out` or `data_erasure`)
* The supplier who originated the request
### Your compliance responsibility
When you receive a DSR from a supplier:
1. **Review the request**: Identify the identifier and request type
2. **Locate the data**: Find any records in your systems that match the identifier
3. **Take appropriate action**:
* For `opt_out`: Remove the identifier from marketing communications and targeted advertising
* For `data_erasure`: Delete all personal data associated with the identifier
4. **Document compliance**: Maintain records of your response for audit purposes
You are responsible for complying with DSRs in systems where you have already exfiltrated data from Narrative. The platform blocks future queries, but you must handle data that has already been exported.
***
## Request types
Narrative supports two types of privacy requests:
| Request type | Value | Description | Typical use case |
| ---------------- | -------------- | -------------------------------------------------------------- | ------------------------------------------------- |
| **Opt-out** | `opt_out` | Data subject requests removal from marketing or advertising | CCPA "Do Not Sell" requests, GDPR right to object |
| **Data erasure** | `data_erasure` | Data subject requests complete deletion of their personal data | GDPR right to erasure, CCPA deletion requests |
Both request types result in the identifier being excluded from future queries. The distinction helps you and your data collaborators take the appropriate compliance action.
***
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- |
| Identifier still appearing in query results | Dataset not mapped to Data Privacy Request Identifier attribute | Verify the Rosetta Stone mapping is active |
| DSR not propagating to buyers | Mapping was created after data was already transacted | Contact support—the platform will backfill notifications |
| Invalid request type error | Request type value doesn't match expected enum | Use exactly `opt_out` or `data_erasure` (lowercase, underscore) |
| Buyers not receiving DSRs | No prior transactions on the identifier | DSRs only propagate when there's a transaction history |
***
## Related content
Understand GDPR data subject rights
Understand CCPA consumer rights
Learn how to create Rosetta Stone mappings
Privacy-preserving data collaboration techniques
# Ad Account Eligibility for Custom Audiences
Source: https://docs.narrative.io/guides/connectors/meta/ad-account-access
Resolve eligibility warnings shown when selecting a Meta ad account for Custom Audience delivery
When you select an ad account while creating a Meta Connector profile, Narrative checks whether the account is eligible for Custom Audience delivery. Accounts that may not be eligible are flagged with a warning, but you can still select them and complete profile creation.
You can proceed with an ad account that shows an eligibility warning. If you have already confirmed eligibility outside of Narrative — for example, by accepting the Terms of Service in another browser session — delivery may still succeed. If the underlying issue is not resolved on the Meta side, deliveries to that ad account will fail.
This page covers the eligibility warnings you may see and how to resolve the underlying issue on Meta's side so that deliveries succeed reliably.
## Custom Audience Terms of Service not accepted
**Warning shown:** *The business associated with the ad account must accept the Custom Audience Terms of Service.*
**Cause:** The Business Manager that owns the ad account hasn't accepted Meta's Custom Audiences Terms of Service.
The warning includes a **Learn more** link that opens the Terms of Service page for that specific ad account.
Click **Learn more** on the warning, or open the URL directly:
```
https://business.facebook.com/ads/manage/customaudiences/tos/?act=YOUR_ACCOUNT_ID
```
Replace `YOUR_ACCOUNT_ID` with your Meta ad account ID (numbers only, without the `act_` prefix).
Review and accept the Terms of Service. A Business Manager admin must perform this step.
Return to the Narrative Meta Connector and re-authenticate, or reload the ad account list. The eligibility warning should clear.
## Ad account is not associated with a business
**Warning shown:** *Only ad accounts associated with businesses are eligible for use with custom audiences.*
**Cause:** The ad account is a personal ad account that has not been claimed by a Meta Business Manager. Custom Audiences require a Business Manager.
In [Meta Business Suite](https://business.facebook.com/), open **Business Settings** > **Ad Accounts** and add or claim the ad account.
Once the ad account is associated with a business, accept the Custom Audience Terms of Service for it (see the section above).
Return to Narrative and re-authenticate the Meta Connector profile so it picks up the updated business association.
Ads datasets are only available for profiles whose ad account is associated with a Business. Profiles whose ad account is not associated with a Business will not show an ads datasets table on the profile details page.
## Ad account does not support custom audiences
**Warning shown:** *This ad account does not support custom audiences.*
**Cause:** Meta has flagged the ad account as ineligible for Custom Audiences for a reason that is not specific to Terms of Service or business association. Common causes include:
* The Business Manager has not completed business verification.
* Your role on the ad account is below **Advertiser** or **Ad Account Admin**.
* The ad account is restricted, disabled, or under review by Meta.
### Verify your role permissions
You must have at least **Advertiser** or **Ad Account Admin** role on the ad account to create Custom Audiences.
Ask them to verify your role on the ad account.
If you don't have sufficient access, request **Advertiser** or **Ad Account Admin** permissions.
In Meta Business Manager, roles can be updated under **Ad Accounts** > select your ad account > **People** > edit user permissions.
### Complete business verification
Log into Meta Business Manager and navigate to **Business Settings** > **Security Center** > **Business Verification**.
Follow Meta's prompts to verify your business. This process may take several days.
Once verification is complete, re-authenticate the Meta Connector profile.
Business verification is a Meta requirement, not a Narrative requirement. Learn more in [Meta's Business Help Center](https://www.facebook.com/business/help/2058515294227817).
### Confirm ad account association
Verify that you're logged into the correct Meta Business Manager account.
In Business Manager, go to **Business Settings** > **Ad Accounts** and confirm that the ad account is listed and assigned to the correct Business Manager.
If the ad account is missing, request access from its owner or add it to your Business Manager.
## Additional troubleshooting
If the warning persists after resolving the underlying issue:
* **Re-authenticate the profile** — Eligibility flags are read at authentication time. Re-authenticate to refresh them.
* **Clear browser cache** — Clear your browser cache and cookies, then try again.
* **Try a different browser** — Some browser extensions may interfere with the Meta authentication flow.
* **Check Meta status** — Verify that Meta's systems are operational at [Meta Status](https://metastatus.com/).
* **Wait and retry** — Eligibility changes on Meta's side can take several minutes to propagate.
## Getting support
If you continue to experience issues, contact [Narrative support](mailto:support@narrative.io) with:
* Your Meta ad account ID
* The Business Manager ID you're using
* Screenshots of the eligibility warning
* Confirmation of the steps you've already tried
***
## Related content
Reference documentation for the Meta Connector
Understand how connectors deliver data
Prepare identifiers for Meta
# Archive a Compute Pool
Source: https://docs.narrative.io/guides/data-planes/archive-compute-pool
Retire a compute pool safely by clearing its references and understanding the runtime impact on in-flight jobs
Archiving a compute pool removes it from selection without permanently deleting it. The archive flow behaves differently on Snowflake and AWS data planes, and in-flight jobs that still reference the pool are failed by the operator on their next iteration. This guide walks through the order in which to clear references so the archive succeeds and your jobs report meaningful errors.
## Prerequisites
* A Narrative API token with **Compute Pools** Read and Write permissions ([manage API keys](/account-settings/api-keys))
* The `computePoolId` of the pool you want to archive
* For AWS data planes: knowledge of any datasets or companies that pin this pool as a default
## When archive rejects
On AWS data planes, the API rejects the archive when the pool is still:
* The **data plane default** — the level-4 fallback when no other level supplied a pool
* A **dataset default** — pinned to one or more datasets as their default compute pool (level 2)
* A **company default** — pinned by one or more companies as their per-data-plane default (level 3)
When this happens, the API returns a `CannotArchiveDefault` error describing every reference that would dangle. Clear those references first, then retry.
On Snowflake data planes, all three reference paths are reconciled automatically: the platform re-elects another active pool as the new data plane default (or clears it if none remain) and clears the dataset and company defaults on every affected entity before archiving. You can skip the manual steps below and call the archive endpoint directly.
Snowflake users frequently mutate their warehouse list outside the platform. The Snowflake archive flow is permissive so the platform can track reality when a backing warehouse disappears. See [Compute Pools › Archiving compute pools](/concepts/primitives/compute-pools#archiving-compute-pools) for the rationale.
## Archive on an AWS data plane
If the compute pool you want to archive is the data plane default, update the data plane to point at a different active compute pool, or clear the default entirely. After this step, jobs that do not specify a `computePoolId` either resolve to the new default or fail with an actionable error at job creation.
Update each affected dataset's `computePoolConfig.defaultComputePoolId` to a different pool or to `null`. Datasets whose default is cleared fall through to the rest of the resolution chain on their next job.
Dataset defaults are a user-specific choice. The platform does not auto-pick a replacement on AWS data planes — you decide whether to repoint each dataset or let it fall through.
If the pool is set as a company's default for this data plane, clear or repoint it. A company admin clears the default with:
```bash theme={null}
curl -X DELETE \
-H "Authorization: Bearer $NARRATIVE_API_TOKEN" \
https://api.narrative.io/company//data-planes//default-compute-pool
```
or repoints it to a different active pool with:
```bash theme={null}
curl -X PUT \
-H "Authorization: Bearer $NARRATIVE_API_TOKEN" \
https://api.narrative.io/company//data-planes//default-compute-pool/
```
Both endpoints return `204 No Content` on success and require the **Company Info** write permission.
Send a `DELETE` request to `/compute-pools/{id}`. The pool's status changes to `archived` and it stops appearing in list responses.
```bash theme={null}
curl -X DELETE \
-H "Authorization: Bearer $NARRATIVE_API_TOKEN" \
https://api.narrative.io/compute-pools/
```
The data plane operator fails any job whose compute pool resolves to a non-`active` status on its next polling iteration. If you do not want in-flight jobs to fail, wait until they reach a terminal state before archiving, or resubmit them against a different active pool first.
## What happens to in-flight jobs
After archive, the operator treats the pool as unresolvable for every `Pending`, `Running`, and `PendingCancellation` job that referenced it. The job is reported as failed with a message similar to:
```text theme={null}
compute pool '' could not be resolved (it may have been archived or does not exist).
Resubmit the job with an active compute pool.
```
Jobs in `PendingCancellation` are failed rather than reported as cancelled. The missing pool is the actionable signal users need to see — a successful cancellation report would hide the underlying cause. From the user's perspective the job is terminal either way, so failing it is the safe and informative choice.
## Verifying the archive
The pool's record stays in the database, so `GET /compute-pools/{id}` still returns it with `status: archived`. The pool is filtered out of `GET /compute-pools` list responses by default. Job creation requests that try to resolve to an archived pool fail validation at the API layer; existing jobs that resolved to it at submission time fail at runtime as described above.
## Related content
How compute pools work and why archive behavior diverges by provider
Where data lives and how the operator processes jobs
Transition from a single Snowflake warehouse to compute pools
Manage API tokens with the permissions needed to archive pools
# Migrate to Compute Pools
Source: https://docs.narrative.io/guides/data-planes/migrate-to-compute-pools
Map your Snowflake virtual warehouses to Narrative Compute Pools to use multiple warehouses for different job types
If your Snowflake data plane was set up before compute pools were available, it uses a single virtual warehouse for all workloads. Migrating to compute pools lets you register one or more Snowflake warehouses, giving you control over which warehouse handles each type of work.
## Why migrate
With a single default warehouse, every query—exploratory, production, audience builds—competes for the same resources. Compute pools solve this by letting you assign different warehouses to different workloads:
* **Right-size compute for the job.** Use a small warehouse for quick lookups and a large one for heavy processing, rather than over-provisioning a single warehouse for all cases.
* **Isolate workloads.** Prevent an expensive production pipeline from slowing down ad-hoc exploration, or vice versa.
* **Control costs.** Smaller warehouses for lightweight work cost less per credit-second than running everything on a large warehouse.
* **Set collaboration policies.** Control which companies in your data plane can use which warehouses, so shared environments stay predictable.
## Prerequisites
* ACCOUNTADMIN role or equivalent in your Snowflake account
* Access to the Narrative Snowflake Native App in your account
* One or more Snowflake warehouses you want to register as compute pools
* A Narrative API token with **Compute Pools** Read and Write permissions ([manage API keys](/account-settings/api-keys))
## Migration steps
1. Open the Narrative Data Collaboration app configuration screen in Snowflake.
2. Expand the **Warehouse Management** section.
3. Click **Select Warehouses to Register** and select the warehouses you want to use as compute pools.
Then, grant the app usage on each selected warehouse by running the following in a Snowflake worksheet:
```sql theme={null}
GRANT USAGE ON WAREHOUSE TO APPLICATION NARRATIVE_DATA_COLLABORATION;
```
For example, to register two warehouses:
```sql theme={null}
GRANT USAGE ON WAREHOUSE ANALYTICS_SMALL TO APPLICATION NARRATIVE_DATA_COLLABORATION;
GRANT USAGE ON WAREHOUSE PRODUCTION_XLARGE TO APPLICATION NARRATIVE_DATA_COLLABORATION;
```
Each granted warehouse is automatically registered as a compute pool on your data plane. You can grant access to your existing warehouse plus any additional warehouses you want to add.
In the [Narrative Platform UI](https://app.narrative.io/platform/), navigate to **Settings > Data Planes**, select your Snowflake data plane, and open the **Compute Pools** tab. You should see each registered warehouse listed as a compute pool.
From the Compute Pools tab, open the action menu (`···`) on the warehouse you want as your default and select **Set as default**. This warehouse is used whenever a query doesn't specify a compute pool.
The [context selector](/guides/platform/using-the-context-selector) now shows your registered compute pools in the second column. Select the appropriate warehouse for your current workload. The previous static "DEFAULT" label is replaced by your registered pools.
## What changes after migration
| Before | After |
| ----------------------------------------------------- | ------------------------------------------------------------------- |
| Single warehouse for all workloads | One or more warehouses, each as a named compute pool |
| No choice in the context selector | Context selector populates with your registered warehouses |
| Warehouse configuration managed entirely in Snowflake | Basic settings visible and editable from the data plane detail page |
The legacy single-warehouse configuration is removed once you complete the migration. All queries going forward use the compute pool model.
## Related content
Understand compute pool types and when to use each
Choose your compute pool and other execution context settings
Full installation guide for new Snowflake data planes
# Install the Snowflake Native App
Source: https://docs.narrative.io/guides/data-planes/snowflake-native-app-installation
Set up a customer-hosted data plane in your Snowflake environment
The Snowflake Native App installs a customer-hosted [data plane](/concepts/primitives/data-planes) directly in your Snowflake environment. Once installed, it operates as a data plane operator—polling Narrative's control plane for jobs and executing queries against your Snowflake data without it ever leaving your account.
## Prerequisites
* Snowflake ACCOUNTADMIN role or equivalent privileges
* A Narrative account ([create one here](/getting-started/create-account))
* A Narrative API token with the following permissions ([manage API keys](/account-settings/api-keys)):
| Permission | Access level |
| --------------- | -------------- |
| Compute Pools | Read and Write |
| Data Planes | Read and Write |
| Data Plane Logs | Read and Write |
| Datasets | Read and Write |
| Jobs | Read and Write |
| Models | Read |
* A Snowflake virtual warehouse
* Access to the [Narrative Data Collaboration listing](https://app.snowflake.com/marketplace/listing/GZTSZI0HICH/narrative-inc-narrative-data-collaboration) on the Snowflake Marketplace
* At least one Snowflake table or view to register as a dataset
## Installation steps
Find and install the [Narrative Data Collaboration](https://app.snowflake.com/marketplace/listing/GZTSZI0HICH/narrative-inc-narrative-data-collaboration) app from the Snowflake Marketplace. Click **Get** and follow the prompts to install it in your Snowflake account.
Click **Grant Privileges** to grant the app the following privileges:
* `EXECUTE TASK`
* `CREATE COMPUTE POOL`
* `BIND SERVICE ENDPOINT`
These allow the app to schedule jobs, provision compute resources, and communicate with Narrative's control plane.
In the app configuration screen, expand **Warehouse Management** and click **Select Warehouses to Register**. Select the warehouses you want to use as compute pools.
Then, grant the app usage on each selected warehouse by running the following in a Snowflake worksheet:
```sql theme={null}
GRANT USAGE ON WAREHOUSE TO APPLICATION NARRATIVE_DATA_COLLABORATION;
```
Each granted warehouse is registered as a [compute pool](/concepts/primitives/compute-pools) on your data plane, giving you control over which warehouse handles each workload. You can register additional warehouses later and manage them from the **Compute Pools** tab on the data plane detail page.
Start with a smaller warehouse and scale up based on your data volume and query complexity.
1. Click **Setup External Access Integration**.
2. Click **Next**.
3. Paste your Narrative API token under **Secret value**.
This allows the app to authenticate with Narrative's control plane.
Select the Snowflake table or view you want to register as a dataset in Narrative.
Narrative is granted **read-only** access to the selected object. You can change or add additional tables later.
Set the **DATASET UNIQUE NAME** for your registered dataset.
Dataset names must use uppercase alphanumeric characters and underscores only (e.g., `MY_DATASET_NAME`).
Upload a sample of your data for display in the Narrative UI. This helps collaborators understand the structure and content of your dataset. You can skip this step and upload sample data later from within the platform.
Upload dataset statistics for display in the Narrative UI. Statistics help collaborators evaluate your dataset. You can skip this step and upload statistics later from within the platform.
## What happens next
The app now operates as a data plane [operator](/concepts/primitives/data-planes)—it continuously polls Narrative's control plane for pending jobs and executes NQL queries directly in your Snowflake environment. Your data stays within your Snowflake security perimeter throughout this process.
Your registered warehouses appear as compute pools on the data plane detail page. You can set a default compute pool, add or remove warehouses, and configure collaboration policies from the **Compute Pools** tab. To choose which compute pool runs a specific query, use the [context selector](/guides/platform/using-the-context-selector).
If your data plane was set up before compute pools were available, see [Migrate to Compute Pools](/guides/data-planes/migrate-to-compute-pools) to transition from the legacy single-warehouse model.
## Recommended: Enable AI features
To enable optional Narrative features that use advanced AI models hosted by Snowflake Cortex (such as Ask Rosetta), run the following grants after the app is installed:
```sql theme={null}
GRANT DATABASE ROLE snowflake.cortex_user TO APPLICATION NARRATIVE_DATA_COLLABORATION;
ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION';
```
The first grant allows the app to call Cortex AI models. The second enables cross-region Cortex access, which is required if your Snowflake account is in a region where certain models are not natively available.
***
## Related content
Capabilities, requirements, and configuration details
Understand how data planes and operators work
Manage API tokens and permissions
Learn how queries are executed across data planes
# Building a marketplace match report
Source: https://docs.narrative.io/guides/graph-studio/building-a-marketplace-match-report
Configure and generate a Marketplace Match Report to evaluate a third-party supplier against your first-party identity data
A [Marketplace Match Report](/guides/graph-studio/viewing-marketplace-match-reports) measures how well a supplier's data overlaps with — and enriches — your first-party identity data. This guide walks through the five-step builder that produces a report.
Marketplace Match Reports run on Snowflake data planes. The builder generates a workflow that materializes the report as a dataset; the dataset appears in the Reports listing once the workflow's final step completes.
## Prerequisites
* A first-party **edge dataset** built with [Graph Studio](/guides/graph-studio/building-an-identity-graph). The match report uses these edges as its starting inventory of identifiers.
* An [access rule](/concepts/primitives/access-rules) granting you access to a supplier dataset that has been mapped to Rosetta Stone identifier attributes and (optionally) demographic attributes.
* A Snowflake data plane selected in the data plane switcher.
## Open the builder
Navigate to **My Data > Reports** and click **New report**. The builder loads with a five-step left rail:
1. **Source dataset** — your first-party edge dataset.
2. **Supplier dataset** — the third-party access rule you are evaluating.
3. **Match identifiers** — which identifier types to match on.
4. **Supplier enrichment** — which identifiers and demographic attributes to pull from the supplier for matched people.
5. **Finalize** — name and schedule the report.
You can move forward only when the current step is valid. You can return to any earlier step at any time; downstream selections are preserved when they remain valid.
## Step 1: Select a source dataset
Pick the edge dataset that holds the customers you want to evaluate. Only datasets eligible to act as a graph source — those with Rosetta Stone identifier mappings and statistics that the report can read — appear in the picker.
A match report requires **exactly one** source dataset. Selecting a different dataset replaces the current selection rather than adding to it.
## Step 2: Select a supplier dataset
Choose the access rule for the supplier you want to evaluate. Only access rules that the report builder can join against your source dataset appear in the list. The builder shows an inline message when a supplier is ineligible — typically because it has no overlapping identifier types with your source.
You can select **one** supplier access rule per report. To compare multiple suppliers, build one report per supplier.
## Step 3: Choose match identifiers
Match identifiers determine which identifier types the report uses to match your people to the supplier. The grid lists every identifier type your source dataset has in common with the supplier; all are selected by default.
Deselect a type to exclude it from matching. The match rate the report reports is the share of your people who matched on **any** selected identifier type, so a broader selection generally raises the match rate but may include lower-confidence matches.
You must keep at least one identifier type selected to continue.
## Step 4: Configure supplier enrichment
Supplier enrichment defines what the report pulls from the supplier for the people who matched in step 3. Two selections live on this step:
* **New identifiers** — additional identifier types the supplier contributes for your matched people (e.g., a hashed email the supplier has that you do not). These drive the **Enrichment** card and the **ID Enrichment** KPI.
* **Demographic and behavioral attributes** — categorical attributes from the supplier (e.g., gender, education level, income range). Each attribute you select becomes a panel in the [Demographic & Behavioral Enrichment](/guides/graph-studio/viewing-marketplace-match-reports#demographic--behavioral-enrichment) section of the rendered report.
Only attributes the supplier has mapped to Rosetta Stone are available. Selecting nothing is allowed — the report will still produce match metrics and identifier enrichment without demographic panels.
## Step 5: Finalize the report
Name the report, optionally describe it, and choose a refresh schedule.
The unique name is the NQL-safe identifier for the generated dataset. The builder uppercases the name on blur and surfaces a conflict warning if another dataset already uses the same name.
The display name is what readers see in the Reports listing and at the top of the rendered report. If you leave the display name blank, the builder copies the unique name into it when you finish editing.
The default is **Daily**. Match reports are [materialized views](/concepts/nql/materialized-views) under the hood, so the schedule controls how often the underlying workflow re-runs and the report's numbers update.
The collapsible workflow preview shows the multi-step workflow the builder will submit — including the intermediate datasets it creates and the final materialized view that backs the report. Use this to sanity-check what will run on your data plane.
Click **Build report**. The builder submits the workflow and returns you to the Reports listing.
## After submission
The report appears in the Reports listing immediately. While the producing workflow runs, the row shows the workflow's run state (pending, running, or failed) and is not yet clickable. Once the workflow's final step has materialized the dataset, the row becomes a link to the rendered report.
If the workflow fails, open the run from the listing to see step-level errors. Re-submitting the builder with the same unique name will conflict with the failed workflow's dataset — change the unique name or archive the failed dataset before retrying.
## Related
* [Viewing a marketplace match report](/guides/graph-studio/viewing-marketplace-match-reports) — how to read the rendered report once the workflow finishes
* [Building an identity graph](/guides/graph-studio/building-an-identity-graph) — produce the edge dataset that feeds the report's source step
* [Access rules](/concepts/primitives/access-rules) — how supplier datasets become available to the builder
# Building an identity graph
Source: https://docs.narrative.io/guides/graph-studio/building-an-identity-graph
Create an identity graph from your first-party data using Graph Studio
This guide walks through building an identity graph from a CRM dataset using Graph Studio. By the end, you will have resolved duplicate customer records into unified identities based on shared emails and phone numbers.
Graph Studio runs on both Snowflake and AWS data planes. For a conceptual overview, see [Graph Studio](/concepts/identifiers/graph-studio).
## Prerequisites
* A dataset with [Rosetta Stone attribute mappings](/guides/rosetta-stone/mapping-schemas) for a unique identifier and at least one identity attribute (e.g., normalized email, phone number)
* The dataset must be eligible as a Graph Studio source — the Edge Builder only lists datasets tagged for Graph Studio, and the Graph Builder only lists edges datasets. See [Source eligibility](/concepts/identifiers/graph-studio#source-eligibility) if a source is missing from a picker.
* A Snowflake or AWS data plane
## Example dataset
This guide uses a CRM dataset called `OFFICE_CRM`:
| CUSTOMER\_ID | FIRST\_NAME | LAST\_NAME | EMAIL | PHONE |
| ------------ | ----------- | ---------- | ------------------------------------------------------------------------- | -------------- |
| CRM-001 | Michael | Scott | [michael.scott+rewards@gmail.com](mailto:michael.scott+rewards@gmail.com) | (570) 555-1234 |
| CRM-002 | Dwight | Schrute | [Dwight.Schrute@gmail.com](mailto:Dwight.Schrute@gmail.com) | 15705552345 |
| CRM-003 | Jim | Halpert | [j.im.halpert@gmail.com](mailto:j.im.halpert@gmail.com) | 5705553456 |
| CRM-004 | Michael | Scott | [michael.scott@dundermifflin.com](mailto:michael.scott@dundermifflin.com) | (570) 555-9876 |
| CRM-005 | Pam | Beesly | [pam.beesly@gmail.com](mailto:pam.beesly@gmail.com) | (570) 555-4567 |
| CRM-006 | Michael | Scott | [michael.scott+rewards@gmail.com](mailto:michael.scott+rewards@gmail.com) | (570) 555-9876 |
Michael Scott appears three times with different email and phone combinations. The goal is to resolve all three records into a single identity.
The dataset is mapped to Rosetta Stone attributes for **Unique Identifier** (using `CUSTOMER_ID`), **Normalized Email**, **Clear Text E.164 Phone Number**, and **Person Name**.
## Step 1: Build edges
Edges define how records connect to each other through shared identifiers. Navigate to **My Data > Graph Studio** and select the **Edge builder** tab.
Click **Select Sources** and choose your dataset. Set a **source ID type** (a label like `OFFICE_CRM` that identifies this system) and choose the **source ID** field (`CUSTOMER_ID`).
Target IDs are the Rosetta Stone attributes that serve as connection points. When two records share the same target ID value, the graph connects them.
Target IDs are grouped — each group acts as a single connection type. For this example, add two target ID groups:
1. **Normalized Email** — connects records that share the same email address
2. **Clear Text E.164 Phone Number + Person Name > first\_name** — connects records that share both the same phone number and first name. Combining these into one group means both values must match for a connection, which is more precise than matching on phone alone.
Name the edge dataset (e.g., `office_crm_edges`) and click **Build Edges**.
## Step 2: Build the graph
Switch to the **Graph builder** tab. This takes your edges and runs a connected components algorithm to discover which records belong to the same person.
Click **Select Sources** and choose the edges dataset you just created.
The defaults work well for most use cases. You can adjust **max component size** (caps how many records can merge into one identity), **max iterations**, and **max degree threshold** (excludes overly-connected nodes like shared corporate emails) if needed later.
Optionally, use **Exclusive attributes** to pick one or more target ID types that must hold a single value per identity (for example, an SSN token or exact date of birth). After the connected-components pass converges, any component whose vertices disagree on a declared attribute is split apart, targeting known overmerge cases without dropping legitimate connections. Only first-party target ID types present on your input edges are selectable; leaving the field empty preserves the default behavior.
Name the graph (e.g., `office_crm_graph`), choose a refresh schedule, and click **Build Graph +**.
## Results
The graph resolves the six CRM records into four identities:
| Identity | Records | How they connected |
| ---------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Identity 1 | CRM-001, CRM-004, CRM-006 | All three Michael Scott records — CRM-001 and CRM-006 share the same email; CRM-004 and CRM-006 share the same phone + first name |
| Identity 2 | CRM-002 | Dwight Schrute |
| Identity 3 | CRM-003 | Jim Halpert |
| Identity 4 | CRM-005 | Pam Beesly |
Michael Scott's three records are resolved into a single identity even though no two records share both the same email and phone — the graph follows transitive connections across shared values to link them together.
Dwight, Jim, and Pam remain as separate identities because they have no overlapping identifiers with other records in this dataset.
## Next steps
* **Add third-party data** — Include an access rule as an additional source in the Edge Builder to introduce connections your first-party data cannot see on its own. See [Graph Enrichment](/concepts/identifiers/graph-enrichment).
* **Set up recurring builds** — Use a refresh schedule to keep your graph current as new data arrives.
# Viewing a marketplace match report
Source: https://docs.narrative.io/guides/graph-studio/viewing-marketplace-match-reports
Understand how to read and interpret the Marketplace Match Report to evaluate supplier overlap and enrichment potential
The Marketplace Match Report measures how your identity data connects with a third-party supplier's data. It quantifies three things: how many of your customers the supplier can identify, what new identifiers the supplier adds, and what demographic attributes the supplier has for your matched customers.
This guide explains how to read a rendered report. To configure and generate a new report, see [Building a marketplace match report](/guides/graph-studio/building-a-marketplace-match-report).
Navigate to **My Data > Reports** and click a completed report to view it.
All counts in the report use approximate distinct counting (HyperLogLog) with roughly 1% relative error. Use these numbers for decision support and supplier evaluation, not for billing or contractual purposes.
## Report info
The card at the top of the report displays metadata about the underlying dataset:
| Field | Description |
| ---------------- | ---------------------------------------------------------------------- |
| **Dataset Name** | The generated dataset that contains the report data |
| **Created** | When the report was generated |
| **Description** | The supplier dataset and enrichment attributes included in this report |
You can share a report by copying its link. Recipients see a **Loaded from shared link** badge when they open a shared report.
## Identity Enrichment
This section visualizes the end-to-end flow from your starting identifiers through matching to the new identifiers gained. It reads left to right across three cards.
### Input (Customer Identifiers)
The pink card on the left shows your starting inventory:
| Metric | What it means |
| ----------------------------- | --------------------------------------------------------------------------------------------------- |
| **Identifier counts by type** | The number of unique identifiers you have for each type (e.g., E164 Phone Number, Normalized Email) |
| **Total IDs** | The sum of unique identifiers across all types |
| **Unique People** | The number of distinct person profiles in your data, after deduplication across identifier types |
| **IDs per person** | Total IDs divided by Unique People — how many identifiers you have per person on average |
### Resolution (Identity Matching)
The purple card in the center shows which of your identifiers matched the supplier:
| Metric | What it means |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Identifier counts by type** | The number of unique identifiers that matched, broken out by type. Each type also shows its **match rate** — the percentage of your identifiers of that type that found a match. |
| **Total Matched** | The sum of matched identifiers across all types |
| **Unique People** | The number of your distinct person profiles that had at least one identifier match |
### Enrichment (Additional Data)
The green card on the right shows what the supplier adds for your matched people:
| Metric | What it means |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **New identifier counts by type** | The number of net-new unique identifiers the supplier contributes, broken out by type. These are identifiers the supplier has for your matched people that you did not already have. |
| **New IDs** | The total net-new identifiers gained across all types |
## Identifier Type Summary
Below the flow cards, a per-identifier-type breakdown consolidates the numbers into one view:
| Column | Definition |
| --------------------- | ----------------------------------------------------------------------------------------- |
| **Input (unique)** | Your unique identifiers of this type (from the Input card) |
| **Matched (unique)** | How many of your identifiers of this type matched the supplier (from the Resolution card) |
| **New (unique)** | Net-new identifiers of this type contributed by the supplier (from the Enrichment card) |
| **Total Identifiers** | Input + New — the total identifiers of this type you would have after enrichment |
## Key Performance Metrics
Three headline KPIs summarize the report. Each card shows the metric value and the formula components beneath it.
### Match Rate
The percentage of your people that the supplier can identify.
**Formula:** Matched Unique People ÷ Input Unique People × 100
A higher match rate means the supplier covers more of your customer base. In the example report, 156M matched out of 232M input people yields a 67% match rate.
### ID Enrichment
How many times over the supplier multiplies your total identifier count.
**Formula:** Total IDs after enrichment ÷ Input Total IDs
A value of 2.1x means the supplier roughly doubles the number of identifiers available for activation. This measures the channel-expansion potential of the supplier's data.
### IDs Per Person
The average number of identifiers per person after enrichment.
**Formula:** Total IDs after enrichment ÷ Input Unique People
Higher density means more ways to reach each person across channels. In the example report, 798M total IDs across 232M people yields 3.4 IDs per person.
## Demographic & Behavioral Enrichment
This section shows the distribution of demographic and behavioral attributes that the supplier has for your matched people. Each attribute the report was configured with gets its own panel.
Every panel header displays:
* The **attribute name** (e.g., `hl7 gender.gender`, `Education Level`, `household income range.income_range`)
* **Coverage**: how many of your matched people have a value for this attribute, shown as both a count and a percentage (e.g., "156M of 232M people (67% match rate)")
Within each panel, a horizontal bar chart shows the top values for that attribute ranked by person count. Each bar displays:
* The **value label** (e.g., "female", "Completed College", "$100,000 - $149,999")
* The **person count** — how many matched people have this value
* The **percentage** — that count as a share of all matched people with a value for this attribute
Use these distributions to evaluate whether the supplier's view of your customers is consistent with what you already know. Unexpected skews in gender, age, or income distributions may indicate data-quality issues worth investigating before activating.
## Report Metadata
The collapsible **Report Metadata** section at the bottom of the page contains technical details about the report dataset and its generation.
# Guides
Source: https://docs.narrative.io/guides/index
Step-by-step instructions for common tasks in Narrative I/O
Guides help you accomplish specific tasks. Unlike tutorials, they assume you have basic familiarity with the platform and focus on getting things done efficiently.
## Platform
Configure datasets and manage platform settings.
Choose which column-level metrics to compute and set refresh schedules
Add, edit, and manage retention policies from the dataset details page
## Data Ingestion
Get your data into Narrative I/O.
Prepare email addresses and phone numbers by hashing them
## Querying with NQL
Write effective queries to filter, transform, and analyze data.
Select, filter, and transform data with NQL
Combine data from multiple datasets
Cache expensive query results for better performance
Techniques for writing faster, more efficient queries
## Rosetta Stone
Map and normalize schemas across data sources.
Create mappings between your schema and Rosetta Stone
Test and verify your schema mappings
Handle complex mapping scenarios
## Audience Studio
Build and deliver targeted audiences.
Select data, apply filters, choose destinations, and create an audience
## Graph Studio
Build identity graphs from your data.
Create an identity graph from first-party data using the Edge Builder and Graph Builder
## Connectors
Troubleshoot and configure data delivery to external platforms.
Resolve eligibility warnings when selecting ad accounts for Custom Audience delivery
## Compliance
Manage privacy requests and regulatory obligations.
Submit and process opt-out and deletion requests
## Webhooks
Receive push notifications for job events.
Create and manage webhook subscriptions for job state changes
## Account Settings
Manage your account and API access.
Create and manage API keys for programmatic access
Configure single sign-on for your organization
# Hashing PII for Upload
Source: https://docs.narrative.io/guides/ingestion/hashing-pii
Prepare email addresses and phone numbers for upload by hashing them with MD5, SHA-1, and SHA-256
All personally identifiable information (PII) uploaded to Narrative must be pseudonymized using cryptographic hash functions. This guide walks you through formatting and hashing email addresses and phone numbers before uploading them to the platform.
To understand why Narrative requires hashed identifiers rather than raw PII, see [Data Pseudonymization](/concepts/security/data-pseudonymization).
## Prerequisites
Before you begin, ensure you have:
* Your ID list saved as a single-column CSV file
* Access to one of the following: Google Sheets, a command line terminal, or the Touchless PII Hasher app
## Choose your method
| Method | Best for | Requirements |
| --------------------------------------------------------------------- | ------------------------------- | ---------------- |
| [Touchless PII Hasher](https://www.narrative.io/touchless-pii-hasher) | Quick, one-off hashing | Web browser only |
| Google Sheets | Small lists (under 10,000 rows) | Google account |
| Command line | Large lists, automation | Terminal access |
***
## Hashing email addresses
### Pre-formatting requirements
Hash functions are case-sensitive and whitespace-sensitive. Before hashing, you must normalize your email addresses:
| Rule | Example | Result |
| --------------------------------------- | --------------------------- | ------------------- |
| Lowercase all text | `JohnDoe@Gmail.COM` | `johndoe@gmail.com` |
| Remove leading/trailing whitespace | ` john@example.com ` | `john@example.com` |
| Remove `+` aliases and everything after | `john+newsletter@gmail.com` | `john@gmail.com` |
| Remove extra periods in username | `j.o.h.n@gmail.com` | `john@gmail.com` |
Skipping pre-formatting will result in different hash values, preventing matches with other datasets. A single extra space or uppercase letter produces a completely different hash.
### Supported hash algorithms
Narrative supports three hash algorithms. For maximum correspondence with other datasets, we recommend generating all three:
| Algorithm | Output length | Example hash of `johndoe@gmail.com` |
| --------- | ------------- | ------------------------------------------------------------------ |
| MD5 | 32 characters | `29a1df4646cb3417c19994a59a3e022a` |
| SHA-1 | 40 characters | `e1e8d3e4a336d4f9dc63b70a534ff10834471556` |
| SHA-256 | 64 characters | `06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3` |
### Method 1: Touchless PII Hasher (fastest)
The [Touchless PII Hasher](https://www.narrative.io/touchless-pii-hasher) is a free web app that handles formatting and hashing automatically. Your data never leaves your browser.
Navigate to [narrative.io/touchless-pii-hasher](https://www.narrative.io/touchless-pii-hasher).
Drag and drop your CSV file or click to browse.
The app automatically formats and hashes your data. Download the resulting file.
### Method 2: Google Sheets (small lists)
For lists under 10,000 rows, use Narrative's [Hashing Functions spreadsheet](https://docs.google.com/spreadsheets/d/1JZrNMcLC4PXrBPWfpL4JHdBPKUngZmW33hC7U1NrLBo/copy) which automatically handles pre-formatting and all three hash algorithms.
Open the [Hashing Functions spreadsheet](https://docs.google.com/spreadsheets/d/1JZrNMcLC4PXrBPWfpL4JHdBPKUngZmW33hC7U1NrLBo/copy) and click **Make a copy** when prompted.
Copy your list of emails and paste them starting in cell **A4**.
The spreadsheet may take a few minutes to compute, depending on list size.
Navigate to the tab titled **DOWNLOAD\_THIS\_SHEET\_AS\_.CSV\_FILE**. Go to **File > Download > Comma-separated values (.csv, current sheet)**.
### Method 3: Command line (large lists)
For large lists or automation, use the command line. Narrative provides a hashing script, or you can use standard Unix tools.
**Option A: Use Narrative's hashing script**
Download [hashingFunction.py](https://gist.github.com/narrative-io/example-hashing-script) and save it to your `~/bin` directory.
```bash theme={null}
chmod +x ~/bin/hashingFunction.py
```
```bash theme={null}
~/bin/hashingFunction.py < input.csv > output.csv
```
**Option B: Use standard Unix tools**
For a quick one-liner that handles pre-formatting and generates SHA-256 hashes:
```bash theme={null}
cat emails.csv | tr '[:upper:]' '[:lower:]' | sed 's/+[^@]*//g' | \
while read email; do echo -n "$email" | shasum -a 256 | cut -d' ' -f1; done > hashed.csv
```
To generate all three hash formats:
```bash theme={null}
cat emails.csv | tr '[:upper:]' '[:lower:]' | sed 's/+[^@]*//g' | \
while read email; do
echo "$(echo -n "$email" | md5),$(echo -n "$email" | shasum -a 1 | cut -d' ' -f1),$(echo -n "$email" | shasum -a 256 | cut -d' ' -f1)"
done > hashed.csv
```
Use PowerShell to hash your email list:
```powershell theme={null}
Get-Content emails.csv | ForEach-Object {
$email = $_.Trim().ToLower() -replace '\+[^@]*', ''
$bytes = [System.Text.Encoding]::UTF8.GetBytes($email)
$md5 = [System.Security.Cryptography.MD5]::Create()
$sha1 = [System.Security.Cryptography.SHA1]::Create()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$md5Hash = [BitConverter]::ToString($md5.ComputeHash($bytes)) -replace '-', ''
$sha1Hash = [BitConverter]::ToString($sha1.ComputeHash($bytes)) -replace '-', ''
$sha256Hash = [BitConverter]::ToString($sha256.ComputeHash($bytes)) -replace '-', ''
"$sha256Hash,$md5Hash,$sha1Hash".ToLower()
} | Out-File -Encoding UTF8 hashed.csv
```
Never direct output to the same file as input. This will delete your data:
```bash theme={null}
# DO NOT DO THIS
hashingFunction.py < emails.csv > emails.csv # Data will be lost!
```
***
## Hashing phone numbers
Phone numbers follow a similar process, but with different pre-formatting rules.
### Pre-formatting requirements
| Rule | Example | Result |
| ------------------------------------------ | ------------------- | ------------- |
| Remove all non-numeric characters | `+1 (555) 123-4567` | `15551234567` |
| Include country code | `555-123-4567` (US) | `15551234567` |
| Remove leading zeros (except country code) | `0555-123-4567` | `15551234567` |
### Using Google Sheets for phone numbers
Use Narrative's [Phone Hashing Functions spreadsheet](https://docs.google.com/spreadsheets/d/1example-phone-sheet/copy) which automatically strips non-numeric characters before hashing.
Follow the same steps as email hashing above.
### Using command line for phone numbers
```bash theme={null}
cat phones.csv | tr -cd '0-9\n' | \
while read phone; do
echo "$(echo -n "$phone" | md5),$(echo -n "$phone" | shasum -a 1 | cut -d' ' -f1),$(echo -n "$phone" | shasum -a 256 | cut -d' ' -f1)"
done > hashed_phones.csv
```
```powershell theme={null}
Get-Content phones.csv | ForEach-Object {
$phone = $_ -replace '[^0-9]', ''
$bytes = [System.Text.Encoding]::UTF8.GetBytes($phone)
$md5 = [System.Security.Cryptography.MD5]::Create()
$sha1 = [System.Security.Cryptography.SHA1]::Create()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$md5Hash = [BitConverter]::ToString($md5.ComputeHash($bytes)) -replace '-', ''
$sha1Hash = [BitConverter]::ToString($sha1.ComputeHash($bytes)) -replace '-', ''
$sha256Hash = [BitConverter]::ToString($sha256.ComputeHash($bytes)) -replace '-', ''
"$sha256Hash,$md5Hash,$sha1Hash".ToLower()
} | Out-File -Encoding UTF8 hashed_phones.csv
```
***
## Verify your output
Before uploading, verify your hashing is correct using this test data:
**Test email:** `johndoe@gmail.com`
| Algorithm | Expected hash |
| --------- | ------------------------------------------------------------------ |
| SHA-256 | `06a240d11cc201676da976f7b49341181fd180da37cbe40a77432c0a366c80c3` |
| MD5 | `29a1df4646cb3417c19994a59a3e022a` |
| SHA-1 | `e1e8d3e4a336d4f9dc63b70a534ff10834471556` |
If your hashes match, your implementation is correct.
***
## Uploading your hashed list
Once your list is hashed:
1. Ensure your file is in CSV format
2. Navigate to your dataset in Narrative
3. Upload the hashed file
***
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------------------- | -------------------------- | ------------------------------------------------------------- |
| Hashes don't match expected values | Pre-formatting not applied | Verify lowercase, whitespace removal, and alias stripping |
| Google Sheets is slow or unresponsive | List too large | Use command line method for lists over 10,000 rows |
| Different hashes for same email | Inconsistent formatting | Ensure all emails go through the same pre-formatting pipeline |
| Command not found (Mac/Linux) | Script not in PATH | Use full path to script or add `~/bin` to PATH |
| PowerShell security error | Execution policy | Run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` |
***
## Related content
Understand why Narrative requires hashed identifiers
Free web app for quick, secure hashing
Learn how Narrative protects your data
# Connecting to the Narrative MCP Server
Source: https://docs.narrative.io/guides/mcp/connecting-an-ai-assistant
Set up your MCP client to interact with Narrative through an AI agent
This guide walks you through connecting an MCP-compatible AI assistant to the Narrative platform. Once connected, your assistant can search datasets, run NQL queries, manage materialized views, and more — all from within your conversation.
## Prerequisites
* A Narrative account with access to at least one company
* An MCP-compatible AI client installed (see supported clients below)
## Server URL
All clients connect to the same endpoint:
```
https://mcp.narrative.io/mcp
```
## Setup by client
Run the following command in your terminal:
```bash theme={null}
claude mcp add --transport http narrative https://mcp.narrative.io/mcp
```
This registers the Narrative MCP server in your Claude Code configuration. The next time Claude calls a Narrative tool, a browser window opens for you to authenticate.
To verify the server is registered:
```bash theme={null}
claude mcp list
```
You should see `narrative` in the output with the server URL.
Claude Desktop connects to remote MCP servers through its built-in **Connectors** UI. The `claude_desktop_config.json` file only supports stdio-based servers, so don't add Narrative there.
1. Open **Settings** (gear icon, or `⌘,`).
2. In the left nav, select **Connectors**.
3. Scroll to the bottom and click **Add custom connector**.
4. Enter the following:
* **Name:** `Narrative`
* **Remote MCP server URL:** `https://mcp.narrative.io/mcp`
5. Click **Add**. A browser window opens for OAuth — sign in with your Narrative credentials.
6. Return to Claude Desktop. The connector should show **Connected**.
The Narrative tools appear in the tools menu (hammer icon) at the bottom of the chat input.
If you don't see **Add custom connector**, update Claude Desktop — native remote MCP support requires a recent version.
1. Open **Settings** > **MCP**.
2. Click **+ Add new MCP server**.
3. Enter the following:
* **Name:** `narrative`
* **Type:** `http`
* **URL:** `https://mcp.narrative.io/mcp`
4. Click **Add** and confirm the server appears in the list.
Narrative tools are available in Cursor's Agent mode.
1. Open the **Command Palette** and search for **MCP Configuration**.
2. Add the Narrative server to your configuration:
```json theme={null}
{
"mcpServers": {
"narrative": {
"type": "http",
"url": "https://mcp.narrative.io/mcp"
}
}
}
```
3. Save and restart Windsurf.
Any MCP-compatible client that supports HTTP transport can connect using:
* **Transport:** HTTP (Streamable HTTP)
* **URL:** `https://mcp.narrative.io/mcp`
Refer to your client's documentation for how to add a remote MCP server.
## Authenticate
The MCP server accepts two kinds of bearer credential in the `Authorization` header:
* **OAuth login (interactive clients).** A standard OAuth flow exchanges your Narrative login for a scoped session token. This is what Claude Code, Claude Desktop, Cursor, and Windsurf use by default.
* **Narrative API token (programmatic clients).** Long-lived API tokens are accepted directly, which lets server-to-server callers and scripted MCP clients connect without a browser-based login.
The server distinguishes the two by token shape — JWTs (three dot-separated segments) follow the OAuth path, and opaque tokens are treated as Narrative API tokens.
### OAuth (default for interactive clients)
The first time your assistant calls a Narrative tool, the MCP server initiates an OAuth login:
1. A browser window opens with the Narrative login page.
2. Sign in with your Narrative credentials.
3. Return to your AI client — the session is now authenticated.
Your session persists across tool calls. You only need to log in once per session.
### Narrative API token (for programmatic clients)
If your MCP client supports a static bearer token, you can authenticate with a [Narrative API key](/account-settings/api-keys) instead of OAuth. Set the `Authorization` header on every MCP request:
```http theme={null}
Authorization: Bearer
```
The server validates the token, resolves the associated user and default company, and reuses the same token for downstream Narrative API calls. The token's existing permissions and company access apply.
Use API token auth for headless workloads — CI jobs, agent orchestrators, and other server-to-server callers that can't complete an interactive OAuth flow.
If you belong to multiple companies, the server defaults to your primary company. Ask the assistant to switch companies during the conversation — for example, "switch to Acme Corp" — and it will use the `narrative_context_set_company` tool.
## Verify the connection
After setup, test the connection by asking your assistant something like:
> "What companies do I have access to on Narrative?"
The assistant should call `narrative_context_get_companies` and return a list of your companies. If you see results, the connection is working.
## Troubleshooting
### Browser window doesn't open
Some terminal environments block browser launches. If the OAuth login window doesn't appear:
* Check that your default browser is configured correctly.
* Try opening the server URL directly in a browser to confirm network access: `https://mcp.narrative.io/mcp`
### Tools not appearing
If your AI client doesn't show Narrative tools:
* Verify the server URL is exactly `https://mcp.narrative.io/mcp` (no trailing slash).
* Restart your AI client after adding the server configuration.
* Check your client's MCP logs for connection errors.
### Session expired
If tool calls start failing with authentication errors, your session may have expired. Ask the assistant to retry — the server will prompt you to log in again.
## What's next
Understand how the MCP server works and what it enables
Browse the complete catalog of available tools
Learn NQL basics to get more from query tools
Configure column statistics that the MCP server can read and trigger
# Installing skills
Source: https://docs.narrative.io/guides/mcp/installing-skills
Install Narrative skills into Claude Code or any agentskills.io-compliant agent
[Skills](/concepts/mcp/skills) are pre-built workflows that orchestrate Narrative's MCP server to complete specific tasks — drafting NQL queries, generating Rosetta Stone mappings, building identity graphs, and more. Skills are harness-agnostic: you can install them with the standalone `skills` CLI into any spec-compliant agent, or as Claude Code plugins.
## Prerequisites
* An agent runtime that loads skills. Either:
* Any [agentskills.io](https://agentskills.io)-compliant agent (used with the `skills` CLI), or
* Claude Code, updated to a version that supports the `/plugin` command.
* The Narrative MCP server connected to that agent. See [Connecting to the Narrative MCP Server](/guides/mcp/connecting-an-ai-assistant) if you haven't set this up yet.
* A Narrative account with access to the active company you want the skills to operate against.
Skills run inside your agent and call the MCP server on your behalf. Without an active MCP connection, skills can't reach your Narrative data.
## Install skills
Each skill's detail page in the [Skills catalog](https://app.narrative.io/marketplace/skills) shows an **Installation** picker with the two options below. Pick whichever matches your agent.
The standalone [`skills` CLI](https://agentskills.io) installs a skill's rendered `SKILL.md` into any spec-compliant agent. No repo clone, no Bun runtime, no Claude Code required.
Run the install command from any skill's detail page, or globally for the whole marketplace:
```text theme={null}
npx skills add narrative-io/narrative-skills-marketplace
```
The CLI fetches the marketplace and installs every available skill into your active agent's skills directory.
Open your agent and ask it to describe the skills it has available. You should see entries such as **Find Attribute**, **Write NQL**, and **Design Analysis**.
Narrative also publishes its skills as Claude Code plugins from the public [`narrative-io/narrative-skills-marketplace`](https://github.com/narrative-io/narrative-skills-marketplace) repository. You add the marketplace once, then install whichever plugins you want.
In a Claude Code session, run:
```text theme={null}
/plugin marketplace add narrative-io/narrative-skills-marketplace
```
Claude Code fetches the marketplace manifest and lists the available plugins. You only need to do this once.
Install the plugin that contains the skill you want. For general-purpose authoring, querying, and Rosetta Stone work:
```text theme={null}
/plugin install narrative-common@narrative-skills-marketplace
```
For identity resolution and partner overlap work:
```text theme={null}
/plugin install narrative-identity@narrative-skills-marketplace
```
For audience expansion and look-alike modeling:
```text theme={null}
/plugin install narrative-audience@narrative-skills-marketplace
```
Installing a plugin makes every skill it contains available in the current Claude Code session.
In a Claude Code chat, ask Claude to describe its available skills. You should see entries for each skill in the plugin you installed (for example, **Find Attribute**, **Write NQL**, **Design Analysis**).
## Use a skill
Once a plugin is installed, you invoke its skills with natural-language prompts. Claude routes the request to the matching skill and follows its instructions.
For example, with the `narrative-common` plugin installed:
```text theme={null}
Find the Rosetta Stone attribute for a SHA-256 hashed email.
```
Claude calls the **Find Attribute** skill, which uses the `narrative_attributes_search` and `narrative_attributes_describe` MCP tools to resolve the description to a canonical attribute ID (`sha256_hashed_email`).
Each skill's detail page in the [Skills catalog](https://app.narrative.io/marketplace/skills) lists example prompts, accepted arguments, and the use cases each one addresses.
### Slash commands and arguments
Many skills also expose a slash-command form with explicit arguments. For example, **Write NQL** accepts `--dataset`, `--run`, and `--limit`:
```text theme={null}
/write-nql --dataset 123 --run --limit 50 distinct users in the last 30 days
```
The slash-command form is useful for scripting, scoping a skill to a specific dataset, or chaining skills together. See each skill's **Arguments** table on its detail page for the full list of flags and defaults.
Skills that produce artifacts — NQL queries, workflow specifications, mapping definitions — always show you the draft and wait for explicit approval before submitting it. You can edit the draft or ask Claude to revise it before saying "go."
## Where skills run
Skills run on your machine inside the agent you installed them into. They reach Narrative through the MCP server using the OAuth credential you established when you connected the agent to the server. Skills never bypass MCP — every action they take is a tool call you can inspect in the agent's transcript.
This also means skills inherit MCP's company scoping: they operate against whichever company is active in your MCP session. Use the `narrative_context_set_company` tool (or ask the agent to switch companies) before invoking a skill if you need to target a different account.
## Update or remove a skill
If you used the **skills CLI**, rerun the add command to pick up newer versions, or use the CLI's remove command to uninstall:
```text theme={null}
npx skills add narrative-io/narrative-skills-marketplace
```
If you used **Claude Code**, manage plugins with `/plugin`:
```text theme={null}
/plugin update narrative-common@narrative-skills-marketplace
/plugin uninstall narrative-common@narrative-skills-marketplace
```
The skill version shown on each catalog detail page matches the semver in the skill's `SKILL.md` frontmatter. Reinstall to pick up newer versions.
## Next steps
How skills relate to the MCP server, plugins, and runtimes.
The underlying tools that skills call.
Set up the OAuth connection skills depend on.
Every published skill with example prompts and install commands.
# Creating Materialized Views
Source: https://docs.narrative.io/guides/nql/creating-materialized-views
Pre-compute and cache query results for improved performance
If you're running the same expensive query repeatedly—for dashboards, reports, or data sharing—a materialized view lets you compute once and read many times. This guide walks through creating and managing materialized views in NQL.
## Prerequisites
Before creating a materialized view, you should have:
* Basic familiarity with [NQL syntax](/nql/general/syntax)
* A dataset to query (your own or one you have access to)
* Understanding of what [materialized views](/concepts/nql/materialized-views) are and when to use them
## Basic syntax
A materialized view is created with the `CREATE MATERIALIZED VIEW` statement:
```sql theme={null}
CREATE MATERIALIZED VIEW "view_name"
AS
```
The query can be any valid NQL SELECT statement. The result is stored as a dataset that you can query like any other table.
## Creating your first materialized view
Let's create a simple materialized view that aggregates user counts by region.
### Step 1: Write and test your query
Start by writing the query you want to materialize. Run it to verify it returns the expected results:
```sql theme={null}
SELECT
region,
COUNT(1) as user_count,
APPROX_COUNT_DISTINCT(company_id) as company_count
FROM user_data
WHERE status = 'active'
GROUP BY region
```
### Step 2: Wrap it in CREATE MATERIALIZED VIEW
Once your query works, add the `CREATE MATERIALIZED VIEW` wrapper:
```sql theme={null}
CREATE MATERIALIZED VIEW "active_users_by_region"
AS
SELECT
region,
COUNT(1) as user_count,
APPROX_COUNT_DISTINCT(company_id) as company_count
FROM user_data
WHERE status = 'active'
GROUP BY region
```
### Step 3: Execute the statement
Run the statement to create the view. The query executes immediately and stores the results.
### Step 4: Query the materialized view
Once created, query it like any other table:
```sql theme={null}
SELECT region, user_count, company_count
FROM active_users_by_region
WHERE user_count > 1000
```
## Adding metadata options
You can configure how the view behaves using metadata options. The most common are:
### Setting a refresh schedule
To keep your view updated automatically:
```sql theme={null}
CREATE MATERIALIZED VIEW "daily_sales_summary"
REFRESH_SCHEDULE = '@daily'
AS
SELECT
DATE(order_timestamp) as order_date,
COUNT(1) as order_count,
SUM(total_amount) as total_revenue
FROM orders
GROUP BY DATE(order_timestamp)
```
Available schedules include `@hourly`, `@daily`, `@weekly`, `@monthly`, and custom CRON expressions.
### Adding a display name and description
Make your view easier to find in the UI:
```sql theme={null}
CREATE MATERIALIZED VIEW "monthly_revenue"
DISPLAY_NAME = 'Monthly Revenue Report'
DESCRIPTION = 'Total revenue aggregated by month, refreshed daily'
REFRESH_SCHEDULE = '@daily'
AS
SELECT
DATE_TRUNC('month', order_timestamp) as month,
SUM(total_amount) as revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_timestamp)
```
### Partitioning for performance
For time-series data, partition by date to improve query performance:
```sql theme={null}
CREATE MATERIALIZED VIEW "event_metrics"
REFRESH_SCHEDULE = '@hourly'
PARTITIONED_BY event_date DAY
AS
SELECT
DATE(event_timestamp) as event_date,
event_type,
COUNT(1) as event_count
FROM events
GROUP BY DATE(event_timestamp), event_type
```
For complete details on all available options, see the [Materialized View Syntax Reference](/nql/commands/create-materialized-view).
## Refreshing materialized views
Views refresh according to their schedule. You can also trigger manual refreshes via the API when you need immediate updates.
### Scheduled refresh
Use the `REFRESH_SCHEDULE` option to set automatic refresh intervals:
| Schedule | When it runs |
| --------------- | --------------- |
| `@hourly` | Every hour |
| `@daily` | Once per day |
| `@weekly` | Once per week |
| `@monthly` | Once per month |
| CRON expression | Custom schedule |
### Custom CRON schedules
For more control, use a CRON expression:
```sql theme={null}
-- Refresh every Monday at 3:15 PM UTC
REFRESH_SCHEDULE = '15 15 * * 1'
-- Refresh at 2 AM on the first day of each month
REFRESH_SCHEDULE = '0 2 1 * *'
```
### Incremental refresh
For large datasets, NQL can refresh incrementally by processing only changed data. This happens automatically when the query pattern supports it. To learn more, see [Incremental View Maintenance](/concepts/nql/incremental-view-maintenance).
### Editing the refresh schedule and compute pool from the UI
You can change a materialized view's refresh cadence and the [compute pool](/concepts/primitives/compute-pools) that runs its scheduled refreshes without rewriting the view. From the dataset's **Overview** screen, click the pencil icon next to the **Refresh Schedule** row:
* **Refresh schedule** — pick a preset (`@hourly`, `@daily`, `@weekly`, `@monthly`) or enter a custom CRON expression.
* **Compute pool** — select which compute pool runs the scheduled refresh. The dropdown lists only **active, company-owned** pools on the dataset's [data plane](/concepts/primitives/data-planes), matching the pools you can pick in the [Context Selector](/guides/platform/using-the-context-selector).
The selected pool persists with the dataset and is reused for every subsequent scheduled refresh until you change it.
## Write modes
Control how refreshes update the view data:
### Overwrite mode (default)
Each refresh replaces the entire view with fresh results:
```sql theme={null}
CREATE MATERIALIZED VIEW "current_inventory"
WRITE_MODE = 'overwrite'
AS
SELECT product_id, SUM(quantity) as stock_level
FROM inventory
GROUP BY product_id
```
### Append mode
Each refresh adds new data to the existing view:
```sql theme={null}
CREATE MATERIALIZED VIEW "event_log"
WRITE_MODE = 'append'
AS
SELECT event_id, user_id, event_type, event_timestamp
FROM events
WHERE event_timestamp > CURRENT_TIMESTAMP - INTERVAL 1 HOUR
```
When using append mode, ensure your query includes filters to avoid inserting duplicate records on each refresh.
Need to update existing records instead of just appending? Use [MERGE ON](/guides/nql/incremental-upserts) to match incoming rows against existing data and decide whether to update or insert.
## Example: Complete materialized view
Here's a complete example combining multiple options:
```sql theme={null}
CREATE MATERIALIZED VIEW "female_audience"
REFRESH_SCHEDULE = '@daily'
DISPLAY_NAME = 'Female Audience'
DESCRIPTION = 'Audience containing demographic of female users.'
EXPIRE = 'retain_everything'
TAGS = ( '_nio_materialized_view', 'user_engagement', 'metrics' )
WRITE_MODE = 'overwrite'
EXTENDED_STATS = 'all'
PARTITIONED_BY event_date DAY
AS
SELECT
"unique_identifier",
"hl7_gender",
"age"
FROM narrative.rosetta_stone
WHERE
"hl7_gender"."gender" = 'female'
AND "event_timestamp" > CURRENT_TIMESTAMP - INTERVAL 160 DAYS
AND rosetta_stone._price_cpm_usd <= 2.00
```
This creates a view that:
* Refreshes daily with fresh data
* Stores 160 days of female user demographics
* Partitions by day for efficient date-range queries
* Computes extended statistics for query optimization
* Retains all data indefinitely
***
## Related content
Understand when and why to use materialized views
Complete reference for all available options
How NQL optimizes refreshes for large datasets
Techniques for writing faster, more efficient queries
# Filtering and Transforming Data
Source: https://docs.narrative.io/guides/nql/filtering-transforming
Select columns, filter rows, and transform data with NQL
This guide covers the fundamentals of querying data with NQL: selecting columns, filtering rows with WHERE clauses, and transforming data using expressions and functions.
## Prerequisites
* A Narrative I/O account with at least one dataset
* Basic familiarity with SQL syntax
## What you'll learn
* How to select specific columns and use aliases
* How to filter data with comparison and logical operators
* How to transform data using expressions and functions
* How to work with complex types (arrays, structs, maps)
***
## Selecting columns
### Basic column selection
Select specific columns from a dataset:
```sql theme={null}
SELECT
user_id,
email,
created_at
FROM company_data."123"
```
### Using column aliases
Rename columns in your output with `AS`:
```sql theme={null}
SELECT
user_id AS id,
email AS contact_email,
created_at AS signup_date
FROM company_data."123"
```
### Fully qualified column names
When joining datasets or for clarity, use fully qualified names:
```sql theme={null}
SELECT
company_data."123".user_id,
company_data."123".email
FROM company_data."123"
```
Always use fully qualified column names in joins to avoid ambiguity.
NQL requires explicit column lists. Wildcards (`SELECT *`) are not supported. See [Explicit Column Selection](/nql/general/explicit-columns) for why and how to list columns explicitly.
### Select distinct values
Remove duplicates with `DISTINCT`:
```sql theme={null}
SELECT DISTINCT category
FROM company_data."123"
```
***
## Filtering with WHERE
### Comparison filters
Filter rows using comparison operators:
```sql theme={null}
SELECT user_id, email, age, status
FROM company_data."123"
WHERE
age >= 18
AND age < 65
AND status = 'active'
```
| Operator | Meaning |
| -------- | --------------------- |
| `=` | Equal to |
| `<>` | Not equal to |
| `<` | Less than |
| `>` | Greater than |
| `<=` | Less than or equal |
| `>=` | Greater than or equal |
### Combining conditions
Use `AND`, `OR`, and `NOT` to combine conditions:
```sql theme={null}
SELECT user_id, email, status, region
FROM company_data."123"
WHERE
(status = 'active' OR status = 'pending')
AND NOT is_test_account
AND region = 'US'
```
Use parentheses to control evaluation order. `AND` is evaluated before `OR`.
### IN and NOT IN
Check membership in a list:
```sql theme={null}
SELECT user_id, category, source, created_at
FROM company_data."123"
WHERE
category IN ('electronics', 'clothing', 'home')
AND source NOT IN ('test', 'internal')
```
### BETWEEN
Filter within a range (inclusive):
```sql theme={null}
SELECT user_id, email, age, score
FROM company_data."123"
WHERE
age BETWEEN 25 AND 45
AND score BETWEEN 80 AND 100
```
### NULL checks
Check for missing values:
```sql theme={null}
SELECT user_id, email, phone
FROM company_data."123"
WHERE
email IS NOT NULL
AND phone IS NOT NULL
AND secondary_email IS NULL
```
Don't use `= NULL` or `<> NULL`. These always return NULL, not true/false. Use `IS NULL` and `IS NOT NULL`.
### Pattern matching with LIKE
Match string patterns using LIKE wildcards:
```sql theme={null}
SELECT user_id, email, name, code
FROM company_data."123"
WHERE
email LIKE '%@gmail.com' -- Ends with @gmail.com
AND name LIKE 'John%' -- Starts with John
AND code LIKE 'A__' -- A followed by exactly 2 characters
```
| Wildcard | Matches |
| -------- | ----------------------- |
| `%` | Zero or more characters |
| `_` | Exactly one character |
For case-insensitive matching, normalize the case first:
```sql theme={null}
WHERE LOWER(email) LIKE '%@gmail.com'
```
***
## Date and time filtering
### Current date comparisons
Filter relative to the current date:
```sql theme={null}
SELECT user_id, event_date, created_at
FROM company_data."123"
WHERE
-- Last 30 days
event_date >= CURRENT_DATE - INTERVAL '30' DAY
-- Last 3 months
AND created_at >= CURRENT_DATE - INTERVAL '3' MONTH
```
### Date ranges
Filter within specific date ranges:
```sql theme={null}
SELECT user_id, email, event_date
FROM company_data."123"
WHERE
event_date BETWEEN '2024-01-01' AND '2024-12-31'
```
### Extracting date parts
Filter by specific date components:
```sql theme={null}
SELECT user_id, event_date, event_type
FROM company_data."123"
WHERE
EXTRACT(YEAR FROM event_date) = 2024
AND EXTRACT(MONTH FROM event_date) IN (1, 2, 3)
AND EXTRACT(DAYOFWEEK FROM event_date) = 1 -- Sunday
```
***
## Price filtering
Control costs by filtering on the special `_price_cpm_usd` column:
```sql theme={null}
SELECT user_id, email, category
FROM company_data."123"
WHERE
category = 'premium'
AND _price_cpm_usd <= 1.00 -- Max $1 per 1000 rows
```
Always include price filtering to control data acquisition costs, especially when creating materialized views.
***
## Transforming data
### Expressions in SELECT
Compute values using expressions:
```sql theme={null}
SELECT
quantity * unit_price AS line_total,
(quantity * unit_price) * 1.0825 AS total_with_tax,
first_name || ' ' || last_name AS full_name
FROM company_data."123"
```
### CASE expressions
Apply conditional logic:
```sql theme={null}
SELECT
user_id,
CASE
WHEN lifetime_value > 1000 THEN 'platinum'
WHEN lifetime_value > 500 THEN 'gold'
WHEN lifetime_value > 100 THEN 'silver'
ELSE 'bronze'
END AS tier
FROM company_data."123"
```
### String transformations
Clean and format text:
```sql theme={null}
SELECT
UPPER(name) AS uppercase_name,
LOWER(email) AS lowercase_email,
TRIM(user_input) AS cleaned_input,
CONCAT(city, ', ', state) AS location,
SUBSTRING(phone, 1, 3) AS area_code
FROM company_data."123"
```
### Type casting
Convert between types:
```sql theme={null}
SELECT
CAST(string_number AS LONG) AS numeric_value,
CAST(timestamp_value AS DATE) AS date_only,
CAST(amount AS DECIMAL) AS precise_amount
FROM company_data."123"
```
***
## Working with complex types
### Accessing array elements
Use bracket notation with zero-based indexing:
```sql theme={null}
SELECT
identifiers[0] AS first_id,
identifiers[1] AS second_id,
tags[0] AS primary_tag
FROM company_data."123"
```
### Accessing struct fields
Use dot notation:
```sql theme={null}
SELECT
person.name,
person.age,
address.city,
address.postal_code
FROM company_data."123"
```
### Nested access
Combine notation for deeply nested data:
```sql theme={null}
SELECT
-- Array of structs
identifiers[0].type AS first_id_type,
identifiers[0].value AS first_id_value,
-- Struct with nested struct
user.preferences.language,
user.preferences.timezone
FROM company_data."123"
```
### Accessing map values
Use `ELEMENT_AT` for maps:
```sql theme={null}
SELECT
ELEMENT_AT(properties, 'color') AS color,
ELEMENT_AT(metadata, 'source') AS source
FROM company_data."123"
```
### Expanding arrays with UNNEST
Convert array elements to rows:
```sql theme={null}
SELECT
t.user_id,
tag
FROM company_data."123" t, UNNEST(t.tags) AS tag
```
This creates one row per array element. If a user has tags `['a', 'b', 'c']`, the result includes three rows for that user.
***
## Aggregating data
### Basic aggregations
Compute summary statistics:
```sql theme={null}
SELECT
COUNT(1) AS total_rows,
APPROX_COUNT_DISTINCT(user_id) AS unique_users,
SUM(amount) AS total_amount,
AVG(score) AS average_score,
MIN(created_at) AS earliest,
MAX(created_at) AS latest
FROM company_data."123"
```
### Grouping
Aggregate by categories:
```sql theme={null}
SELECT
category,
COUNT(1) AS count,
SUM(amount) AS total,
AVG(amount) AS average
FROM company_data."123"
GROUP BY category
```
### Filtering aggregated results
Use `HAVING` to filter after aggregation:
```sql theme={null}
SELECT
category,
COUNT(1) AS count
FROM company_data."123"
GROUP BY category
HAVING COUNT(1) > 100
```
***
## Deduplication with QUALIFY
Remove duplicates based on a window function:
```sql theme={null}
SELECT
user_id,
email,
last_seen
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
This keeps only the most recent record for each user.
***
## Putting it together
Here's a complete example combining multiple techniques:
```sql theme={null}
SELECT
user_id,
LOWER(email) AS normalized_email,
CASE
WHEN age < 25 THEN '18-24'
WHEN age < 35 THEN '25-34'
WHEN age < 45 THEN '35-44'
ELSE '45+'
END AS age_bracket,
identifiers[0].type AS primary_id_type,
created_at
FROM company_data."123"
WHERE
email IS NOT NULL
AND status = 'active'
AND created_at >= CURRENT_DATE - INTERVAL '90' DAY
AND _price_cpm_usd <= 0.50
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC
) = 1
ORDER BY created_at DESC
LIMIT 1000 ROWS
```
***
## Related content
Combine data from multiple sources
Complete syntax documentation
All comparison and logical operators
String, date, and transformation functions
# Incremental Upserts with MERGE ON
Source: https://docs.narrative.io/guides/nql/incremental-upserts
Update existing records and insert new ones during materialized view refreshes
When building materialized views that refresh periodically, you often need to update existing records rather than simply appending new ones. The `MERGE ON` clause lets you match incoming rows against existing data and decide whether to update or insert—avoiding duplicates while keeping data fresh.
## Prerequisites
Before using MERGE ON, you should have:
* Familiarity with [materialized views](/concepts/nql/materialized-views)
* Understanding of [write modes](/nql/commands/create-materialized-view#write_mode) (append vs overwrite)
* A dataset with stable keys suitable for matching
## The problem
Consider a view that refreshes monthly with recent activity data. With `WRITE_MODE = 'overwrite'`, each refresh replaces all data—losing historical records. With `WRITE_MODE = 'append'`, each refresh adds new rows—but if the same record appears in multiple refreshes, you get duplicates.
MERGE ON solves this by matching rows between the query result and existing view data, then updating matches and inserting non-matches.
## How MERGE ON works
MERGE ON compares each row from your query (the **source**) against existing rows in the materialized view (the **target**). Based on the match condition, it either updates the existing row or inserts a new one.
### Source and target aliases
NQL automatically generates two aliases for use in MERGE ON clauses:
| Alias | Description |
| -------- | ------------------------------------------------------ |
| `source` | References columns from the incoming query result |
| `target` | References columns from the existing materialized view |
Use these aliases in your MERGE ON condition and in the WHEN MATCHED/WHEN NOT MATCHED clauses.
## Use case 1: Incremental upsert with time-bounded matching
This example maintains an audience feed that refreshes monthly. The MERGE ON condition restricts updates to rows considered "fresh" (modified within the last 20 days), which can help respect license refresh windows or avoid stale overwrites.
```sql theme={null}
CREATE MATERIALIZED VIEW "ongoing_Feed"
REFRESH_SCHEDULE = '@monthly'
WRITE_MODE = 'append'
AS
SELECT
"ip_address" AS ip,
"sha256_hashed_email"."value" AS sha256,
"sha1_hashed_email"."value" AS sha1,
"md5_hashed_email"."value" AS md5,
"mobile_id_unique_identifier"."value" AS maid,
"mobile_id_unique_identifier"."type" AS maid_type,
CAST("event_timestamp" AS TIMESTAMP) AS "event_timestamp",
CURRENT_TIMESTAMP as last_modified_at
FROM
company_data.audience_feed
WHERE
"event_timestamp" >= CURRENT_TIMESTAMP - INTERVAL '30' DAY
AND "sha256_hashed_email"."value" IS NOT NULL
AND "ip_address" IS NOT NULL
AND "mobile_id_unique_identifier"."value" IS NOT NULL
MERGE ON target.last_modified_at > CURRENT_TIMESTAMP - INTERVAL '20' DAY
WHEN MATCHED THEN UPDATE SET
ip = source.ip,
sha256 = source.sha256,
sha1 = source.sha1,
md5 = source.md5,
maid = source.maid,
maid_type = source.maid_type,
event_timestamp = source.event_timestamp,
last_modified_at = source.last_modified_at
WHEN NOT MATCHED THEN INSERT (
ip,
sha256,
sha1,
md5,
maid,
maid_type,
event_timestamp,
last_modified_at
) VALUES (
source.ip,
source.sha256,
source.sha1,
source.md5,
source.maid,
source.maid_type,
source.event_timestamp,
source.last_modified_at
);
```
**Key points:**
* The time-bounded condition (`target.last_modified_at > CURRENT_TIMESTAMP - INTERVAL '20' DAY`) is optional but useful for limiting which rows are considered for updates
* `WRITE_MODE = 'append'` is required for incremental merge semantics
* The `last_modified_at` column tracks when each row was last updated
## Use case 2: Enrichment with composite keys
This example enriches internal user data with geographic information from Rosetta Stone. It uses a composite key (user\_id + sha256) with `IS NOT DISTINCT FROM` to handle nullable values safely.
```sql theme={null}
CREATE MATERIALIZED VIEW "email_geo_enrichment"
WRITE_MODE = 'append'
REFRESH_SCHEDULE = '@weekly'
AS (
SELECT
ds.user_id,
rs."sha256_hashed_email"."value" AS sha256,
rs."geo_country_code" AS country_code,
rs."geo_region" AS region,
rs."nio_last_modified_at" AS rs_modified_at,
CURRENT_TIMESTAMP AS last_modified_at
FROM company_data."internal_users" ds
JOIN narrative."rosetta_stone" rs
ON rs."unique_id"."value" = ds.unique_id
)
MERGE ON
target.user_id IS NOT DISTINCT FROM source.user_id
AND target.sha256 IS NOT DISTINCT FROM source.sha256
WHEN MATCHED THEN UPDATE SET
country_code = source.country_code,
region = source.region,
rs_modified_at = source.rs_modified_at,
last_modified_at = source.last_modified_at
WHEN NOT MATCHED THEN INSERT (
user_id,
sha256,
country_code,
region,
rs_modified_at,
last_modified_at
) VALUES (
source.user_id,
source.sha256,
source.country_code,
source.region,
source.rs_modified_at,
source.last_modified_at
);
```
**Key points:**
* `IS NOT DISTINCT FROM` treats two NULL values as equal, unlike `=` which returns NULL when comparing NULLs
* Composite keys work well when no single column uniquely identifies a record
* The enriched columns (country\_code, region) update on match while the key columns remain stable
## Best practices
| Practice | Why |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Use a stable, unique key for matching | Non-unique or changing keys cause repeated inserts or updates to wrong rows |
| Use `IS NOT DISTINCT FROM` for nullable fields | Plain `=` on nullable fields can misclassify matches (NULL = NULL returns NULL, not true) |
| Consider time-bounded conditions | Scoping to recent data respects license windows and improves performance |
| Always use `WRITE_MODE = 'append'` | Required for incremental merge semantics; overwrite mode replaces all data |
| Keep MERGE ON conditions minimal | Overly broad conditions cause frequent rewrites; scope to the minimum key set needed |
## Common pitfalls
**Non-unique match keys**
If your MERGE ON condition matches multiple target rows for a single source row, you may see unexpected updates or duplicate inserts. Ensure your match key (or composite key) uniquely identifies records.
**Using = on nullable fields**
Using plain `=` to compare nullable fields can cause matches to be missed:
```sql theme={null}
-- Wrong: will not match when both values are NULL
MERGE ON target.user_id = source.user_id
-- Correct: treats NULL = NULL as a match
MERGE ON target.user_id IS NOT DISTINCT FROM source.user_id
```
**MERGE ON only works in CREATE MATERIALIZED VIEW**
MERGE ON is a clause within the `CREATE MATERIALIZED VIEW` statement. It cannot be used as a standalone MERGE command in Data Studio. For row-level changes outside a materialized view refresh, use standalone [INSERT](/nql/commands/insert), [UPDATE](/nql/commands/update), or [DELETE](/nql/commands/delete) statements instead.
***
## Related content
Complete reference for MERGE ON and other options
How NQL optimizes refreshes for large datasets
When and why to use materialized views
Basic guide to creating materialized views
# Joining Datasets
Source: https://docs.narrative.io/guides/nql/joining-datasets
Combine data from multiple datasets using JOINs
This guide covers how to combine data from multiple datasets using JOIN operations. JOINs are essential for enriching your data with information from other sources.
## Prerequisites
* A Narrative I/O account with access to multiple datasets
* Understanding of [filtering and transforming data](/guides/nql/filtering-transforming)
## What you'll learn
* How to use INNER JOIN to match records across datasets
* How to use LEFT JOIN for optional matches
* How to chain multiple JOINs
* How to use anti-joins to find unmatched records
* How to join with Rosetta Stone for identity resolution
* Performance considerations for large joins
***
## INNER JOIN
An INNER JOIN returns only rows that have matching values in both datasets.
### Basic syntax
```sql theme={null}
SELECT
t1.column1,
t2.column2
FROM company_data."123" t1
INNER JOIN company_data."456" t2
ON t1.id = t2.id
```
### Example: Enriching customer data
Combine customer information with their orders:
```sql theme={null}
SELECT
customers.customer_id,
customers.email,
orders.order_id,
orders.order_date,
orders.total_amount
FROM company_data."100" customers
INNER JOIN company_data."200" orders
ON customers.customer_id = orders.customer_id
WHERE
orders.order_date >= CURRENT_DATE - INTERVAL '30' DAY
```
Only customers who have orders (and orders with valid customers) appear in the results. Customers without orders are excluded.
### Using table aliases
Aliases make queries more readable and are required when referencing columns:
```sql theme={null}
SELECT
c.customer_id,
c.name,
o.order_total
FROM company_data."100" AS c
INNER JOIN company_data."200" AS o
ON c.customer_id = o.customer_id
```
***
## LEFT JOIN
A LEFT JOIN returns all rows from the left table, plus matching rows from the right table. Unmatched rows have NULL values for right-table columns.
### Basic syntax
```sql theme={null}
SELECT
t1.column1,
t2.column2
FROM company_data."123" t1
LEFT JOIN company_data."456" t2
ON t1.id = t2.id
```
### Example: Find customers with or without orders
```sql theme={null}
SELECT
c.customer_id,
c.email,
COALESCE(o.order_count, 0) AS order_count
FROM company_data."100" c
LEFT JOIN (
SELECT customer_id, COUNT(1) AS order_count
FROM company_data."200"
GROUP BY customer_id
) o ON c.customer_id = o.customer_id
```
This returns all customers. Those without orders show `order_count = 0`.
### Filtering LEFT JOIN results
To find records without matches, filter for NULL:
```sql theme={null}
SELECT
c.customer_id,
c.email
FROM company_data."100" c
LEFT JOIN company_data."200" o
ON c.customer_id = o.customer_id
WHERE o.customer_id IS NULL -- No matching orders
```
***
## Multiple JOINs
Chain JOINs to combine data from three or more datasets:
```sql theme={null}
SELECT
c.customer_id,
c.name,
o.order_id,
p.product_name
FROM company_data."100" c
INNER JOIN company_data."200" o
ON c.customer_id = o.customer_id
INNER JOIN company_data."300" p
ON o.product_id = p.product_id
WHERE
o.order_date >= '2024-01-01'
```
### Mixing JOIN types
You can mix INNER and LEFT JOINs:
```sql theme={null}
SELECT
c.customer_id,
c.name,
o.order_id,
r.rating
FROM company_data."100" c
INNER JOIN company_data."200" o
ON c.customer_id = o.customer_id
LEFT JOIN company_data."300" r
ON o.order_id = r.order_id
```
This returns all customers with orders, optionally including ratings if they exist.
***
## Anti-joins with NOT EXISTS
Find records in one dataset that don't have matches in another:
### Basic anti-join
```sql theme={null}
SELECT
c.customer_id,
c.email
FROM company_data."100" c
WHERE NOT EXISTS (
SELECT 1
FROM company_data."200" o
WHERE c.customer_id = o.customer_id
)
```
This returns customers who have never placed an order.
### Example: Find inactive users
Find users who haven't logged in recently:
```sql theme={null}
SELECT
u.user_id,
u.email,
u.signup_date
FROM company_data."100" u
WHERE NOT EXISTS (
SELECT 1
FROM company_data."200" logins
WHERE u.user_id = logins.user_id
AND logins.login_date >= CURRENT_DATE - INTERVAL '90' DAY
)
```
***
## Joining with Rosetta Stone
Rosetta Stone enables identity resolution across datasets using normalized identifiers.
### Access Rosetta Stone identifiers
```sql theme={null}
SELECT
d.user_attribute,
d._rosetta_stone.unique_id
FROM company_data."123" d
```
### Join datasets via Rosetta Stone
Match records across datasets using resolved identities:
```sql theme={null}
SELECT
d1.attribute1,
d2.attribute2
FROM company_data."123" d1
INNER JOIN company_data."456" d2
ON d1._rosetta_stone.unique_id = d2._rosetta_stone.unique_id
```
### Query Rosetta Stone directly
```sql theme={null}
SELECT
unique_id,
event_timestamp
FROM narrative.rosetta_stone
WHERE
unique_id.b."type" = 'email'
AND event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
Rosetta Stone queries can be scoped to different levels: global (`narrative.rosetta_stone`), company-specific (`company_data._rosetta_stone`), or dataset-specific (`company_data."123"._rosetta_stone`). See [Rosetta Stone table references](/nql/general/syntax#rosetta-stone-table-references) for complete syntax.
***
## Join conditions
### Multiple conditions
Join on multiple columns:
```sql theme={null}
SELECT
t1.value,
t2.related_value
FROM company_data."123" t1
INNER JOIN company_data."456" t2
ON t1.key1 = t2.key1
AND t1.key2 = t2.key2
```
### Complex conditions
Use expressions in join conditions:
```sql theme={null}
SELECT
events.event_id,
sessions.session_id
FROM company_data."123" events
INNER JOIN company_data."456" sessions
ON events.user_id = sessions.user_id
AND events.event_timestamp BETWEEN sessions.start_time AND sessions.end_time
```
### NULL-safe joins
Use `IS NOT DISTINCT FROM` for NULL-safe equality:
```sql theme={null}
SELECT
t1.id,
t1.nullable_key,
t2.value,
t2.nullable_key AS t2_nullable_key
FROM company_data."123" t1
INNER JOIN company_data."456" t2
ON t1.nullable_key IS NOT DISTINCT FROM t2.nullable_key
```
***
## Self-joins
Join a dataset to itself for comparisons:
```sql theme={null}
SELECT
e1.employee_id,
e1.name AS employee_name,
e2.name AS manager_name
FROM company_data."123" e1
LEFT JOIN company_data."123" e2
ON e1.manager_id = e2.employee_id
```
***
## Subqueries in JOINs
Join with aggregated or transformed data:
```sql theme={null}
SELECT
c.customer_id,
c.name,
stats.total_spent,
stats.order_count
FROM company_data."100" c
INNER JOIN (
SELECT
customer_id,
SUM(total_amount) AS total_spent,
COUNT(1) AS order_count
FROM company_data."200"
GROUP BY customer_id
) stats ON c.customer_id = stats.customer_id
WHERE stats.total_spent > 1000
```
***
## Performance considerations
### Use fully qualified column names
Always qualify column names in joins to avoid ambiguity and improve query parsing:
```sql theme={null}
-- Good: Fully qualified
SELECT
company_data."123".column1,
company_data."456".column2
FROM company_data."123"
INNER JOIN company_data."456"
ON company_data."123".id = company_data."456".id
-- Also good: With aliases
SELECT
t1.column1,
t2.column2
FROM company_data."123" t1
INNER JOIN company_data."456" t2
ON t1.id = t2.id
```
### Filter before joining
Apply filters as early as possible to reduce the data being joined:
```sql theme={null}
-- Better: Filter in subqueries
SELECT
c.customer_id,
o.order_id
FROM (
SELECT customer_id, email, status FROM company_data."100"
WHERE status = 'active'
) c
INNER JOIN (
SELECT order_id, customer_id, order_date FROM company_data."200"
WHERE order_date >= '2024-01-01'
) o ON c.customer_id = o.customer_id
-- Or use CTEs for clarity
WITH active_customers AS (
SELECT customer_id, email, status FROM company_data."100"
WHERE status = 'active'
),
recent_orders AS (
SELECT order_id, customer_id, order_date FROM company_data."200"
WHERE order_date >= '2024-01-01'
)
SELECT
c.customer_id,
o.order_id
FROM active_customers c
INNER JOIN recent_orders o
ON c.customer_id = o.customer_id
```
### Avoid OR in join conditions
OR conditions in joins can cause performance issues:
```sql theme={null}
-- Avoid
ON t1.id = t2.id OR t1.alt_id = t2.alt_id
-- Better: Use COALESCE or separate queries
ON COALESCE(t1.id, t1.alt_id) = COALESCE(t2.id, t2.alt_id)
```
See [Query Optimization](/guides/nql/query-optimization) for more performance tips.
### Include budget controls
Always include budget controls when joining datasets in materialized views:
```sql theme={null}
CREATE MATERIALIZED VIEW "enriched_customers"
AS (
SELECT
c.customer_id,
c.email,
o.total_orders
FROM company_data."100" c
INNER JOIN (
SELECT customer_id, COUNT(1) AS total_orders
FROM company_data."200"
GROUP BY customer_id
) o ON c.customer_id = o.customer_id
)
BUDGET 50 USD
```
***
## Common patterns
### Enrich with latest record
Get the most recent related record for each row:
```sql theme={null}
SELECT
c.customer_id,
c.name,
latest.last_order_date,
latest.last_order_amount
FROM company_data."100" c
INNER JOIN (
SELECT
customer_id,
order_date AS last_order_date,
total_amount AS last_order_amount
FROM company_data."200"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) = 1
) latest ON c.customer_id = latest.customer_id
```
### Aggregate before joining
Pre-aggregate to reduce join complexity:
```sql theme={null}
SELECT
c.customer_id,
c.segment,
metrics.total_value,
metrics.avg_order
FROM company_data."100" c
INNER JOIN (
SELECT
customer_id,
SUM(amount) AS total_value,
AVG(amount) AS avg_order
FROM company_data."200"
GROUP BY customer_id
) metrics ON c.customer_id = metrics.customer_id
```
***
## Related content
Understanding performance implications
Tips for faster queries
Advanced join patterns and examples
Identity resolution for cross-dataset matching
# Query Optimization
Source: https://docs.narrative.io/guides/nql/query-optimization
Write faster, more efficient NQL queries
Efficient queries are essential for working with large datasets in Narrative. This section covers optimization techniques and patterns to help your queries run faster and use fewer resources.
## Prerequisites
Before diving into optimization, you should have:
* Basic familiarity with [NQL syntax](/nql/general/syntax)
* Understanding of [JOIN operations](/guides/nql/joining-datasets)
## Optimization guides
Restructure OR conditions into single-key joins for dramatically better performance
## Related content
Learn why certain JOIN patterns cause performance problems
Complete reference for NQL functions including UNNEST
Fundamentals of combining data from multiple datasets
# Avoid OR in JOIN Clauses
Source: https://docs.narrative.io/guides/nql/query-optimization/avoid-or-in-join
Restructure OR conditions into single-key joins for dramatically better query performance
When joining datasets on multiple possible keys, you might be tempted to use OR in your JOIN condition. This pattern prevents the query planner from optimizing the join, often causing queries to run for hours instead of seconds.
Using OR in JOIN conditions can make queries 100x slower or more. Always restructure OR conditions into single-key joins using the techniques in this guide.
## The anti-pattern
This query attempts to match records where either `user_id` or `email` matches:
```sql theme={null}
SELECT p.purchase_id, p.amount, u.name, u.email
FROM Purchases p
JOIN Users u
ON p.user_id = u.id
OR p.email = u.email;
```
While logically correct, this pattern prevents the query planner from using efficient join algorithms. The planner cannot build a hash table or use indexes effectively when it must evaluate two conditions with OR.
## Solution 1: Array flattening with UNNEST
The recommended approach is to combine your join keys into an array, flatten it with UNNEST, and then join on a single key column.
### How it works
1. **Combine keys into an array** on each side of the join
2. **UNNEST the arrays** to create one row per potential key
3. **JOIN on the single key column** using standard equality
### Example
```sql theme={null}
WITH Purchases_flat AS (
SELECT
p.*,
key
FROM Purchases p
CROSS JOIN UNNEST([p.user_id, p.email]) AS t(key)
),
Users_flat AS (
SELECT
u.*,
key
FROM Users u
CROSS JOIN UNNEST([u.id, u.email]) AS t(key)
)
SELECT DISTINCT
pf.purchase_id,
pf.item,
pf.amount,
uf.user_name,
uf.email
FROM Purchases_flat pf
JOIN Users_flat uf
ON pf.key = uf.key;
```
This query:
* Creates arrays containing both possible join keys
* Uses UNNEST to expand each row into multiple rows (one per key)
* Joins on a single `key` column, which the planner can optimize efficiently
* Uses `SELECT DISTINCT` to remove duplicate matches
The UNNEST approach works well when you have more than two possible join keys or when the keys are already stored as arrays.
## Solution 2: Using UNION
For simple cases with exactly two join conditions, UNION provides a cleaner alternative:
```sql theme={null}
SELECT
p.purchase_id,
p.item,
p.amount,
u.user_name,
u.email
FROM Purchases p
JOIN Users u ON p.user_id = u.id
UNION
SELECT
p.purchase_id,
p.item,
p.amount,
u.user_name,
u.email
FROM Purchases p
JOIN Users u ON p.email = u.email;
```
This approach:
* Runs two optimized single-key joins separately
* Combines the results and automatically removes duplicates with UNION
* May be easier to read for simple two-condition cases
Use `UNION ALL` instead of `UNION` if you want to keep duplicates, but be aware this may produce unexpected results when both conditions match the same record.
## Choosing the right approach
| Approach | Best for | Trade-offs |
| ---------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| **UNNEST** | Multiple keys, complex queries, keys already in arrays | Requires explicit DISTINCT, slightly more complex syntax |
| **UNION** | Exactly two conditions, readability is priority | Repeats query logic, harder to maintain with many conditions |
Both approaches transform the problematic OR condition into single-key joins that the query planner can optimize using hash joins or indexes.
To understand why OR conditions cause these performance problems, see [Understanding JOIN Performance](/concepts/nql/join-performance).
## Related content
Learn why OR in JOINs prevents optimization
More techniques for writing efficient queries
Fundamentals of combining data from multiple datasets
Reference for UNNEST and other NQL functions
# NQL Troubleshooting
Source: https://docs.narrative.io/guides/nql/troubleshooting
Diagnose and resolve common NQL query errors
When an NQL query fails, the platform returns a structured error response with details about what went wrong. This section helps you diagnose and resolve common errors.
## Understanding error responses
NQL errors return a JSON response with the following structure:
```json theme={null}
{
"type": "https://www.narrative.io/knowledge-base/...",
"title": "Error Title",
"status": 422,
"detail": "Detailed explanation of what went wrong",
"instance": "/nql/run",
"logId": "unique-error-identifier"
}
```
| Field | Description |
| ---------- | ------------------------------------------------------ |
| `type` | URL identifying the error type |
| `title` | Brief error summary |
| `status` | HTTP status code (typically 422 for validation errors) |
| `detail` | Detailed explanation of the problem |
| `instance` | The API endpoint that produced the error |
| `logId` | Unique identifier for support requests |
When contacting support about an error, include the `logId` value to help quickly identify your specific issue.
## Common errors
Resolve errors when queries reference datasets in different data planes
Fix type mismatches and unsupported operations in your queries
## General troubleshooting steps
1. **Read the error detail** — The `detail` field usually explains exactly what's wrong
2. **Check the instance** — Identifies whether the error occurred during parsing (`/nql/parse`) or execution (`/nql/run`)
3. **Review your query** — Look for the specific element mentioned in the error
4. **Consult the reference** — Check [NQL syntax](/nql/general/syntax) and [data types](/nql/data-types) for supported operations
# Cross-Data Plane Query Errors
Source: https://docs.narrative.io/guides/nql/troubleshooting/cross-data-plane-queries
Resolve errors when queries reference data across multiple data planes
NQL queries execute within a single [data plane](/concepts/primitives/data-planes). If your query references datasets that reside in different data planes, the query will fail.
NQL cannot join or query across multiple data planes. All datasets referenced in a query must exist within the same data plane.
## Why this happens
A data plane is the infrastructure where your data physically resides and where queries execute. Narrative's architecture separates data planes to enable:
* **Data residency compliance** — Keep data in specific geographic or organizational boundaries
* **Security isolation** — Maintain separate security perimeters for different data sources
* **Infrastructure flexibility** — Run data planes on different platforms (Narrative-hosted, Snowflake, Spark)
Because each data plane is an independent execution environment, the query engine cannot access data across data plane boundaries in a single query.
For a deeper understanding of data plane architecture, see [Data Planes](/concepts/primitives/data-planes).
## Troubleshooting steps
### 1. Identify which datasets are in different data planes
Review your query and identify all datasets referenced. In the Narrative platform:
1. Navigate to the dataset catalog
2. Check the data plane assignment for each dataset in your query
3. Note which datasets belong to different data planes
### 2. Modify your query
Once you've identified the issue, you have two options:
**Option A: Query datasets separately**
Run separate queries against each data plane and combine results outside of NQL:
```sql theme={null}
-- Query 1: Run against Data Plane A
SELECT user_id, email, created_at FROM "companydata"."dataset_in_plane_a" WHERE ...
-- Query 2: Run against Data Plane B
SELECT user_id, email, created_at FROM "companydata"."dataset_in_plane_b" WHERE ...
-- Combine results in your application
```
**Option B: Move data to a common data plane**
If you need to join datasets frequently, consider:
1. Creating a materialized view that copies the necessary data to a single data plane
2. Working with your Narrative representative to consolidate datasets
Data movement between data planes may have compliance implications. Consult your data governance team before moving data.
## Related content
Understand where your data lives and is processed
How queries flow from submission through execution
How Narrative orchestrates across data planes
Overview of common NQL errors
# Unsupported Type Errors
Source: https://docs.narrative.io/guides/nql/troubleshooting/unsupported-type-error
Resolve errors when NQL queries use unsupported data types or operations
NQL validates data types during query parsing and execution. When a query includes unsupported types or incompatible type operations, the platform returns a 422 error with details about the type issue.
## GEOMETRY type not supported in SELECT
The GEOMETRY type cannot be used directly in SELECT statements. This error occurs when your query attempts to return a geometry column or the result of a geometry function.
### Error response
```json theme={null}
{
"type": "https://www.narrative.io/knowledge-base/support-troubleshooting/nql/unsupported-type-error",
"title": "Unsupported Type Error: Unsupported GEOMETRY Type",
"status": 422,
"detail": "The NQL query includes the unsupported GEOMETRY type. The request cannot be processed because the GEOMETRY type is not supported in the SELECT.",
"instance": "/nql/run",
"logId": "2cf8fef9-9dc2-4438-b6f9-e28c386e48cc"
}
```
### Cause
Geometry functions like `STCIRCLE()` return GEOMETRY types that cannot be serialized in query results. While you can use geometry functions in JOIN conditions and WHERE clauses, you cannot SELECT them directly.
### Solution
Remove the GEOMETRY column from your SELECT statement. Keep geometry operations only in JOIN or WHERE clauses.
**Before (causes error):**
```sql theme={null}
SELECT
maid,
STCIRCLE(longitude, latitude, 15) AS geofence -- GEOMETRY in SELECT
FROM companydata.locations
```
**After (fixed):**
```sql theme={null}
SELECT
maid,
longitude,
latitude
FROM companydata.locations
```
If you need to use geometry for filtering, keep it in the JOIN or WHERE clause:
```sql theme={null}
SELECT
observations.maid,
observations.event_timestamp,
pois.name AS poi_name
FROM companydata.observations
INNER JOIN companydata.pois
ON STIntersects(
STSafePoint(observations.longitude, observations.latitude),
STCIRCLE(pois.longitude, pois.latitude, 15)
)
```
## String concatenation type mismatch
The `||` operator concatenates strings but requires compatible types on both sides. This error occurs when you try to concatenate incompatible types.
### Error response
```json theme={null}
{
"type": "https://www.narrative.io/knowledge-base/support-troubleshooting/nql/unsupported-type-error",
"title": "Unsupported Type Error: String Concatenation",
"status": 422,
"detail": "The NQL query encountered an unsupported type error when attempting to concatenate different types using the '||' operator. The '||' operator can only concatenate :string || :string or || and is not compatible with ' || '.",
"instance": "/nql/parse",
"logId": "7798e641-5d5e-47d3-a884-f5271e788b6b"
}
```
### Cause
The `||` operator requires both operands to be strings (or equivalent string types). Common causes include:
* Concatenating a complex record type with a string
* Mixing incompatible character types
* Using structured fields that haven't been extracted to simple strings
### Solution
Extract string values from complex types before concatenation, or cast values to compatible string types.
**Before (causes error):**
```sql theme={null}
SELECT
"hashedemail" || '-' || "mobileiduniqueidentifier" || '-' || "eventtimestamp" AS unique_key
FROM companydata.events
```
**After (fixed):**
If the fields are structured types with nested values, extract the string value first:
```sql theme={null}
SELECT
"hashedemail"."value" || '-' || "mobileiduniqueidentifier"."value" || '-' || CAST("eventtimestamp" AS VARCHAR) AS unique_key
FROM companydata.events
```
When concatenating timestamps or other non-string types, use `CAST(field AS VARCHAR)` to convert them to strings first.
## General tips for type errors
1. **Check the `detail` field** — It identifies the specific types that are incompatible
2. **Review structured fields** — Many fields in Narrative are record types with `.value` subfields
3. **Use explicit casts** — When in doubt, cast values to the expected type with `CAST(field AS type)`
4. **Check the instance** — `/nql/parse` errors occur during query validation; `/nql/run` errors occur during execution
## Related content
Reference for supported data types
Reference for built-in functions including geometry operations
Overview of common NQL errors
Understanding structured attribute types
# Using Query Templates
Source: https://docs.narrative.io/guides/nql/using-query-templates
Create and use reusable queries with configurable placeholders
Query templates let you build reusable queries that others can customize without editing NQL. This guide walks through finding existing templates, using them, and creating your own.
## Prerequisites
Before working with query templates, you should have:
* Basic familiarity with [NQL syntax](/nql/general/syntax)
* Access to Data Studio
* Understanding of [what query templates are](/concepts/nql/query-templates) and when to use them
## Finding templates
To browse available templates:
1. Navigate to **My Data** in the main navigation
2. Select **Queries**
3. Click **Query Templates**
The template list shows all templates available to your organization. Each entry displays the template name, description, and the placeholders it requires.
## Using an existing template
### Select a template
From the Query Templates list, click on the template you want to use. The template opens showing:
* The template's description and purpose
* A list of placeholders with their names, types, and descriptions
* Which placeholders are required vs. optional
### Fill in placeholders
For each placeholder, enter an appropriate value:
* **Required placeholders** must have values before you can execute the query
* **Optional placeholders** use default values if left empty
* The placeholder type determines what values are valid (dates, numbers, column names, etc.)
Placeholder descriptions explain what each value controls. Read them carefully to understand how your inputs affect the query results.
### Execute the query
Once all required placeholders are filled:
1. Click **Execute** to run the query
2. Review the results in the query output panel
3. Optionally, save the executed query for future reference
## Creating a template
### Enter Template Mode
To create a new template:
1. Open **Data Studio**
2. Click **Template Mode** in the toolbar
Template Mode enables placeholder insertion and template-specific options.
### Write your base query
Start by writing a working NQL query that produces the results you want. Test it with specific values to verify it works correctly:
```sql theme={null}
SELECT
region,
COUNT(1) as user_count,
SUM(revenue) as total_revenue
FROM sales_data
WHERE
sale_date >= '2024-01-01'
AND sale_date < '2024-02-01'
AND region = 'North America'
GROUP BY region
```
### Add placeholders
Identify the parts of your query that should be variable, then replace them with placeholders using `{{placeholder_name}}` syntax:
```sql theme={null}
SELECT
region,
COUNT(1) as user_count,
SUM(revenue) as total_revenue
FROM sales_data
WHERE
sale_date >= {{start_date}}
AND sale_date < {{end_date}}
AND region = {{target_region}}
GROUP BY region
```
### Define placeholder metadata
For each placeholder, specify:
* **Name**: A clear, descriptive identifier
* **Type**: The data type (string, number, date, etc.)
* **Description**: What this placeholder controls
* **Required**: Whether users must provide a value
* **Default** (optional): A fallback value for optional placeholders
Good placeholder names are self-documenting. `start_date` is clearer than `d1`. `min_revenue_threshold` is clearer than `thresh`.
### Save the template
1. Click **Save Template**
2. Enter a template name that describes its purpose
3. Add a description explaining what the template does and when to use it
4. Add tags to help others find the template
5. Click **Save**
## Editing templates
To modify an existing template:
1. Navigate to **Queries** → **Query Templates**
2. Click on the template you want to edit
3. Click **Edit**
4. Modify the query or placeholder definitions as needed
5. Save your changes
Editing a template affects all future uses. If others rely on the template, communicate changes before modifying placeholder names or removing placeholders.
## Example: Date-range report template
Let's create a template for a sales report that users can run for any date range and region.
### Step 1: Write the base query
```sql theme={null}
SELECT
DATE(sale_timestamp) as sale_date,
product_category,
COUNT(1) as transactions,
SUM(amount) as revenue
FROM sales
WHERE
sale_timestamp >= '2024-01-01'
AND sale_timestamp < '2024-02-01'
AND region = 'EMEA'
GROUP BY DATE(sale_timestamp), product_category
ORDER BY sale_date, revenue DESC
```
### Step 2: Identify variable parts
In this query, three things might change:
* Start date (`2024-01-01`)
* End date (`2024-02-01`)
* Region (`EMEA`)
### Step 3: Add placeholders
```sql theme={null}
SELECT
DATE(sale_timestamp) as sale_date,
product_category,
COUNT(1) as transactions,
SUM(amount) as revenue
FROM sales
WHERE
sale_timestamp >= {{start_date}}
AND sale_timestamp < {{end_date}}
AND region = {{region}}
GROUP BY DATE(sale_timestamp), product_category
ORDER BY sale_date, revenue DESC
```
### Step 4: Define placeholder metadata
| Placeholder | Type | Required | Description |
| ------------ | ------ | -------- | --------------------------------------------- |
| `start_date` | date | Yes | Beginning of the reporting period (inclusive) |
| `end_date` | date | Yes | End of the reporting period (exclusive) |
| `region` | string | Yes | Geographic region to filter by |
### Step 5: Save and test
Save the template as "Regional Sales Report" with a description like "Daily sales metrics by product category for a specified region and date range."
Test with different placeholder values to verify results match expectations.
## Example: Materialized view template
Templates can also create materialized views. This lets users generate scheduled data products with customized parameters.
```sql theme={null}
CREATE MATERIALIZED VIEW {{view_name}}
REFRESH_SCHEDULE = '@daily'
DISPLAY_NAME = {{display_name}}
AS
SELECT
user_id,
COUNT(1) as event_count,
MAX(event_timestamp) as last_activity
FROM events
WHERE
event_type = {{event_type}}
AND event_timestamp > CURRENT_TIMESTAMP - INTERVAL {{lookback_days}} DAY
GROUP BY user_id
```
| Placeholder | Type | Required | Description |
| --------------- | ------ | -------- | --------------------------------------- |
| `view_name` | string | Yes | Internal name for the materialized view |
| `display_name` | string | Yes | Human-readable name shown in the UI |
| `event_type` | string | Yes | Type of event to aggregate |
| `lookback_days` | number | Yes | Number of days of history to include |
***
## Related content
Understand when and why to use templates
Complete reference for placeholder syntax
Pre-compute and cache query results
Core NQL syntax reference
# Configuring dataset statistics
Source: https://docs.narrative.io/guides/platform/configuring-dataset-statistics
Choose which column-level metrics to compute, set refresh schedules, and add per-field overrides from the dataset details page
Dataset statistics configuration lets you control which column-level metrics are computed for a dataset, how often they refresh, and whether specific fields get different treatment than the defaults. You configure all of this from a side panel on the dataset details page.
This guide covers the UI-based configuration. You can also manage statistics configuration programmatically through the [API](/reference/architecture/dataset-statistics#api-endpoints) or the [TypeScript SDK](/guides/sdk/managing-datasets#getting-dataset-statistics).
## Prerequisites
* A dataset you own or have write access to
* The dataset must be on an active [data plane](/concepts/primitives/data-planes)
## Opening the configuration panel
Go to **My Data > Datasets** and select the dataset you want to configure.
You can open the statistics configuration drawer from two places on the dataset details page:
* **Overview tab** — click the pencil icon on the **Extended Stats** row.
* **Actions menu** — click **Actions**, then under the **Stats** group select **Configure statistics**.
If a configuration already exists, the drawer loads it in edit mode. Otherwise, you see the create form with sensible defaults pre-selected.
## Setting default statistics
The **Defaults** section controls which statistics are computed for all eligible fields across the dataset. Select or deselect individual statistics, or use the category-level and global toggles to select groups at once.
Available statistics are organized into five categories:
| Category | Statistics |
| ---------------- | ---------------------------------------------- |
| **Counts** | Value count, Null value count, NaN value count |
| **Bounds** | Lower bound, Upper bound |
| **Distinctness** | Approx count distinct, Count distinct |
| **Distribution** | Histogram, Mean, Standard deviation |
| **Quality** | Completeness |
**Approx count distinct** and **Count distinct** are mutually exclusive — you can enable one or the other, but not both. Approximate counting uses HyperLogLog and is significantly faster on large datasets.
Not all statistics apply to all data types. For example, Mean and Standard deviation only apply to numeric columns (`double` and `long`). The UI automatically filters out incompatible statistics when you configure per-field overrides. See the [type compatibility matrix](/reference/architecture/dataset-statistics#type-compatibility-matrix) for the full mapping.
### Histogram options
When you enable **Histogram**, additional options appear:
| Option | Description | Default |
| ------------ | ------------------------------------------------------------------- | ------- |
| **Max bins** | Maximum number of histogram buckets (2–100,000) | 50 |
| **Overflow** | How to handle values exceeding the bin count — `none` or `truncate` | none |
## Setting a refresh schedule
The **Refresh Trigger** section controls when statistics are recomputed. Choose one of three options:
| Trigger | Behavior |
| ------------- | ------------------------------------------------------------------------------------------ |
| **Manual** | Statistics are only recomputed when you manually trigger an update from the actions menu |
| **On update** | Statistics are automatically recomputed whenever new data is ingested into the dataset |
| **Cron** | Statistics are recomputed on a UTC cron schedule (e.g., `0 0 * * *` for daily at midnight) |
For most datasets, a daily cron schedule (`0 0 * * *`) provides a good balance between freshness and compute cost. Use **On update** only for datasets where you need statistics to reflect every data change immediately.
## Adding field overrides
By default, all eligible fields inherit the statistics you set in the Defaults section. You can override this at two levels:
### Namespace scope overrides
Expand the **Dataset Field Overrides** or **Rosetta Stone Overrides** section and enable the scope override toggle. This lets you set different default statistics for all fields within that namespace, overriding the global defaults.
### Per-field overrides
Within each namespace section, click **Add Field Override** to configure statistics for a specific column or attribute. Each override row lets you:
1. Select a field from the dropdown
2. Choose which statistics to compute for that field
3. Optionally configure histogram options specific to that field
For non-primitive fields (objects and arrays), only **Null value count** is available as a self-statistic. Statistics on nested child fields within objects and arrays are not yet configurable from the UI.
The inheritance model means a per-field override completely replaces the stat set from the parent level. If you want a field to compute only `value_count` and `histogram`, select just those two — it won't also inherit the defaults. For details on how inheritance works, see the [inheritance model](/reference/architecture/dataset-statistics#inheritance-model) in the reference.
## Saving and deleting
* Click **Save** to create or update the configuration. A confirmation toast appears on success.
* In edit mode, click **Delete Configuration** to remove the configuration entirely. This stops automatic statistics computation for the dataset.
## Manually triggering a statistics update
Separately from the configuration, you can trigger an immediate one-off recomputation. From the dataset actions menu under the **Stats** group, click **Update statistics**. This runs the `datasets_calculate_column_stats` [job](/reference/architecture/job-types) regardless of the refresh trigger setting.
## Related content
Why statistics matter and where they appear in the platform
Full API reference, configuration schema, and validation errors
Manage datasets programmatically including reading statistics
Job types including statistics computation
# Configuring dataset retention policies
Source: https://docs.narrative.io/guides/platform/configuring-retention-policies
Add, edit, and manage retention policies from the dataset details page to automate data lifecycle cleanup
Retention policies automate data lifecycle management by removing data that has exceeded a specified age. You can configure retention policies directly from the dataset details page using the **Retention** tab.
This guide covers the UI-based configuration. You can also manage retention policies programmatically through the [Upsert Retention Policies API](/api-reference/datasets/upsert-retention-policies-for-a-dataset) or the [TypeScript SDK](/guides/sdk/managing-datasets).
## Prerequisites
* A dataset you own or have write access to
* The dataset must be on an active [data plane](/concepts/primitives/data-planes)
## Understanding policy classes
Before configuring a policy, choose the right policy class for your use case. The available classes depend on your data plane:
| Policy class | What it deletes | Available on |
| ---------------- | ---------------------------------------------------------- | ----------------------------- |
| **Row TTL** | Individual rows whose event timestamp exceeds the interval | AWS and Snowflake data planes |
| **Snapshot TTL** | Old ingestion snapshots based on snapshot age | AWS data planes |
| **Table TTL** | The entire dataset when table age exceeds the interval | Both |
For a detailed explanation of each policy class, see [Dataset retention policies](/concepts/primitives/dataset-retention-policies).
Deletions on Snowflake-based data planes are immediate with no grace period. AWS-based data planes apply a default 30-day grace period before permanent deletion.
## Adding a retention policy
Go to **My Data > Datasets** and select the dataset you want to configure.
You can start a new policy from several places on the dataset details page:
* **Retention tab** — click the **New Policy +** button. If no policies are configured, the tab shows an empty state that prompts you to add your first policy.
* **Overview tab** — click the pencil icon on the **Data Expiration** row to open the new-policy drawer directly.
* **Actions menu** — click **Actions**, then under the **Retention** group select **Configure retention policy**.
From the Retention tab, click the **New Policy +** button. You can either:
* Click the button directly to open the manual configuration drawer
* Click the dropdown arrow to choose between **Ask Rosetta** (AI-assisted) or **Configure manually**
The Overview pencil and actions-menu shortcuts open the manual configuration drawer directly.
In the configuration drawer, set the following:
**Policy class** — Select the type of retention policy. The available options depend on your data plane.
**Interval** — Choose a preset duration (30, 90, 180, or 365 days) or enter a custom [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) such as `P60D` for 60 days.
**Class-specific settings:**
**Event time clock** — Select the [Rosetta Stone](/concepts/rosetta-stone/overview) attribute that maps to a timestamp column in your dataset. The dropdown shows all `timestamptz` attributes mapped on the dataset. If you don't specify one, the system defaults to `nio_last_modified`.
**Retention value** — Choose how snapshots are handled:
* **Time-based** — Delete snapshots older than the interval
* **Retain everything** — Keep all snapshots indefinitely
* **Expire everything** — Remove all snapshots on each evaluation
**Clock type** — Choose how table age is measured:
* **`created_at`** — Uses when the dataset was created
* **`max_event_time`** — Uses the most recent event timestamp (requires a column reference)
* **`static_time`** — Uses a fixed timestamp (requires a column reference)
**Status** — Toggle the policy on or off. Policies are enabled by default.
Click **Add Policy** to save. The policy appears in the retention policies table.
## Using Ask Rosetta to generate a policy
Instead of configuring a policy manually, you can describe what you want in plain language and let Rosetta generate the configuration for you.
From the **Retention** tab, click the dropdown arrow on the **New Policy +** button and select **Ask Rosetta**.
Enter a plain-language description of the retention behavior you want. For example:
> Delete individual rows older than 90 days based on when the event occurred. Use the event\_timestamp attribute as the clock. Keep the policy enabled.
You can also click **Use this prompt** to start with the sample prompt provided.
Click **Generate Policy**. Rosetta converts your description into a structured policy configuration and opens the configuration drawer with the values pre-filled. Review the settings and adjust if needed.
Click **Add Policy** to save the generated configuration.
If you prefer to configure manually instead, click the **Manual** button in the Ask Rosetta dialog to switch to the manual configuration drawer.
## Editing a retention policy
To modify an existing policy, click the actions menu (three dots) on the policy row and select **Edit**. The configuration drawer opens in edit mode with the current settings pre-populated. You cannot change the policy class when editing — to switch policy classes, delete the existing policy and create a new one.
## Enabling or disabling a policy
You can toggle a policy on or off without deleting it:
* **From the table** — Click the toggle switch next to the policy class badge
* **From the actions menu** — Click the actions menu and select **Enable** or **Disable**
Disabled policies remain in the configuration but are not evaluated during retention runs.
## Deleting a retention policy
Click the actions menu on the policy row and select **Delete**. Confirm the deletion in the dialog that appears. This removes the policy configuration entirely — it does not undo any deletions that have already occurred.
## Related content
Understand how retention policies work and when to use each policy class
Learn where your data physically resides and how it affects retention behavior
Set retention on materialized views using NQL
Manage retention policies programmatically
# Using the Context Selector
Source: https://docs.narrative.io/guides/platform/using-the-context-selector
Choose your data plane, compute pool, database, and schema to control where queries execute and what data is visible
The context selector is the pill-shaped button in the platform's top navigation that shows your current [execution context](/concepts/primitives/execution-context). Use it to switch between data planes, choose compute pools, and set database and schema scope.
The context selector replaces the previous "Data Plane" dropdown. It now includes compute pool, database, and schema settings alongside data plane selection.
## Prerequisites
* A Narrative account with access to at least one [data plane](/concepts/primitives/data-planes)
## Reading the current context
The context selector displays your current settings as a segmented pill button in the top navigation:
| Segment | What it shows | Example |
| ---------------------------- | ---------------------------------------------- | ------------- |
| **Data plane** (icon + name) | The active data plane where operations execute | Narrative DCP |
| **Compute pool** | The compute resources used for query execution | Shared |
| **Database** | The active database within the data plane | DEFAULT |
| **Schema** | The active schema within the database | DEFAULT |
## Changing your execution context
Click the pill button in the top navigation to open the context flyout. The flyout displays four columns—one for each dimension of your execution context.
Click a data plane in the first column. This resets the downstream columns (compute pool, database, and schema) since available options depend on the selected data plane.
Click a compute pool in the second column. The available options depend on your data plane's provider—see [Compute Pools](/concepts/primitives/compute-pools) for details on each type.
Select a database in the third column, then a schema in the fourth column. Selecting the schema confirms your choices and closes the flyout.
## Shortcuts
When downstream columns have only a single option, you can **double-click** a selection to auto-commit the remaining choices and close the flyout immediately. This is common when a data plane has only one compute pool and default database/schema.
## Canceling
Click anywhere outside the flyout to discard your changes. Your previous execution context remains active.
## Common scenarios
### Single data plane user
If your account has access to only one data plane, the context selector auto-selects it for you. You may never need to interact with the context selector—your context is set automatically with sensible defaults.
### Switching compute pools for a production query
If you've been running exploratory queries on a shared compute pool, switch to a dedicated pool before running production workloads:
1. Click the context selector
2. Keep your current data plane selected
3. Select **Dedicated** in the compute pool column
4. Confirm database and schema selections
Your subsequent queries will use dedicated compute resources until you switch back.
### Selecting a Snowflake warehouse for a query
If your Snowflake data plane has multiple registered warehouses (compute pools), you can choose which one to use:
1. Click the context selector
2. Keep your Snowflake data plane selected
3. Select the warehouse you want in the compute pool column
4. Confirm database and schema selections
This is useful when you have warehouses sized for different workloads—for example, a small warehouse for quick lookups and a larger one for heavy data processing.
### Working across multiple data planes
If your organization has multiple data planes (for example, a Narrative-hosted plane and a customer Snowflake instance), use the context selector to switch between them. Remember that datasets, queries, and other objects are scoped to the selected data plane—switching data planes changes what's visible across the platform.
The full context selector is available on desktop. On mobile, a simplified data plane dropdown appears in the navigation menu.
## Related content
Understand how the four context dimensions work together
Learn about dedicated, shared, and default compute pools
Where your data lives and is processed
# Accepting AI Mapping Suggestions
Source: https://docs.narrative.io/guides/rosetta-stone/accepting-ai-suggestions
Review, accept, and reject AI-generated mapping recommendations
This guide covers how to generate AI mapping suggestions and decide which to accept. AI suggestions help you expand your normalizations by proposing mappings for columns that aren't yet mapped to Rosetta Stone attributes.
Before generating suggestions, your dataset should be in the Normalized Datasets interface. If you don't see your dataset there, you may need to create at least one mapping first. See [Mapping Schemas](/guides/rosetta-stone/mapping-schemas).
## Why use AI suggestions
Creating mappings manually requires understanding both your source data and the target attribute schema. AI suggestions accelerate this process by:
**Analyzing column content**: The AI examines actual data values, not just column names, to propose appropriate attributes.
**Generating transformations**: Suggestions include complete transformation expressions that handle format conversion.
**Providing confidence ratings**: Each suggestion includes a confidence score so you can prioritize your review.
**Showing reasoning**: The AI explains why it proposed each mapping, helping you evaluate its accuracy.
## Generating suggestions
### Using the UI
1. Navigate to **Rosetta Stone** → **Normalized Datasets**
2. Click on a dataset to open its detail page
3. Switch to the **Normalize** tab
4. Click **Start Analysis**
The AI analyzes unmapped columns in your dataset. This process:
* Samples data from each column
* Compares patterns against known attribute types
* Generates transformation expressions
* Assigns confidence scores
When complete, suggestions appear grouped by confidence level.
## Understanding suggestion cards
Each suggestion card displays:
| Element | Description |
| -------------------- | ------------------------------------------------ |
| Source column | The column in your dataset |
| Target attribute | The Rosetta Stone attribute the AI recommends |
| Confidence score | 0-100% indicating how certain the AI is |
| Transformation | The expression that will convert source values |
| Reasoning | Why the AI proposed this mapping |
| Before/after samples | Example values showing the transformation effect |
### Confidence groupings
Suggestions are organized into collapsible sections by confidence:
**High confidence (80-100%)**: Strong matches where column content clearly aligns with the attribute. These are generally safe to accept.
**Medium confidence (50-79%)**: Reasonable matches with some uncertainty. Review the reasoning and samples before accepting.
**Low confidence (0-49%)**: Uncertain matches that require careful evaluation. The AI found some alignment but isn't confident.
## Reviewing before/after samples
Before accepting any suggestion, examine the sample transformations:
### What to check
**Value accuracy**: Do the "after" values look correct? Is the transformation producing expected output?
**Edge case handling**: Look for unusual values in the samples. Does the transformation handle them appropriately?
**Null behavior**: If samples include null or empty values, verify they're handled as you expect.
**Format consistency**: Are all output values in the correct format for the attribute?
### Example review
A suggestion to map `gender_code` to `hl7_gender`:
| Before | After |
| ------ | ----------- |
| `"1"` | `"male"` |
| `"2"` | `"female"` |
| `"0"` | `"unknown"` |
| `"3"` | `"other"` |
| `null` | `null` |
Review questions:
* Are the numeric codes mapped correctly? (Verify with your data documentation)
* Is `"0"` → `"unknown"` the right interpretation?
* Should `null` remain `null` or become `"unknown"`?
If the mapping looks correct, accept it. If something seems off, reject it and create a manual mapping instead.
## Accepting suggestions
### Individual acceptance
1. Review the suggestion card
2. Examine the before/after samples
3. Click **Accept**
The suggestion becomes an active mapping immediately. You can verify by switching to the **Evaluate** tab or checking the mapping in your dataset's Rosetta Stone configuration.
### Bulk accepting high-confidence suggestions
When you have many high-confidence suggestions:
1. Review a sample of the high-confidence suggestions to verify quality
2. Click **Accept All High Confidence**
3. Confirm the bulk action
This creates mappings for all suggestions scoring 80% or above. Use this when you've verified that high-confidence suggestions are consistently accurate for your dataset.
Bulk accept creates multiple mappings at once. While high-confidence suggestions are generally reliable, review at least a few before bulk accepting to ensure the AI understands your data correctly.
## Rejecting suggestions
Reject suggestions that don't fit your use case:
1. Click **Reject** on the suggestion card
2. The suggestion is dismissed
Rejected suggestions won't reappear in future analysis runs for the same column.
### Reasons to reject
**Incorrect attribute**: The AI suggested mapping to the wrong attribute. For example, a phone number column suggested for an email attribute.
**Wrong transformation**: The mapping logic doesn't match your data's semantics. For example, gender codes that use different values than the AI assumed.
**Intentionally unmapped**: You don't want this column normalized, perhaps because it contains internal identifiers or data you'll handle differently.
**Duplicate coverage**: Another mapping already covers this data adequately.
## When AI suggestions don't fit
Sometimes none of the AI suggestions work for a column. This happens when:
* Your data uses non-standard formats or encodings
* Column names don't reflect content
* The data requires complex business logic to map correctly
* No standard attribute matches your data's semantics
### Creating manual mappings instead
When AI suggestions miss the mark:
1. Reject the suggestion (if one was proposed)
2. Navigate to your dataset's **Rosetta Stone** tab
3. Click **Add Mapping**
4. Select the source column and target attribute
5. Write your transformation expression
6. Test and activate the mapping
See [Mapping Schemas](/guides/rosetta-stone/mapping-schemas) for detailed guidance on manual mapping creation.
### Requesting custom attributes
If no existing attribute fits your data:
1. Check the [attribute catalog](/reference/rosetta-stone/attribute-types) to confirm no suitable attribute exists
2. Contact Narrative support to discuss creating a custom attribute
3. Once the attribute exists, create your mapping manually
### Improving future suggestions
AI suggestions improve over time. To help:
* Use descriptive column names when possible
* Ensure data quality in source columns
* Accept accurate suggestions—this trains the system on your data patterns
## Validating accepted suggestions
After accepting suggestions, verify they work correctly:
### Run an evaluation
1. Switch to the **Evaluate** tab
2. Click **Evaluate Mappings**
3. Check that newly created mappings score well
New mappings from accepted suggestions should typically score high. If they score medium or low, review the evaluation feedback.
### Query the normalized data
Test that your mappings produce expected results:
```sql theme={null}
SELECT
raw_email,
hl7_gender,
COUNT(1) as count
FROM narrative.rosetta_stone
WHERE _nio_source_dataset_id = 'your-dataset-id'
GROUP BY raw_email, hl7_gender
LIMIT 100
```
Spot-check that values look correct.
### Monitor over time
As new data flows through your mappings, periodically:
* Re-run evaluations to catch emerging issues
* Check for unexpected null rates
* Verify distribution of enum values
## Global warnings
The AI analysis may produce dataset-level warnings that appear at the top of the suggestions list:
| Warning | Meaning |
| -------------------- | ------------------------------------------------ |
| High null rate | Many columns have significant null values |
| Inconsistent formats | Same column contains multiple data formats |
| Low coverage | Few columns could be mapped to known attributes |
| Duplicate columns | Multiple columns appear to contain the same data |
Address these warnings to improve overall data quality, independent of individual mapping decisions.
## Best practices
**Review before accepting**: Even high-confidence suggestions deserve a quick review of the samples.
**Accept incrementally**: Accept a few suggestions, validate them, then continue. This catches issues early.
**Document rejections**: When rejecting suggestions, note why so future team members understand your decisions.
**Re-run after changes**: If you modify source data or add columns, generate new suggestions to capture the changes.
**Combine with evaluation**: After accepting suggestions, run an evaluation to verify quality holistically.
## Related content
Assess mapping quality with AI scoring
Create mappings manually
Understand how confidence scores work
Test mappings before production use
# Creating Normalized Attributes
Source: https://docs.narrative.io/guides/rosetta-stone/creating-normalized-attributes
Best practices for designing and proposing Rosetta Stone attributes
This guide covers how to design well-formed, reusable normalized attributes for Rosetta Stone. A good attribute is unambiguous, well-documented, and mappable from real-world data sources.
You can create custom attributes directly from the UI using the [Attributes interface](/reference/rosetta-stone/attributes-ui). Rosetta Stone can auto-generate the display name, name, and description for you. For the complete attribute type system, see [Attribute Types](/reference/rosetta-stone/attribute-types) and [The Normalization Model](/concepts/rosetta-stone/normalization-model).
## Before you begin
Answer these three questions before proposing a new attribute:
1. **Does this attribute already exist?** Search by aliases, descriptions, and examples---not just names. An attribute called `purchase_channel` might already cover what you plan to name `buy_source`.
2. **Is the meaning unambiguous?** If two reasonable interpretations exist, you need two attributes, not one fuzzy compromise.
3. **Do you have real demand?** At least two concrete use cases from different parties should need this attribute.
If you cannot answer yes to all three, stop and reconsider.
## Naming your attribute
The `name` field uses the pattern `[.]`. Names should be specific enough to understand without additional context.
| Good | Bad | Why bad |
| --------------------- | -------------- | ---------------------------------------- |
| `email` | `address` | Ambiguous---postal or email? |
| `email.hashed_sha256` | `hashed_value` | Hashed what? |
| `total_amount.gross` | `total` | Total of what? |
| `purchase_channel` | `channel` | Marketing channel? Distribution channel? |
| `birth_year` | `year` | Year of what event? |
The `display_name` field is the human-readable label shown in UIs. It should be concise but clear---for example, `"Email (SHA-256 Hash)"` for the attribute named `email.hashed_sha256`.
## Writing the description
The description is the most important piece of documentation for an attribute. A good description eliminates ambiguity for anyone creating mappings.
**Bad:**
> Email address
**Good:**
> The electronic mail address associated with an individual, formatted per RFC 5321, representing a point of contact for that person. Does not include addresses associated with businesses, roles, or distribution lists.
Your description should answer:
* **What does this attribute represent?** Define the concept precisely.
* **What does it explicitly NOT represent?** Call out common confusions. If `email` does not include business or role addresses, say so.
* **What does null mean?** Distinguish between unknown (data was not collected), not applicable (concept does not apply to this record), and intentionally blank (value was collected but is empty).
### Join key designation
Set `is_join_key` to `true` if the attribute is suitable for identity resolution or record linkage. Only primitive types can be join keys.
```json theme={null}
{
"id": 101,
"name": "email.hashed_sha256",
"type": "string",
"is_join_key": true,
"description": "SHA-256 hash of an individual's email address, used for identity resolution. Lowercase and trimmed before hashing.",
"validations": ["LENGTH($this) = 64"]
}
```
## Choosing a type
Select the type that most accurately represents the attribute's semantics. Do not default to `string` when a more specific type exists.
| Type | Use when | Examples |
| ------------- | ----------------------------------------------- | --------------------------------------------- |
| `string` | Text values, identifiers, codes | Names, hashed emails, country codes |
| `boolean` | True/false flags | Opt-in status, active/inactive |
| `double` | Decimal numbers, measurements, currency amounts | Price, latitude, temperature |
| `long` | Integers, counts, whole-number IDs | Age, row counts, sequence numbers |
| `timestamptz` | Points in time (always with timezone) | Event timestamps, signup dates |
| `array` | Repeated values of a single type | Interest categories, identifier lists |
| `object` | Nested structure with named properties | Addresses, coordinates, composite identifiers |
For primitive types, also consider:
* **`enum`**: Use when the attribute has a closed set of allowed values. Add an `enum` array to a `string` type.
* **`validations`**: Use to enforce the canonical format (length constraints, range bounds, patterns).
For arrays, define the element type in `items`. For objects, define the field structure in `properties` and list mandatory fields in `required`.
See the [Attribute Types Reference](/reference/rosetta-stone/attribute-types) for full type definitions and examples.
## Enum governance
When your attribute uses an `enum`, define each value with clear semantics. Every enum value should be a deliberate, documented choice---not an afterthought.
### Example: purchase channel
| Value | Meaning | Common source mappings |
| ------------ | ---------------------------------------------------- | ------------------------------------------ |
| `in_store` | Purchase at a physical retail location | "retail", "brick\_and\_mortar", "pos", "1" |
| `online_web` | Purchase via a web browser | "web", "ecommerce", "website", "2" |
| `online_app` | Purchase via a native mobile app | "app", "mobile\_app", "mobile", "3" |
| `other` | Purchase through a channel not listed above | Any unrecognized value |
| `unknown` | Purchase channel was not collected or is unavailable | NULL, empty string, "N/A" |
### Enum rules
* **Case-sensitive and lowercase.** All enum values use `snake_case`.
* **Always include fallback values.** Use `other` when the source value is known but does not match any defined option. Use `unknown` when the source value is missing or indeterminate.
* **Document when to use `other` vs. `unknown`.** `other` means "we know the value but it doesn't fit our categories." `unknown` means "we don't have the information."
```json theme={null}
{
"id": 250,
"name": "purchase_channel",
"type": "string",
"enum": ["in_store", "online_web", "online_app", "other", "unknown"],
"description": "The sales channel through which a purchase was completed. Does not describe marketing attribution or traffic source."
}
```
## Using references
Use `$ref` with a numeric attribute ID when an object property should conform to an existing attribute's definition. References avoid duplicating validation rules and semantics, and changes to the referenced attribute propagate automatically.
```json theme={null}
{
"id": 600,
"name": "purchase_event",
"type": "object",
"properties": {
"timestamp": {
"$ref": 1007
},
"amount": {
"type": "double"
},
"channel": {
"$ref": 250
}
},
"required": ["timestamp", "amount"]
}
```
In this example, `timestamp` inherits the full definition of the `event_timestamp` attribute (ID 1007) and `channel` inherits from `purchase_channel` (ID 250), including their types, validations, and enum constraints.
See [Reference Type](/reference/rosetta-stone/attribute-types#reference-type) for full details.
## Setting collaborators
The `collaborators` property controls which companies can view or map to your attribute. Use it for organization-specific attributes shared with selected partners.
```json theme={null}
{
"id": 700,
"name": "loyalty_tier",
"type": "string",
"enum": ["bronze", "silver", "gold", "platinum"],
"description": "Customer loyalty program tier level.",
"collaborators": [
{ "company_id": 42, "access": "read" },
{ "company_id": 87, "access": "map" }
]
}
```
When `collaborators` is omitted, the attribute follows default visibility rules for your organization.
## Documenting beyond the schema
The schema defines structure. Documentation captures intent. Include the following alongside your attribute definition to help others create accurate mappings.
### Good examples
Provide 3--5 real values with context explaining why they belong:
* `"user@example.com"` --- a personal email address (valid)
* `"jane.doe+newsletter@gmail.com"` --- plus-addressed email (valid, the plus suffix is part of the address)
* `"info@company.org"` --- a role address (invalid for this attribute; use a business email attribute instead)
### Anti-examples
Values that do NOT belong and where they should go instead:
* `"not-an-email"` --- fails format validation, should be null
* `"support@company.com"` --- role address, belongs in `business_email` attribute
* `""` (empty string) --- should be null, not empty
### Mapping guidance
Document common source representations and the expected transformations:
* **Email**: Lowercase, trim whitespace, validate against pattern `$this LIKE '%@%.%'`
* **Phone**: Convert to E.164 format (e.g., `+15551234567`), strip parentheses, dashes, and spaces
* **Currency**: Decimal with two-digit precision, strip currency symbols and thousands separators
### Canonical representation
For ambiguous types, specify the expected serialization:
* **Timestamps**: ISO 8601 in UTC (`2024-01-15T14:30:00Z`)
* **Decimals**: Precision expectations (e.g., currency amounts use two decimal places)
* **Booleans**: `true` / `false` (not `1`/`0`, not `"yes"`/`"no"`)
## Validation checklist
Before submitting a new attribute, verify:
* `name` is unambiguous without additional context
* `description` explains what it means AND what it does not mean
* `type` is appropriate (not just defaulting to `string`)
* `enum` values are defined with clear semantics (if applicable)
* `validations` enforce the canonical format (if applicable)
* `is_join_key` is set correctly for identity attributes
* The attribute can be mapped from at least 2--3 real datasets
* No existing attribute already covers this concept
## Recommended governance workflow
The following workflow is a recommended team process for introducing new attributes. It is not enforced by the platform.
A champion creates a specification using this guide. The spec includes the full attribute definition, description, examples, anti-examples, and mapping guidance.
Review the proposal for duplicates, ambiguity, and overly company-specific scope. If the attribute only makes sense for one organization, it may belong as an organization attribute rather than a shared one.
Create the attribute with limited `collaborators` access. This restricts visibility while the attribute is being validated.
Prove mapping feasibility by mapping the attribute across at least two real datasets. Confirm that the type, enum values, and validations work with actual source data.
After production use by multiple parties, expand access. Update the description and mapping guidance based on what you learned during validation.
## Design principles
* **Names are presentation, not identity.** The `id` is immutable; `name` and `display_name` can evolve.
* **Separate semantics from syntax.** `description` captures meaning; `type`, `enum`, and `validations` capture structure.
* **Prefer two specific attributes over one vague one.** Ambiguity is a bug, not a feature.
* **Every attribute is maintenance burden.** Only create what has real, demonstrated demand.
* **Use references to avoid duplication.** If a property should match an existing attribute, use `$ref`.
## Related content
Browse and create attributes in the UI
Complete type system documentation
How Rosetta Stone structures types and validations
Map your dataset columns to attributes
# Handling Edge Cases
Source: https://docs.narrative.io/guides/rosetta-stone/edge-cases
Solutions for complex mapping scenarios and unusual data patterns
This guide covers solutions for complex mapping scenarios that go beyond simple column-to-attribute translations. Use these patterns when your data doesn't fit neatly into the standard mapping model.
Familiarize yourself with [basic mapping](/guides/rosetta-stone/mapping-schemas) before tackling these advanced scenarios.
## Multi-value fields
When a single source column contains multiple values (often comma-separated or delimited).
### Scenario
Your source data has an `interests` column with values like:
* `"sports,music,travel"`
* `"cooking"`
* `"tech,gaming,music,movies"`
### Solution: Split to array
Map to an array attribute using `SPLIT`:
```sql theme={null}
SPLIT(interests, ',')
```
This transforms `"sports,music,travel"` into `["sports", "music", "travel"]`.
### Handling whitespace
If values might have spaces around delimiters:
```sql theme={null}
TRANSFORM(SPLIT(interests, ','), x -> TRIM(x))
```
### Handling empty values
Filter out empty strings that result from trailing delimiters:
```sql theme={null}
FILTER(SPLIT(interests, ','), x -> LENGTH(TRIM(x)) > 0)
```
## Conditional mappings
When different source values require completely different transformation logic.
### Scenario: Different ID types in one column
Your `identifier` column contains mixed ID types:
* `"email:user@example.com"`
* `"phone:5551234567"`
* `"cookie:abc123xyz"`
### Solution: Parse and route
```sql theme={null}
STRUCT(
CASE
WHEN identifier LIKE 'email:%' THEN 'email'
WHEN identifier LIKE 'phone:%' THEN 'phone'
WHEN identifier LIKE 'cookie:%' THEN 'cookie_id'
ELSE 'unknown'
END AS type,
SUBSTRING(identifier, POSITION(':' IN identifier) + 1) AS value,
CASE
WHEN identifier LIKE 'email:%' THEN 'hashed_email'
WHEN identifier LIKE 'phone:%' THEN 'hashed_phone'
WHEN identifier LIKE 'cookie:%' THEN 'advertising'
ELSE 'other'
END AS context
)
```
### Scenario: Different date formats in one column
Some rows use `MM/DD/YYYY`, others use `YYYY-MM-DD`:
```sql theme={null}
CASE
WHEN date_col LIKE '____-__-__' THEN
TO_TIMESTAMP(date_col, 'YYYY-MM-DD')
WHEN date_col LIKE '__/__/____' THEN
TO_TIMESTAMP(date_col, 'MM/DD/YYYY')
ELSE
NULL
END
```
## Missing or null data
Handling columns that are frequently null or contain placeholder values.
### Default values
Provide a fallback when data is missing:
```sql theme={null}
COALESCE(gender, 'unknown')
```
### Coalescing from multiple columns
Try multiple columns in order:
```sql theme={null}
COALESCE(primary_email, secondary_email, backup_email)
```
### Treating placeholders as null
Your data uses `"N/A"` or `"-"` as placeholders:
```sql theme={null}
CASE
WHEN UPPER(value) IN ('N/A', 'NA', '-', 'NONE', 'NULL', '') THEN NULL
ELSE value
END
```
### Conditional defaults
Different defaults based on context:
```sql theme={null}
CASE
WHEN country = 'US' AND state IS NULL THEN 'unknown_us_state'
WHEN country != 'US' AND state IS NULL THEN NULL -- Non-US doesn't need state
ELSE state
END
```
## Type coercion challenges
When source data types don't cleanly match target types.
### String to numeric with formatting
Source has currency formatting: `"$1,234.56"`
```sql theme={null}
CAST(
REPLACE(REPLACE(amount, '$', ''), ',', '')
AS FLOAT
)
```
### Boolean interpretations
Sources represent booleans differently:
| Source values | Transformation |
| ---------------- | --------------------- |
| `"yes"/"no"` | `LOWER(col) = 'yes'` |
| `"Y"/"N"` | `UPPER(col) = 'Y'` |
| `"1"/"0"` | `col = '1'` |
| `"true"/"false"` | `LOWER(col) = 'true'` |
| `1/0` (numeric) | `col = 1` |
Handle multiple formats:
```sql theme={null}
CASE
WHEN UPPER(col) IN ('Y', 'YES', 'TRUE', '1') THEN true
WHEN UPPER(col) IN ('N', 'NO', 'FALSE', '0') THEN false
ELSE NULL
END
```
### Timestamp with multiple formats
Parse dates that might be in several formats:
```sql theme={null}
COALESCE(
TRY_TO_TIMESTAMP(date_col, 'YYYY-MM-DD''T''HH24:MI:SS'),
TRY_TO_TIMESTAMP(date_col, 'MM/DD/YYYY'),
TRY_TO_TIMESTAMP(date_col, 'DD-Mon-YYYY'),
TRY_TO_TIMESTAMP(date_col, 'YYYYMMDD')
)
```
The `TRY_TO_TIMESTAMP` function returns null instead of erroring on parse failure, allowing the `COALESCE` to try the next format.
## One-to-many relationships
When a single source column maps to multiple parts of a struct attribute.
### Scenario
Your `full_address` column contains: `"123 Main St, Boston, MA 02101"`
You need to map to a struct with separate fields for street, city, state, and postal code.
### Solution: Parse components
```sql theme={null}
STRUCT(
TRIM(SPLIT_PART(full_address, ',', 1)) AS street,
TRIM(SPLIT_PART(full_address, ',', 2)) AS city,
TRIM(SPLIT_PART(
SPLIT_PART(full_address, ',', 3), ' ', 1
)) AS state,
TRIM(SPLIT_PART(
SPLIT_PART(full_address, ',', 3), ' ', 2
)) AS postal_code
)
```
Parsing unstructured text is error-prone. Consider whether the complexity is worth it, or if the source data can be improved.
## Many-to-one relationships
When multiple source columns combine into a single attribute.
### Scenario: Separate date and time columns
Source has `event_date` and `event_time` as separate columns:
```sql theme={null}
TO_TIMESTAMP(
CONCAT(event_date, ' ', event_time),
'YYYY-MM-DD HH24:MI:SS'
)
```
### Scenario: First name and last name
Combine into a full name:
```sql theme={null}
TRIM(CONCAT(COALESCE(first_name, ''), ' ', COALESCE(last_name, '')))
```
### Scenario: Building a struct from multiple columns
Create a `unique_identifier` from separate columns:
```sql theme={null}
STRUCT(
id_type AS type,
id_value AS value,
COALESCE(id_context, 'default') AS context
)
```
## Multiple columns mapped to the same attribute
When multiple source columns map to the same Rosetta Stone attribute, each mapping produces a value. The NQL compiler collects these into an array and automatically expands (unnests) them into separate rows.
### Scenario
Your dataset has multiple email columns:
| user\_id | name | email\_1 | email\_2 |
| -------- | ----- | ------------------------------------- | --------------------------------- |
| 1 | Alice | [foo@bar.com](mailto:foo@bar.com) | [baz@bar.com](mailto:baz@bar.com) |
| 2 | Bob | [no@thanks.com](mailto:no@thanks.com) | NULL |
Both `email_1` and `email_2` are mapped to the `raw_email` attribute.
### Query behavior
When you query:
```sql theme={null}
SELECT
user_id,
rosetta_stone.raw_email
FROM narrative.rosetta_stone
WHERE ...
```
The result contains one row per non-null email:
| user\_id | raw\_email |
| -------- | ------------------------------------- |
| 1 | [foo@bar.com](mailto:foo@bar.com) |
| 1 | [baz@bar.com](mailto:baz@bar.com) |
| 2 | [no@thanks.com](mailto:no@thanks.com) |
### Why this happens
The NQL compiler:
1. Evaluates each mapping to `raw_email` (both `email_1` and `email_2`)
2. Collects the results into an array: `['foo@bar.com', 'baz@bar.com']`
3. Automatically unnests the array, creating one output row per array element
4. Filters out NULL values during expansion
This behavior enables queries that need all available values for an attribute without requiring you to know the source schema.
### When to use alternatives
**Use COALESCE when you want only one value per row:**
If your source columns represent "primary email" and "backup email" with a preference order, map them using:
```sql theme={null}
COALESCE(primary_email, secondary_email)
```
This returns only the first non-null value instead of expanding to multiple rows.
### Performance considerations
Row expansion multiplies result set size. If a dataset has 5 columns mapped to the same attribute and 1 million source rows, queries could return up to 5 million rows. Consider:
* Filtering early to reduce the row count before expansion
* Using COALESCE if you only need one value per source row
* Being explicit about which specific columns you need
## Version handling
When your source data schema changes over time.
### Scenario: Column renamed in new data
Old data has `sex`, new data has `gender`:
```sql theme={null}
COALESCE(gender, sex)
```
### Scenario: New values added to enum
Old data: `"M"`, `"F"`
New data: `"M"`, `"F"`, `"NB"`, `"O"`
Update your transformation to handle all values:
```sql theme={null}
CASE UPPER(COALESCE(gender, sex))
WHEN 'M' THEN 'male'
WHEN 'F' THEN 'female'
WHEN 'NB' THEN 'other'
WHEN 'O' THEN 'other'
ELSE 'unknown'
END
```
### Scenario: Data format changed
Old timestamps in epoch milliseconds, new data in ISO 8601:
```sql theme={null}
CASE
WHEN timestamp_col ~ '^\d+$' THEN
-- Numeric: assume epoch milliseconds
TO_TIMESTAMP(CAST(timestamp_col AS BIGINT) / 1000)
ELSE
-- String: assume ISO 8601
TO_TIMESTAMP(timestamp_col, 'YYYY-MM-DD''T''HH24:MI:SS')
END
```
## Handling arrays of structs
When your data contains nested structures.
### Scenario: Multiple identifiers per row
Source column `identifiers` contains JSON like:
```json theme={null}
[
{"type": "email", "value": "user@example.com"},
{"type": "phone", "value": "5551234567"}
]
```
### Solution: Parse JSON array
```sql theme={null}
TRANSFORM(
JSON_PARSE(identifiers),
x -> STRUCT(
x.type AS type,
x.value AS value,
'parsed' AS context
)
)
```
## Handling special characters
When source data contains characters that need escaping or encoding.
### URL encoding
Source has URL-encoded values:
```sql theme={null}
URL_DECODE(encoded_value)
```
### HTML entities
Source has HTML entities like `&`:
```sql theme={null}
REPLACE(
REPLACE(
REPLACE(value, '&', '&'),
'<', '<'
),
'>', '>'
)
```
### Unicode normalization
Ensure consistent unicode representation:
```sql theme={null}
NORMALIZE(unicode_string, 'NFC')
```
## Performance considerations
Complex transformations can impact query performance.
### Avoid expensive operations in hot paths
If a transformation is complex, consider:
* Pre-computing values in a materialized view
* Simplifying the logic where possible
* Breaking into multiple simpler mappings
### Test with realistic data volumes
Edge case handling that works in tests might be slow at scale. Test with production-like data volumes.
## Related content
Basic mapping creation
Test your edge case handling
Complete function reference
Understand attribute types and validations
# Managing Mapping Evaluations
Source: https://docs.narrative.io/guides/rosetta-stone/managing-evaluations
Run AI evaluations, filter by confidence, and understand mapping quality
This guide covers how to evaluate your Rosetta Stone mappings using AI-powered confidence scoring. Evaluations help you identify which mappings are working well and which need attention.
Before running evaluations, you'll need a dataset with existing mappings. See [Mapping Schemas](/guides/rosetta-stone/mapping-schemas) to create mappings first.
## Why evaluate mappings
Manual validation tests specific cases you define, but it can't anticipate every issue. AI evaluation analyzes your mappings holistically, looking for:
**Transformation gaps**: Cases your transformation doesn't handle, like unexpected input values or edge cases.
**Pattern mismatches**: Source data that doesn't align with the target attribute's expected format.
**Quality indicators**: Signals that suggest a mapping might produce incorrect results under certain conditions.
Evaluations complement manual validation—they surface issues you might not have thought to test for.
## Running an evaluation
### Using the UI
1. Navigate to **Rosetta Stone** → **Normalized Datasets**
2. Click on a dataset to open its detail page
3. On the **Evaluate** tab, click **Evaluate Mappings**
The system analyzes all mappings on the dataset. This process examines column names, samples actual data, and analyzes transformation logic. Depending on the number of mappings, evaluation may take a few moments.
When complete, you'll see:
* A **confidence gradient bar** showing the distribution across quality tiers
* **Confidence level cards** with counts for High, Medium, Low, and Not Scored
* **Individual mapping cards** with scores and AI feedback
## Understanding evaluation results
### The confidence gradient
The gradient bar provides a visual summary of your dataset's mapping quality:
| Color | Score Range | Meaning |
| ------ | ----------- | ------------------------------------------- |
| Green | 80-100% | High confidence—mappings are likely correct |
| Yellow | 50-79% | Medium confidence—review recommended |
| Red | 0-49% | Low confidence—manual review required |
| Gray | Not scored | Mappings that couldn't be evaluated |
A dataset with mostly green indicates well-configured mappings. Significant yellow or red sections suggest areas needing attention.
### Confidence level cards
Each card shows the count of mappings in that tier:
**High confidence (80-100%)**: These mappings show strong alignment between source columns and target attributes. The AI found consistent patterns and appropriate transformations. You can generally trust these, though spot-checking critical data flows is still recommended.
**Medium confidence (50-79%)**: The mapping is probably correct but has characteristics worth reviewing. Common reasons include partial transformation coverage or ambiguous column names.
**Low confidence (0-49%)**: The AI identified significant concerns. These mappings require human verification before relying on them.
**Not scored**: Mappings that couldn't be evaluated, typically system-generated mappings for internal columns.
### Individual mapping feedback
Click on any mapping card to see detailed AI feedback:
* **Score breakdown**: Factors contributing to the confidence score
* **Identified issues**: Specific concerns the AI found
* **Recommendations**: Suggested improvements
* **Sample analysis**: How the mapping performs on actual data samples
## Filtering mappings
### By confidence level
Click any confidence level card to filter the mapping list:
1. Click the **Low** card to see only low-confidence mappings
2. Click the **Medium** card to see medium-confidence mappings
3. Click **All** to clear the filter
This helps you focus on mappings that need attention without being distracted by well-functioning ones.
### By mapping type
Filter by how the mapping was created:
| Type | Description |
| ------- | ---------------------------------------------------- |
| System | Auto-generated for internal columns (always correct) |
| AI | Created from AI suggestions |
| User | Manually created by users |
| Lineage | Inherited from parent datasets |
Click the corresponding overview card to filter. You can combine type and confidence filters—for example, view only low-confidence user mappings.
## Understanding low confidence scores
When a mapping scores low, the AI provides specific feedback. Common issues include:
### Missing transformation cases
The transformation doesn't handle all values in the source data:
```
Issue: Transformation covers 85% of values.
Unhandled values: "N/A", "Not Specified", ""
Recommendation: Add cases for missing values or map to default.
```
**Resolution**: Update the transformation to handle additional cases:
```sql theme={null}
CASE UPPER(status)
WHEN 'ACTIVE' THEN 'active'
WHEN 'INACTIVE' THEN 'inactive'
WHEN 'N/A' THEN 'unknown'
WHEN 'NOT SPECIFIED' THEN 'unknown'
WHEN '' THEN 'unknown'
ELSE 'unknown'
END
```
### Type mismatch concerns
The source data contains values that might not convert correctly:
```
Issue: Source column contains non-numeric values but maps to integer attribute.
Samples failing conversion: "N/A", "TBD", "-"
Recommendation: Add validation or filter invalid values.
```
**Resolution**: Add explicit handling for non-numeric values:
```sql theme={null}
CASE
WHEN REGEXP_LIKE(value_col, '^[0-9]+$') THEN CAST(value_col AS INTEGER)
ELSE NULL
END
```
### Ambiguous column mapping
The column name doesn't clearly indicate its content:
```
Issue: Column "field_7" mapped to "email".
Pattern analysis inconclusive—only 60% of values match email format.
Recommendation: Verify this column contains email addresses.
```
**Resolution**: Verify the mapping is correct by examining sample data. If it's wrong, update or remove the mapping.
### Edge case failures
The transformation fails for certain input patterns:
```
Issue: Date parsing fails for some formats.
Successful: "2024-01-15" (ISO format)
Failed: "01/15/2024" (US format), "15-Jan-2024" (text month)
Recommendation: Update transformation to handle multiple date formats.
```
**Resolution**: Use a more flexible date parsing approach or normalize source data before mapping.
## Refreshing stale evaluations
Evaluation results can become outdated when:
* You modify transformation expressions
* The source data changes significantly
* Significant time has passed since the last evaluation
### Recognizing stale evaluations
The UI indicates when results may be stale:
* **Last evaluated** timestamp shows when evaluation ran
* A **Stale** badge appears if mappings changed since evaluation
* Confidence scores may show a warning indicator
### Re-running evaluations
To refresh results:
1. Navigate to the dataset's **Evaluate** tab
2. Click **Evaluate Mappings** to run a new evaluation
3. The new results replace the previous evaluation
Re-evaluate after:
* Modifying any transformation expression
* Accepting or rejecting AI suggestions
* Observing unexpected query results
* Major updates to source data
## Best practices
**Evaluate regularly**: Run evaluations periodically, especially after changes to mappings or source data.
**Address low confidence first**: Focus on low-confidence mappings before medium-confidence ones.
**Don't ignore medium confidence**: While not urgent, medium-confidence mappings may have subtle issues worth investigating.
**Combine with manual validation**: Use AI evaluation to identify potential issues, then validate specific cases manually.
**Document your decisions**: When you verify a low-confidence mapping is actually correct, document why so future reviewers understand.
## Related content
Understand how confidence scores are calculated
Generate and accept AI mapping recommendations
Manual validation techniques
Reference for the interface elements
# Mapping Schemas
Source: https://docs.narrative.io/guides/rosetta-stone/mapping-schemas
Create mappings between your data schema and Rosetta Stone attributes
This guide covers how to create and manage mappings between your dataset columns and Rosetta Stone attributes. Mappings are the translation layer that enables your data to be queried through the normalized `narrative.rosetta_stone` table.
For background on how mappings work, see [How Rosetta Stone Works](/concepts/rosetta-stone/how-it-works).
## Prerequisites
* A dataset uploaded to Narrative
* Understanding of your dataset's column semantics
* Familiarity with basic NQL syntax (for transformation expressions)
## Using the UI
### View suggested mappings
1. Navigate to **Datasets** and select your dataset
2. Click the **Rosetta Stone** tab
3. Review the auto-generated mapping suggestions
The UI displays your columns alongside suggested attributes. Each suggestion includes:
* The target attribute name and description
* A confidence score
* A preview of sample data after transformation
### Accept a suggested mapping
For high-confidence suggestions:
1. Review the suggested attribute and sample output
2. Click **Accept** to create the mapping
The mapping becomes active immediately.
### Modify a suggested mapping
If a suggestion is close but not quite right:
1. Click **Edit** on the suggestion
2. Modify the target attribute or transformation expression
3. Click **Save**
### Create a manual mapping
For columns without suggestions or when you need a custom mapping:
1. Click **Add Mapping**
2. Select the source column
3. Search for and select the target attribute
4. Write a transformation expression if needed
5. Click **Create**
### Reject a suggestion
If a suggestion is incorrect:
1. Click the **X** or **Reject** button
2. The suggestion is removed and won't reappear
## Using the API
### List available attributes
Before creating mappings, browse available attributes:
```bash theme={null}
curl -X GET "https://api.narrative.io/v1/attributes" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json"
```
Response:
```json theme={null}
{
"data": [
{
"id": "hl7_gender",
"name": "hl7_gender",
"description": "Gender using HL7 administrative gender codes",
"type": "enum",
"allowed_values": ["male", "female", "other", "unknown"]
},
{
"id": "event_timestamp",
"name": "event_timestamp",
"description": "The timestamp when the event occurred",
"type": "timestamp"
}
]
}
```
### Get mapping suggestions for a dataset
Request auto-generated mapping suggestions:
```bash theme={null}
curl -X GET "https://api.narrative.io/v1/datasets/{dataset_id}/mappings/suggestions" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json"
```
Response:
```json theme={null}
{
"data": [
{
"source_column": "gender",
"suggested_attribute": "hl7_gender",
"confidence": 0.95,
"suggested_transformation": "LOWER(gender)",
"sample_output": ["male", "female", "male"]
},
{
"source_column": "event_date",
"suggested_attribute": "event_timestamp",
"confidence": 0.87,
"suggested_transformation": "TO_TIMESTAMP(event_date, 'YYYY-MM-DD')",
"sample_output": ["2024-01-15T00:00:00Z", "2024-01-16T00:00:00Z"]
}
]
}
```
### Create a mapping
Create a new mapping for a dataset column:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dataset_id": "your-dataset-id",
"source_column": "sex",
"target_attribute": "hl7_gender",
"transformation": "CASE sex WHEN '\''M'\'' THEN '\''male'\'' WHEN '\''F'\'' THEN '\''female'\'' ELSE '\''unknown'\'' END"
}'
```
Response:
```json theme={null}
{
"data": {
"id": "mapping-12345",
"dataset_id": "your-dataset-id",
"source_column": "sex",
"target_attribute": "hl7_gender",
"transformation": "CASE sex WHEN 'M' THEN 'male' WHEN 'F' THEN 'female' ELSE 'unknown' END",
"status": "active",
"created_at": "2024-01-15T10:30:00Z"
}
}
```
### Accept a system-proposed mapping
Accept a suggestion from the auto-generated mappings:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings/{suggestion_id}/accept" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json"
```
### Update an existing mapping
Modify a mapping's transformation:
```bash theme={null}
curl -X PATCH "https://api.narrative.io/v1/mappings/{mapping_id}" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"transformation": "CASE LOWER(sex) WHEN '\''m'\'' THEN '\''male'\'' WHEN '\''f'\'' THEN '\''female'\'' WHEN '\''nb'\'' THEN '\''other'\'' ELSE '\''unknown'\'' END"
}'
```
### Delete a mapping
Remove a mapping:
```bash theme={null}
curl -X DELETE "https://api.narrative.io/v1/mappings/{mapping_id}" \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
## Mapping types
The Mappings API accepts three mapping `type`s:
* `value_mapping` — a single SQL expression producing a primitive value.
* `object_mapping` — one expression per property of an object attribute.
* `cached_mapping` — joins the source dataset against a precomputed cache dataset at query time. Use this when the transformation is expensive or comes from an external workflow.
For the full schema, validation rules, and constraints (including the company-scope and opt-out restrictions on cached mappings), see [Mapping types](/reference/rosetta-stone/mapping-types).
## Writing transformation expressions
Transformation expressions are NQL expressions that convert source values to the target attribute format.
### Simple column reference
When no transformation is needed:
```sql theme={null}
source_column
```
### Type conversion
Convert a string to an integer:
```sql theme={null}
CAST(string_column AS INTEGER)
```
Convert to timestamp:
```sql theme={null}
TO_TIMESTAMP(date_string, 'YYYY-MM-DD')
```
### Conditional logic
Map discrete values:
```sql theme={null}
CASE source_column
WHEN 'A' THEN 'value_a'
WHEN 'B' THEN 'value_b'
ELSE 'default'
END
```
Map ranges:
```sql theme={null}
CASE
WHEN age < 18 THEN 'minor'
WHEN age < 65 THEN 'adult'
ELSE 'senior'
END
```
### Null handling
Provide a default for nulls:
```sql theme={null}
COALESCE(source_column, 'unknown')
```
Replace specific values with null:
```sql theme={null}
NULLIF(source_column, '')
```
### String manipulation
Normalize case:
```sql theme={null}
LOWER(source_column)
UPPER(source_column)
```
Trim whitespace:
```sql theme={null}
TRIM(source_column)
```
Extract a substring:
```sql theme={null}
SUBSTRING(source_column, 1, 10)
```
### Combining multiple columns
Concatenate values:
```sql theme={null}
CONCAT(first_name, ' ', last_name)
```
Build a struct:
```sql theme={null}
STRUCT(
'email_sha256' AS type,
hashed_email AS value,
'hashed_email' AS context
)
```
## Complete worked example
### Scenario
You have a customer dataset with these columns:
| Column | Sample values |
| ------------- | ------------------------------ |
| `cust_id` | `"12345"`, `"67890"` |
| `sex` | `"M"`, `"F"`, `"X"` |
| `signup_date` | `"01/15/2024"`, `"02/20/2024"` |
| `email_hash` | `"a1b2c3..."`, `"d4e5f6..."` |
### Step 1: Review suggestions
The system suggests:
* `sex` → `hl7_gender` (medium confidence)
* `signup_date` → `event_timestamp` (high confidence)
* `email_hash` → `email_sha256` (high confidence)
### Step 2: Accept high-confidence mappings
Accept the `signup_date` and `email_hash` mappings.
### Step 3: Customize the gender mapping
The suggested transformation doesn't handle `"X"` correctly. Create a custom mapping:
```sql theme={null}
CASE sex
WHEN 'M' THEN 'male'
WHEN 'F' THEN 'female'
WHEN 'X' THEN 'other'
ELSE 'unknown'
END
```
### Step 4: Create a manual mapping for customer ID
Map `cust_id` to the `unique_identifier` attribute:
```sql theme={null}
STRUCT(
'customer_id' AS type,
cust_id AS value,
'internal' AS context
)
```
### Step 5: Test and activate
Test all mappings, then activate.
## Troubleshooting
| Issue | Cause | Solution |
| -------------------------- | -------------------------------------------------- | ------------------------------------- |
| "Type mismatch" error | Transformation output doesn't match attribute type | Use `CAST()` or adjust transformation |
| "Invalid enum value" error | Transformation produces values not in allowed list | Add missing cases to `CASE` statement |
| Null values in output | Transformation doesn't handle all source values | Add `ELSE` clause or use `COALESCE` |
| "Column not found" error | Typo in column name | Verify column name matches exactly |
## Related content
Understand the mechanics of attributes and mappings
Test and verify your mappings
Handle complex mapping scenarios
Complete function reference
API reference for `value_mapping`, `object_mapping`, and `cached_mapping`
# Validating Mappings
Source: https://docs.narrative.io/guides/rosetta-stone/validating-mappings
Test and verify your schema mappings before production use
This guide covers how to test and validate your Rosetta Stone mappings before activating them. Validation catches errors early, ensuring data quality before your data enters the normalized view.
Before validating, you'll need created mappings. See [Mapping Schemas](/guides/rosetta-stone/mapping-schemas) to create your first mapping.
## Why validate mappings
Validation serves several purposes:
**Catch transformation errors**: Identify syntax errors, type mismatches, or logic bugs in transformation expressions before they affect production data.
**Verify edge cases**: Ensure your transformations handle unusual or unexpected values in your source data.
**Preview output**: See exactly how your data will appear after normalization.
**Ensure attribute compliance**: Confirm that transformed values meet attribute validations (enums, ranges, patterns).
## Testing with sample data
### Using the UI
1. Navigate to your dataset's **Rosetta Stone** tab
2. Select one or more mappings
3. Click **Test Mappings**
The test interface displays:
* Sample rows from your source data
* The transformation applied
* The resulting output
* Any validation errors or warnings
\[Screenshot placeholder: Test results showing source data and transformed output]
Review each column to verify the transformation produces expected results.
### Using the API
Test a specific mapping against sample data:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings/{mapping_id}/test" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sample_size": 100
}'
```
Response:
```json theme={null}
{
"data": {
"mapping_id": "mapping-12345",
"samples_tested": 100,
"samples_passed": 97,
"samples_failed": 3,
"results": [
{
"source_value": "M",
"transformed_value": "male",
"validation_status": "passed"
},
{
"source_value": "X",
"transformed_value": null,
"validation_status": "failed",
"error": "Value 'X' not in allowed enum values"
}
],
"error_summary": {
"enum_validation_failed": 3
}
}
}
```
Test with custom sample data:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings/{mapping_id}/test" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"custom_samples": [
{"sex": "M"},
{"sex": "F"},
{"sex": "X"},
{"sex": null},
{"sex": ""}
]
}'
```
This allows you to specifically test edge cases you're concerned about.
## Common validation checks
### Type conversion accuracy
Verify that type conversions work correctly:
| Source type | Target type | Test for |
| ------------------ | --------------- | ----------------------------------- |
| String → Integer | Numeric strings | Non-numeric values, decimals |
| String → Timestamp | Date strings | Malformed dates, timezone handling |
| Integer → Float | Numbers | Precision loss |
| String → Boolean | Text values | Case sensitivity, unexpected values |
**Example**: Testing date parsing
```sql theme={null}
-- Transformation
TO_TIMESTAMP(date_col, 'MM/DD/YYYY')
```
Test with:
* Valid dates: `"01/15/2024"`
* Invalid format: `"2024-01-15"` (wrong format)
* Edge cases: `"02/29/2024"` (leap year), `"13/01/2024"` (invalid month)
### Enum value coverage
For enum attributes, ensure all source values map to valid enum members:
1. Get unique values from your source column
2. Trace each through your transformation
3. Verify all outputs are in the allowed values list
**Example**: Testing gender mapping
Source values: `"M"`, `"F"`, `"Male"`, `"Female"`, `"m"`, `"f"`, `""`, `null`
Transformation:
```sql theme={null}
CASE UPPER(gender)
WHEN 'M' THEN 'male'
WHEN 'MALE' THEN 'male'
WHEN 'F' THEN 'female'
WHEN 'FEMALE' THEN 'female'
ELSE 'unknown'
END
```
Verify that all variations map correctly.
### Null handling
Test how your transformation handles:
* Explicit nulls
* Empty strings
* Missing values (if applicable)
Ensure nulls are handled intentionally, not accidentally.
### Date and timestamp parsing
Date transformations are common sources of errors. Test:
| Scenario | Example |
| ---------------------------- | ------------------------------ |
| Standard format | `"01/15/2024"` |
| Different separators | `"01-15-2024"`, `"01.15.2024"` |
| Year variations | `"01/15/24"` (two-digit year) |
| Time zones | `"2024-01-15T10:30:00-05:00"` |
| UTC timestamps | `"2024-01-15T15:30:00Z"` |
| Timestamps with milliseconds | `"2024-01-15T15:30:00.123Z"` |
## Reviewing validation results
### Understanding error messages
Common validation errors and their meanings:
| Error | Cause | Solution |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------- |
| "Type mismatch" | Output type doesn't match attribute type | Add explicit `CAST()` or fix transformation logic |
| "Invalid enum value" | Output not in allowed values | Add case to transformation or map to default |
| "Validation failed: minimum" | Numeric value below minimum | Add range check or filter invalid data |
| "Validation failed: pattern" | String doesn't match regex | Verify format or clean source data |
| "Null value for required field" | Required field is null | Add `COALESCE()` or fix source data |
### Sample output review
When reviewing test output:
1. **Spot check random samples**: Verify that typical values transform correctly
2. **Focus on edge cases**: Pay extra attention to unusual values
3. **Check for unexpected nulls**: Nulls appearing where values are expected indicate transformation issues
4. **Verify consistency**: Same input should always produce same output
## Iterating on mappings
When validation reveals issues, follow this process:
### 1. Identify the root cause
Is the issue:
* A bug in the transformation expression?
* Unexpected values in source data?
* A mismatch between data and attribute definition?
### 2. Update the mapping
Fix the transformation expression to handle the issue:
```bash theme={null}
curl -X PATCH "https://api.narrative.io/v1/mappings/{mapping_id}" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"transformation": "CASE ... END"
}'
```
### 3. Re-validate
Run tests again to confirm the fix works and doesn't introduce new issues.
### 4. Test with full sample
Before activating, test against a larger sample to catch less common edge cases:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings/{mapping_id}/test" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sample_size": 1000
}'
```
## Promoting to production
Once validation passes:
### Review summary statistics
Check the overall validation results:
* What percentage of samples passed?
* Are failures concentrated in specific value patterns?
* Is the failure rate acceptable for your use case?
### Activate the mapping
Via UI:
1. Click **Activate** on the mapping
2. Confirm the activation
Via API:
```bash theme={null}
curl -X POST "https://api.narrative.io/v1/mappings/{mapping_id}/activate" \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
### Monitor initial results
After activation, query the normalized data to verify it looks correct:
```sql theme={null}
SELECT
target_attribute,
COUNT(1) as count
FROM narrative.rosetta_stone
WHERE _nio_source_dataset_id = 'your-dataset-id'
GROUP BY target_attribute
```
This helps catch any issues that weren't apparent in sample testing.
## Best practices
**Test with representative data**: Ensure your test samples include the full variety of values in your dataset.
**Include edge cases deliberately**: Don't rely on random sampling to cover edge cases. Test specific problematic values.
**Document mapping decisions**: Record why certain values map to certain outputs, especially for ambiguous cases.
**Version your mappings**: Keep track of mapping changes so you can roll back if issues arise.
**Test after source data changes**: When your source data format changes, re-validate existing mappings.
## Related content
Create and manage mappings
Handle complex mapping scenarios
Complete function reference
Understand the normalization pipeline
# SDK Authentication
Source: https://docs.narrative.io/guides/sdk/authentication
Configure API key authentication for the Narrative TypeScript SDK
The Narrative SDK uses API keys for authentication. This guide covers how to configure authentication securely and manage credentials across different environments.
## Prerequisites
* An API key with appropriate permissions (see [API Keys](/account-settings/api-keys) and [Permissions Reference](/reference/security/permissions))
* The Narrative SDK installed in your project
## Authentication methods
### Environment variable (recommended)
Store your API key in an environment variable to keep it out of source control:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
```
Set the environment variable before running your application:
```bash theme={null}
export NARRATIVE_API_KEY="your-api-key-here"
node app.js
```
```powershell theme={null}
$env:NARRATIVE_API_KEY = "your-api-key-here"
node app.js
```
```batch theme={null}
set NARRATIVE_API_KEY=your-api-key-here
node app.js
```
### Using a .env file
For local development, use a `.env` file with a package like `dotenv`:
```bash theme={null}
npm install dotenv
```
Create a `.env` file in your project root:
```bash theme={null}
NARRATIVE_API_KEY=your-api-key-here
NARRATIVE_ENVIRONMENT=prod
```
Load the environment variables in your code:
```typescript theme={null}
import 'dotenv/config';
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
environment: process.env.NARRATIVE_ENVIRONMENT || 'prod',
});
```
Never commit `.env` files to source control. Add `.env` to your `.gitignore` file.
### Direct configuration
For testing or when credentials are managed externally:
```typescript theme={null}
const api = new NarrativeApi({
apiKey: 'your-api-key-here',
});
```
Avoid hardcoding API keys in source code. Use environment variables or a secrets manager in production.
## Environment selection
The SDK supports multiple environments:
| Environment | Use case |
| ----------- | --------------------------------- |
| `prod` | Production workloads (default) |
| `dev` | Development and testing |
| Custom URL | Self-hosted or custom deployments |
```typescript theme={null}
// Production (default)
const prodApi = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
// Development
const devApi = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
environment: 'dev',
});
// Custom URL
const customApi = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
environment: 'https://custom.narrative.example.com/',
});
```
## Verifying authentication
Test your credentials by making a simple API call:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
async function verifyAuth() {
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
try {
const datasets = await api.getDatasets();
console.log('Authentication successful!');
console.log(`Found ${datasets.records.length} datasets`);
} catch (error) {
if (error.status === 401) {
console.error('Authentication failed: Invalid API key');
} else if (error.status === 403) {
console.error('Authentication failed: Insufficient permissions');
} else {
console.error('Unexpected error:', error);
}
}
}
verifyAuth();
```
## Updating credentials at runtime
You can change the API key after initialization:
```typescript theme={null}
const api = new NarrativeApi({});
// Set API key later
api.setApiKey('new-api-key');
// Verify the change
console.log('Current key:', api.getApiKey());
```
## Security best practices
| Practice | Description |
| ------------------------- | ------------------------------------------------ |
| Use environment variables | Keep credentials out of source code |
| Rotate keys regularly | Create new keys and revoke old ones periodically |
| Use minimum permissions | Grant only the permissions needed for the task |
| Set key expiration | Configure expiration dates for all API keys |
| Monitor key usage | Review access logs for unusual activity |
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------ | ---------------------------- | -------------------------------------------------- |
| `401 Unauthorized` | Invalid or expired API key | Verify the key is correct and hasn't expired |
| `403 Forbidden` | Insufficient permissions | Check the key's permissions in Settings > API Keys |
| `undefined` API key | Environment variable not set | Ensure the variable is exported in your shell |
| Key not working in CI/CD | Variable not configured | Add the secret to your CI/CD environment |
## Related content
Create and manage API keys
Complete configuration options
Handle authentication errors gracefully
All available permission resources and access levels
# Choosing the Right Model
Source: https://docs.narrative.io/guides/sdk/choosing-inference-models
Select the appropriate model based on task complexity, speed, and cost
Model Inference supports multiple models with different capabilities. This guide helps you choose the right model for your use case.
## Quick selection guide
| Your Priority | Recommended Model |
| ------------------------ | ----------------------------------------------------------------------------- |
| **Speed and cost** | `anthropic.claude-haiku-4.5` or `openai.o4-mini` |
| **Balanced performance** | `anthropic.claude-sonnet-4.5` or `anthropic.claude-sonnet-4.6` |
| **Complex reasoning** | `anthropic.claude-opus-4.5`, `anthropic.claude-opus-4.6`, or `openai.gpt-4.1` |
## Model comparison
### Anthropic Claude models
| Model | Speed | Capability | Best For |
| ----------------- | ------- | ---------- | --------------------------------------------------- |
| Claude Haiku 4.5 | Fastest | Good | Simple classification, extraction |
| Claude Sonnet 4.5 | Fast | Better | Analysis, summarization, most tasks |
| Claude Sonnet 4.6 | Fast | Better | Latest Sonnet generation with improved reasoning |
| Claude Opus 4.5 | Slower | Best | Complex reasoning, nuanced decisions |
| Claude Opus 4.6 | Slower | Best | Latest Opus generation for the most demanding tasks |
### OpenAI models
| Model | Speed | Capability | Best For |
| ------------ | -------- | ---------- | ----------------------------- |
| o4-mini | Fastest | Good | Quick responses, simple tasks |
| GPT-4.1 | Moderate | Better | Advanced reasoning |
| GPT-oss-120b | Moderate | Good | General-purpose tasks |
## When to use smaller models
Use Claude Haiku or o4-mini when:
* **Task is straightforward**: Binary classification, simple extraction
* **High volume**: Processing many items where speed matters
* **Cost sensitivity**: Budget constraints require efficiency
* **Latency matters**: User-facing features needing fast response
```typescript theme={null}
// Good use case for Haiku: Simple classification
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-haiku-4.5',
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Is this email spam? Subject: "You won $1M!"' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
is_spam: { type: 'boolean' },
confidence: { type: 'number', minimum: 0, maximum: 1 }
},
required: ['is_spam', 'confidence']
}
}
});
```
## When to use medium models
Use Claude Sonnet 4.5 when:
* **Task requires understanding**: Content analysis, summarization
* **Balanced needs**: Good quality without excessive cost
* **Most production use cases**: Default choice for typical workflows
```typescript theme={null}
// Good use case for Sonnet: Content analysis
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages: [
{
role: 'system',
content: [{ type: 'text', text: 'Analyze dataset descriptions for quality and completeness.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Dataset: Customer transactions\nColumns: user_id, amount, date, category' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
summary: { type: 'string' },
completeness_score: { type: 'number', minimum: 0, maximum: 1 },
missing_elements: { type: 'array', items: { type: 'string' } },
suggestions: { type: 'array', items: { type: 'string' } }
},
required: ['summary', 'completeness_score']
}
}
});
```
## When to use larger models
Use Claude Opus 4.5 or GPT-4.1 when:
* **Complex reasoning required**: Multi-step analysis, nuanced judgment
* **High stakes**: Decisions with significant impact
* **Ambiguous inputs**: Tasks requiring interpretation
* **Quality over speed**: Accuracy is paramount
```typescript theme={null}
// Good use case for Opus: Complex analysis
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-opus-4.5',
messages: [
{
role: 'system',
content: [{ type: 'text', text: 'You are a data governance expert. Analyze datasets for compliance risks.' }]
},
{
role: 'user',
content: [{
type: 'text',
text: `Analyze this dataset schema for privacy compliance:
- email (string)
- phone (string)
- purchase_history (array)
- ip_address (string)
- device_fingerprint (string)`
}]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
risk_level: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
pii_fields: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
pii_type: { type: 'string' },
risk: { type: 'string' }
},
required: ['field', 'pii_type', 'risk']
}
},
compliance_concerns: { type: 'array', items: { type: 'string' } },
recommendations: { type: 'array', items: { type: 'string' } }
},
required: ['risk_level', 'pii_fields', 'compliance_concerns', 'recommendations']
}
}
});
```
## Task-based recommendations
### Classification tasks
| Complexity | Recommended Model |
| ------------------------------------------- | ---------------------- |
| Binary (yes/no, spam/not spam) | Claude Haiku |
| Multi-class (3-5 categories) | Claude Haiku or Sonnet |
| Complex taxonomy (many categories, nuanced) | Claude Sonnet or Opus |
### Extraction tasks
| Complexity | Recommended Model |
| ----------------------------------------- | ----------------- |
| Simple fields (dates, names, numbers) | Claude Haiku |
| Structured entities (addresses, products) | Claude Sonnet |
| Complex relationships (multi-entity) | Claude Opus |
### Generation tasks
| Complexity | Recommended Model |
| ---------------------------------------- | ---------------------- |
| Short text (taglines, labels) | Claude Haiku or Sonnet |
| Medium content (descriptions, summaries) | Claude Sonnet |
| Long-form (reports, analysis) | Claude Sonnet or Opus |
### Transformation tasks
| Complexity | Recommended Model |
| ------------------------------------------------- | ----------------- |
| Format conversion (dates, units) | Claude Haiku |
| Language translation (technical to plain) | Claude Sonnet |
| Complex interpretation (natural language to code) | Claude Opus |
## Testing different models
Try multiple models on sample data to compare quality:
```typescript theme={null}
async function compareModels(prompt: string, schema: object) {
const models = [
'anthropic.claude-haiku-4.5',
'anthropic.claude-sonnet-4.5',
'anthropic.claude-opus-4.5'
];
const results = await Promise.all(
models.map(async (model) => {
const start = Date.now();
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model,
messages: [
{ role: 'user', content: [{ type: 'text', text: prompt }] }
],
inference_config: { output_format_schema: schema }
});
const result = await waitForJob(job.id);
const duration = Date.now() - start;
return {
model,
duration,
tokens: result.result?.usage.total_tokens,
output: result.result?.structured_output
};
})
);
console.table(results.map(r => ({
model: r.model,
duration_ms: r.duration,
tokens: r.tokens
})));
return results;
}
```
## Best practices
| Practice | Description |
| ------------------------ | ------------------------------------------------------- |
| Start small | Begin with Haiku, upgrade if quality is insufficient |
| Test on samples | Compare models on representative data before production |
| Monitor quality | Track output quality metrics over time |
| Balance cost and quality | Don't over-engineer simple tasks |
| Consider latency | User-facing features may need faster models |
## Related content
Complete model reference
Submit inference requests
How inference works
Define response schemas
# Error Handling
Source: https://docs.narrative.io/guides/sdk/error-handling
Handle errors gracefully when using the Narrative TypeScript SDK
Robust error handling is essential for building reliable applications with the Narrative SDK. This guide covers common error types and patterns for handling them.
## Prerequisites
* SDK installed and configured (see [SDK Quickstart](/getting-started/sdk-quickstart))
## Error types
### HTTP errors
The SDK throws errors for HTTP response codes indicating failures:
| Status | Name | Description |
| ------ | --------------------- | --------------------------------------------- |
| 400 | Bad Request | Invalid request parameters or malformed query |
| 401 | Unauthorized | Invalid or missing API key |
| 403 | Forbidden | API key lacks required permissions |
| 404 | Not Found | Resource doesn't exist |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
| 502 | Bad Gateway | Upstream service error |
| 503 | Service Unavailable | Service temporarily unavailable |
### Network errors
Connection issues throw standard JavaScript errors:
* Connection timeout
* DNS resolution failure
* Network unreachable
### Validation errors
Query or data validation failures return detailed error messages:
* NQL syntax errors
* Schema validation failures
* Invalid parameter values
## Basic error handling
Wrap SDK calls in try-catch blocks:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
try {
const datasets = await api.getDatasets();
console.log('Datasets:', datasets.records.length);
} catch (error) {
console.error('Error fetching datasets:', error);
}
```
## Handling specific errors
Check the error status for specific handling:
```typescript theme={null}
try {
const dataset = await api.getDataset(12345);
console.log('Dataset:', dataset.name);
} catch (error) {
if (error.status === 401) {
console.error('Authentication failed: Check your API key');
} else if (error.status === 403) {
console.error('Permission denied: API key lacks access to this dataset');
} else if (error.status === 404) {
console.error('Dataset not found');
} else if (error.status === 429) {
console.error('Rate limited: Too many requests');
} else if (error.status >= 500) {
console.error('Server error: Try again later');
} else {
console.error('Unexpected error:', error);
}
}
```
## Rate limiting
Handle rate limits with exponential backoff:
```typescript theme={null}
async function withRetry(
operation: () => Promise,
maxRetries = 5,
baseDelayMs = 1000
): Promise {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
// Only retry on rate limits and server errors
const status = (error as any).status;
if (status !== 429 && status < 500) {
throw error;
}
if (attempt < maxRetries - 1) {
const delay = baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}
throw lastError;
}
// Usage
const datasets = await withRetry(() => api.getDatasets());
```
## Query error handling
NQL queries can fail for various reasons:
```typescript theme={null}
try {
const result = await api.executeNql({
nql: 'SELECT user_id, email, created_at FROM company_data."my_dataset" LIMIT 10',
data_plane_id: null,
});
if (result.state === 'failed') {
console.error('Query failed:', result.failures);
} else {
console.log('Query completed:', result.result.rows, 'rows');
}
} catch (error) {
if (error.status === 400) {
// Parse error details for NQL syntax issues
console.error('Invalid query:', error.message);
} else {
console.error('Query error:', error);
}
}
```
### Validating before execution
Catch syntax errors before running expensive queries:
```typescript theme={null}
async function safeExecuteQuery(nql: string) {
// First validate the query
try {
await api.validateNql({ nql, data_plane_id: null });
} catch (validationError) {
console.error('Query validation failed:', validationError);
throw new Error('Invalid query syntax');
}
// Then execute
return await api.executeNql({ nql, data_plane_id: null });
}
```
## Creating an error wrapper
Build a reusable error handler:
```typescript theme={null}
class NarrativeError extends Error {
status?: number;
details?: unknown;
constructor(message: string, status?: number, details?: unknown) {
super(message);
this.name = 'NarrativeError';
this.status = status;
this.details = details;
}
isRetryable(): boolean {
return this.status === 429 || (this.status ?? 0) >= 500;
}
isAuthError(): boolean {
return this.status === 401 || this.status === 403;
}
isNotFound(): boolean {
return this.status === 404;
}
}
async function wrapApiCall(operation: () => Promise): Promise {
try {
return await operation();
} catch (error) {
const status = (error as any).status;
const message = (error as any).message || 'Unknown error';
throw new NarrativeError(message, status, error);
}
}
// Usage
try {
const dataset = await wrapApiCall(() => api.getDataset(12345));
} catch (error) {
if (error instanceof NarrativeError) {
if (error.isAuthError()) {
console.error('Authentication issue');
} else if (error.isNotFound()) {
console.error('Resource not found');
} else if (error.isRetryable()) {
console.error('Temporary error, retry later');
}
}
}
```
## Logging errors
Implement structured error logging:
```typescript theme={null}
function logError(context: string, error: unknown) {
const errorInfo = {
timestamp: new Date().toISOString(),
context,
message: (error as Error).message,
status: (error as any).status,
stack: (error as Error).stack,
};
console.error(JSON.stringify(errorInfo, null, 2));
// Send to your logging service
// await logService.error(errorInfo);
}
try {
const datasets = await api.getDatasets();
} catch (error) {
logError('getDatasets', error);
throw error;
}
```
## Graceful degradation
Provide fallbacks when services are unavailable:
```typescript theme={null}
async function getDatasetsWithFallback() {
try {
const response = await api.getDatasets();
return { source: 'api', data: response.records };
} catch (error) {
const status = (error as any).status;
if (status >= 500 || status === 429) {
// Return cached data if available
const cachedData = await getCachedDatasets();
if (cachedData) {
console.warn('Using cached datasets due to API error');
return { source: 'cache', data: cachedData };
}
}
throw error;
}
}
```
## Best practices
| Practice | Description |
| -------------------- | -------------------------------------------- |
| Always use try-catch | Wrap all SDK calls in error handlers |
| Check error status | Handle different errors appropriately |
| Implement retries | Use exponential backoff for transient errors |
| Validate inputs | Catch errors early with validation |
| Log errors | Record errors for debugging and monitoring |
| Graceful degradation | Provide fallbacks when possible |
## Troubleshooting
| Issue | Cause | Solution |
| ----------------------- | ---------------------------------- | ----------------------------------- |
| Frequent 401 errors | API key expired or invalid | Generate a new API key |
| Frequent 429 errors | Too many requests | Implement rate limiting and backoff |
| Intermittent 500 errors | Server issues | Implement retry logic |
| Network timeouts | Slow connection or large responses | Increase timeout or paginate |
## Related content
Configure authentication properly
Manage API keys and permissions
Handle async operation errors
Complete SDK documentation
# Executing NQL Queries
Source: https://docs.narrative.io/guides/sdk/executing-queries
Run, validate, and compile NQL queries using the TypeScript SDK
The Narrative SDK provides methods to execute, validate, and compile NQL queries programmatically. This guide covers common query patterns and best practices.
For NQL syntax and language features, see the [NQL Reference](/nql/general/syntax).
## Prerequisites
* SDK installed and configured (see [SDK Quickstart](/getting-started/sdk-quickstart))
* Familiarity with [NQL syntax](/nql/general/syntax)
* An API key with query permissions
## Running a query
### Basic execution
Use `executeNql()` to run a query and retrieve results:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
const result = await api.executeNql({
nql: `
SELECT _nio_id, _nio_updated_at
FROM company_data."my_dataset"
LIMIT 100
`,
data_plane_id: null,
});
console.log('Query state:', result.state);
console.log('Rows returned:', result.result.rows);
console.log('Cost:', result.result.cost);
```
### Query input options
The `executeNql()` method accepts the following options:
| Option | Type | Required | Description |
| ------------------- | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `nql` | `string` | Yes | The NQL query to execute |
| `data_plane_id` | `string \| null` | Yes | Target data plane ID, or `null` for default |
| `execution_cluster` | `object` | No | Cluster configuration (`shared` or `dedicated`). See [Compute Pools](/concepts/primitives/compute-pools) |
| `create_as_view` | `boolean` | No | Create the result as a [view dataset](/concepts/nql/materialized-views#view-datasets) instead of a materialized view |
```typescript theme={null}
const result = await api.executeNql({
nql: 'SELECT user_id, email, created_at FROM company_data."my_dataset" LIMIT 10',
data_plane_id: null,
execution_cluster: { type: 'dedicated' },
});
```
### Creating a view dataset
Set `create_as_view` to `true` to create a [view dataset](/concepts/nql/materialized-views#view-datasets) instead of a materialized view. A view dataset stores only the NQL query definition—not the query results. The NQL is inlined and re-evaluated at query time whenever other queries reference the view dataset.
```typescript theme={null}
const result = await api.executeNql({
nql: `
SELECT user_id, email, event_type
FROM company_data."customer_events"
WHERE event_type = 'purchase'
`,
data_plane_id: null,
create_as_view: true,
});
```
View datasets have several restrictions compared to materialized views. You cannot create access rules, connections, or use features like `MERGE ON`, `PARTITIONED_BY`, or chunking strategies with view datasets. See [view dataset limitations](/concepts/nql/materialized-views#view-dataset-limitations) for the full list.
### Understanding the result
The `NqlResult` object contains execution metadata:
```typescript theme={null}
interface NqlResult {
id: string; // Job ID
state: string; // 'completed', 'failed', etc.
created_at: string; // When the query was submitted
completed_at: string; // When execution finished
result: {
type: string;
rows: number; // Number of rows returned
cost: number; // Query cost
nql_type: string;
};
input: {
nql: string; // The original query
compiled_select?: string;
};
failures: unknown[]; // Any execution errors
}
```
## Validating a query
Use `validateNql()` to check query syntax without execution:
```typescript theme={null}
const validation = await api.validateNql({
nql: `
SELECT _nio_id
FROM company_data."my_dataset"
LIMIT 10
`,
data_plane_id: null,
});
console.log('Query is valid');
```
If the query has syntax errors, the method throws an error with details about the issue.
## Compiling a query
Use `compileNql()` to see the transpiled SQL without executing:
```typescript theme={null}
const compiled = await api.compileNql({
nql: `
SELECT _nio_id, _nio_updated_at
FROM company_data."my_dataset"
WHERE _nio_updated_at > CURRENT_DATE - INTERVAL '7' DAY
LIMIT 100
`,
data_plane_id: null,
});
console.log('Transpiled SQL:', compiled.sql);
if (compiled.mappingErrors) {
console.log('Mapping errors:', compiled.mappingErrors);
}
```
This is useful for:
* Debugging query issues
* Understanding how NQL maps to the underlying SQL dialect
* Verifying Rosetta Stone mappings
## Parsing a query
Use `parseNql()` to get the abstract syntax tree (AST):
```typescript theme={null}
const ast = await api.parseNql({
nql: 'SELECT _nio_id FROM company_data."my_dataset" LIMIT 10',
data_plane_id: null,
});
console.log('AST:', JSON.stringify(ast, null, 2));
```
## Retrieving a previous query
If you have a job ID from a previous query, retrieve its results:
```typescript theme={null}
const result = await api.getNqlByJobId('job-id-here');
console.log('Query state:', result.state);
console.log('Rows returned:', result.result.rows);
```
## Query patterns
### Filtering data
```typescript theme={null}
const result = await api.executeNql({
nql: `
SELECT sha256_hashed_email, event_type, event_timestamp
FROM company_data."customer_events"
WHERE event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
AND event_type = 'purchase'
LIMIT 1000
`,
data_plane_id: null,
});
```
### Aggregations
```typescript theme={null}
const result = await api.executeNql({
nql: `
SELECT
event_type,
COUNT(1) as event_count
FROM company_data."customer_events"
GROUP BY event_type
LIMIT 100
`,
data_plane_id: null,
});
```
### Using Rosetta Stone attributes
Query normalized data across datasets:
```typescript theme={null}
const result = await api.executeNql({
nql: `
SELECT
hl7_gender,
unique_id,
timestamp_utc
FROM narrative.rosetta_stone
LIMIT 100
`,
data_plane_id: null,
});
```
## Error handling
Wrap query execution in try-catch to handle errors:
```typescript theme={null}
try {
const result = await api.executeNql({
nql: 'SELECT user_id, email, created_at FROM company_data."my_dataset" LIMIT 10',
data_plane_id: null,
});
if (result.state === 'completed') {
console.log('Success:', result.result.rows, 'rows');
} else {
console.log('Query state:', result.state);
}
} catch (error) {
if (error.status === 400) {
console.error('Invalid query:', error.message);
} else if (error.status === 403) {
console.error('Permission denied');
} else {
console.error('Query failed:', error);
}
}
```
## Best practices
| Practice | Description |
| ------------------------- | ------------------------------------------ |
| Always use LIMIT | Prevent unbounded result sets |
| Validate before executing | Use `compileNql()` to catch errors early |
| Handle errors gracefully | Wrap calls in try-catch |
| Use appropriate timeouts | Long-running queries may need monitoring |
| Filter at the source | Apply WHERE clauses to reduce data scanned |
## Related content
Complete NQL language reference
Understanding NQL's approach
Tips for efficient queries
Handle SDK errors gracefully
# SDK Guides
Source: https://docs.narrative.io/guides/sdk/index
Step-by-step instructions for common SDK tasks
These guides help you accomplish specific tasks using the Narrative TypeScript SDK. They assume basic familiarity with the SDK and focus on practical implementation.
New to the SDK? Start with the [SDK Quickstart](/getting-started/sdk-quickstart) tutorial to install and configure the SDK.
## Getting started
Install the SDK and run your first query
Configure API key authentication
## Data operations
Run, validate, and compile NQL queries
Create, read, update, and delete datasets
Upload files to datasets
## Model Inference
Submit inference requests to hosted LLMs
Define JSON Schema for typed responses
Select the right model for your task
## Monitoring and reliability
Monitor asynchronous job status
Handle errors gracefully
## Reference
TypeScript SDK overview and architecture
Complete configuration options
All available methods and parameters
Full REST API documentation
## Recipes
Build a complete automation pipeline
# Managing Datasets
Source: https://docs.narrative.io/guides/sdk/managing-datasets
Create, read, update, and delete datasets using the TypeScript SDK
The Narrative SDK provides comprehensive methods for managing datasets programmatically. This guide covers common dataset operations and patterns.
## Prerequisites
* SDK installed and configured (see [SDK Quickstart](/getting-started/sdk-quickstart))
* An API key with dataset permissions
## Listing datasets
Retrieve all datasets accessible to your account:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
const response = await api.getDatasets();
console.log(`Found ${response.records.length} datasets:`);
for (const dataset of response.records) {
console.log(`- ${dataset.name} (ID: ${dataset.id}, Status: ${dataset.status})`);
}
```
## Getting dataset details
Retrieve details for a specific dataset by ID:
```typescript theme={null}
const dataset = await api.getDataset(12345);
console.log('Name:', dataset.name);
console.log('Description:', dataset.description);
console.log('Status:', dataset.status);
console.log('Created:', dataset.created_at);
console.log('Updated:', dataset.updated_at);
console.log('Tags:', dataset.tags);
```
### Dataset properties
| Property | Type | Description |
| -------------- | ---------- | ---------------------------------- |
| `id` | `number` | Unique dataset identifier |
| `name` | `string` | Dataset name |
| `display_name` | `string` | Human-readable display name |
| `description` | `string` | Dataset description |
| `status` | `string` | `active`, `archived`, or `pending` |
| `schema` | `object` | Dataset schema definition |
| `tags` | `string[]` | Tags for organization |
| `created_at` | `string` | Creation timestamp |
| `updated_at` | `string` | Last update timestamp |
| `write_mode` | `string` | `append` or `overwrite` |
## Creating a dataset
Create a new dataset with schema definition:
```typescript theme={null}
const newDataset = await api.createDataset({
name: 'customer_events',
display_name: 'Customer Events',
description: 'Customer interaction events from our platform',
schema: {
properties: {
customer_id: { type: 'string' },
event_type: { type: 'string' },
event_timestamp: { type: 'timestamptz' },
event_value: { type: 'double' },
},
},
tags: ['customers', 'events'],
});
console.log('Created dataset:', newDataset.id);
```
### Schema property types
| Type | Description |
| ------------- | ----------------------- |
| `string` | Text values |
| `long` | Integer numbers |
| `double` | Floating-point numbers |
| `boolean` | True/false values |
| `timestamptz` | Timestamp with timezone |
| `object` | Nested object |
| `array` | Array of values |
## Updating a dataset
Update dataset metadata:
```typescript theme={null}
const updated = await api.updateDataset({
dataset_id: 12345,
display_name: 'Customer Events (Updated)',
description: 'Updated description for customer events',
});
console.log('Updated:', updated.name);
```
## Managing tags
Add tags to a dataset:
```typescript theme={null}
const dataset = await api.addTags(12345, ['production', 'verified']);
console.log('Tags:', dataset.tags);
```
Remove tags from a dataset:
```typescript theme={null}
const dataset = await api.removeTags(12345, ['deprecated']);
console.log('Tags:', dataset.tags);
```
## Getting dataset statistics
Retrieve summary statistics:
```typescript theme={null}
const stats = await api.getStatistics(12345);
console.log('Statistics:', stats.records);
```
Get column-level statistics:
```typescript theme={null}
const columnStats = await api.getColumnStats(12345);
console.log('Column statistics:', columnStats);
```
## Dataset summary
Get a summary with sample data:
```typescript theme={null}
const summary = await api.getDatasetSummary(
12345, // dataset ID
false, // allRecords - set to true for all records
100, // perPage
0 // offset
);
console.log('Summary:', summary);
```
## Sampling data
Sampling provides a preview of dataset records without downloading the full dataset. When you run an [interactive query](/reference/glossary#interactive-query), results are stored as a dataset in your data plane. To view these results in the UI or API, a sample job retrieves up to 1,000 rows and stores them in the control plane for quick access.
Samples are one of the few cases where actual data leaves your data plane. For governance implications, see [Sample Data](/concepts/architecture/sample-data).
Samples are useful for:
* **Viewing query results** — See what your query returned without downloading everything
* **Validating data** — Check data quality before sharing with partners
* **Quick inspection** — Preview dataset contents during development
Request a sample of records:
```typescript theme={null}
// Request a sample (async operation)
const sampleRequest = await api.requestDatasetSample(12345);
console.log('Sample job ID:', sampleRequest.job_id);
// After the job completes, retrieve the sample
const sample = await api.getDatasetSample(12345, 1000);
console.log(`Sample contains ${sample.records.length} records`);
```
Delete a sample:
```typescript theme={null}
await api.deleteDatasetSample(12345);
console.log('Sample deleted');
```
## Working with files
List files in a dataset:
```typescript theme={null}
const files = await api.getDatasetFiles(
12345, // dataset ID
100, // size (number of snapshots)
0, // offset
'2024-01-01', // afterTimestamp (optional)
);
for (const snapshot of files.snapshots) {
console.log(`Snapshot ${snapshot.id}:`, snapshot.files.length, 'files');
}
```
Get a download URL for a specific file:
```typescript theme={null}
const download = await api.getDatasetFileDownloadUrl(
12345, // dataset ID
67890, // snapshot ID
'file-id-here' // file ID
);
console.log('Download URL:', download.download_url);
```
## Materialized views
Refresh a materialized view:
```typescript theme={null}
const job = await api.refreshMaterializedView(12345);
console.log('Refresh job ID:', job.id);
```
Create a refresh schedule:
```typescript theme={null}
await api.createDatasetRefreshSchedule(12345, {
cron_expression: '0 0 * * *', // Daily at midnight
enabled: true,
});
```
Update a refresh schedule:
```typescript theme={null}
await api.updateDatasetRefreshSchedule(12345, {
cron_expression: '0 */6 * * *', // Every 6 hours
enabled: true,
});
```
Delete a refresh schedule:
```typescript theme={null}
await api.deleteDatasetRefreshSchedule(12345);
```
## Retention policy
Update the retention policy:
```typescript theme={null}
const updated = await api.updateRetentionPolicy(12345, {
type: 'time_based',
retention_days: 365,
});
console.log('Retention policy updated');
```
## Activating a dataset
Activate a pending dataset:
```typescript theme={null}
await api.activateDataset(12345);
console.log('Dataset activated');
```
## Deleting a dataset
Deleting a dataset is permanent and cannot be undone. Ensure you have backups if needed.
```typescript theme={null}
await api.deleteDataset(12345);
console.log('Dataset deleted');
```
## Error handling
```typescript theme={null}
try {
const dataset = await api.getDataset(12345);
console.log('Dataset:', dataset.name);
} catch (error) {
if (error.status === 404) {
console.error('Dataset not found');
} else if (error.status === 403) {
console.error('Permission denied');
} else {
console.error('Error:', error);
}
}
```
## Related content
Upload files to datasets
Query dataset data
Complete API documentation
# Running Model Inference
Source: https://docs.narrative.io/guides/sdk/running-model-inference
Submit inference requests to hosted LLMs using the TypeScript SDK
Model Inference enables you to run LLM operations within your [data plane](/concepts/primitives/data-planes), keeping data secure while leveraging AI capabilities. This guide covers how to submit inference requests and handle results.
## Prerequisites
* SDK installed and configured (see [Authentication](/guides/sdk/authentication))
* A data plane ID where inference will run
* An API key with appropriate permissions
## Basic inference request
Submit a simple inference request with a system prompt and user message:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages: [
{
role: 'system',
content: [{ type: 'text', text: 'You are a helpful data assistant.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'What are common use cases for customer data collaboration?' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
use_cases: {
type: 'array',
items: { type: 'string' }
}
},
required: ['use_cases']
}
}
});
console.log('Inference job created:', job.id);
```
## Tracking job completion
Inference jobs are asynchronous. Poll for completion using the job ID:
```typescript theme={null}
async function waitForInference(jobId: string, maxWaitMs = 60000) {
const startTime = Date.now();
const pollInterval = 2000;
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
if (job.state === 'completed') {
return { success: true, result: job.result };
}
if (job.state === 'failed') {
return { success: false, error: job.failures };
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Inference job ${jobId} timed out`);
}
// Usage
const result = await waitForInference(job.id);
if (result.success) {
console.log('Use cases:', result.result.structured_output.use_cases);
console.log('Tokens used:', result.result.usage.total_tokens);
}
```
For more polling patterns, see [Tracking Job Status](/guides/sdk/tracking-jobs).
## Configuring inference parameters
Fine-tune the model's behavior with configuration options:
```typescript theme={null}
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Generate a creative tagline for a data platform.' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
tagline: { type: 'string' },
tone: { type: 'string', enum: ['professional', 'playful', 'bold'] }
},
required: ['tagline', 'tone']
},
max_tokens: 200,
temperature: 0.8, // Higher for more creative responses
top_p: 0.9
}
});
```
| Parameter | Effect | Typical Values |
| ---------------- | ----------------------------------------------- | ----------------------------------------- |
| `temperature` | Controls randomness. Lower = more deterministic | 0.0-0.3 for factual, 0.7-1.0 for creative |
| `max_tokens` | Limits response length | 100-4000 depending on task |
| `top_p` | Nucleus sampling threshold | 0.9-1.0 for most cases |
| `stop_sequences` | Strings that end generation | `["\n\n", "END"]` |
## Multi-turn conversations
Include previous messages for context-aware responses:
```typescript theme={null}
const messages = [
{
role: 'system',
content: [{ type: 'text', text: 'You are a data classification assistant.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'I have a dataset with email, purchase_date, and amount columns.' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'This appears to be transactional customer data.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'What privacy considerations should I be aware of?' }]
}
];
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages,
inference_config: {
output_format_schema: {
type: 'object',
properties: {
considerations: {
type: 'array',
items: {
type: 'object',
properties: {
issue: { type: 'string' },
recommendation: { type: 'string' }
},
required: ['issue', 'recommendation']
}
}
},
required: ['considerations']
}
}
});
```
## Tool-use messages (agent loops)
In agent-style flows, the model emits `tool_use` content blocks requesting a tool
call and you reply with a matching `tool_result` block. Both share the same
`tool_use_id` so the model can correlate the request and response across turns.
```typescript theme={null}
const messages = [
{
role: 'system',
content: [{ type: 'text', text: 'You are a dataset discovery agent.' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Find datasets containing email addresses.' }]
},
{
role: 'assistant',
content: [
{
type: 'tool_use',
tool_use_id: 'tooluse_DWXPKZ50JDGib5GmShyUgJ',
name: 'n-narrative_datasets_describe',
arguments: { search: 'email' }
}
]
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tooluse_DWXPKZ50JDGib5GmShyUgJ',
content: [{ type: 'text', text: '[{"id": 123, "name": "company_emails"}]' }],
is_error: false
}
]
}
];
```
The legacy `{ role, text: string }` request shape is still accepted for backwards
compatibility — the API auto-canonicalizes it into a single `text` content block.
Responses always emit the content-block shape. New integrations should use content
blocks directly. See [Model Inference API reference](/reference/sdks/typescript/model-inference-api#inferencemessage).
## Choosing a model
Select the model based on your task requirements:
| Model | Best For |
| ----------------------------- | ---------------------------------------------------------- |
| `anthropic.claude-haiku-4.5` | Fast, simple tasks (classification, extraction) |
| `anthropic.claude-sonnet-4.5` | Balanced tasks (summarization, analysis) |
| `anthropic.claude-sonnet-4.6` | Latest balanced model with improved reasoning |
| `anthropic.claude-opus-4.5` | Complex reasoning (multi-step analysis, nuanced decisions) |
| `anthropic.claude-opus-4.6` | Latest most capable model for the most demanding tasks |
| `openai.gpt-4.1` | Advanced reasoning tasks |
| `openai.o4-mini` | Fast, cost-effective tasks |
For detailed guidance, see [Choosing the Right Model](/guides/sdk/choosing-inference-models).
## Typing responses
Use TypeScript generics to type the structured output:
```typescript theme={null}
interface SentimentResult {
sentiment: 'positive' | 'negative' | 'neutral';
confidence: number;
key_phrases: string[];
}
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-haiku-4.5',
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Analyze the sentiment: "This product exceeded my expectations!"' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
confidence: { type: 'number', minimum: 0, maximum: 1 },
key_phrases: { type: 'array', items: { type: 'string' } }
},
required: ['sentiment', 'confidence', 'key_phrases']
}
}
});
// After polling for completion
const result = completedJob.result as ModelInferenceRunResult;
// Fully typed access
const sentiment: string = result.structured_output.sentiment;
const confidence: number = result.structured_output.confidence;
```
## Error handling
Handle common inference errors:
```typescript theme={null}
try {
const job = await api.runModelInference(request);
const result = await waitForInference(job.id);
if (!result.success) {
// Job failed during execution
console.error('Inference failed:', result.error);
return;
}
// Process successful result
console.log('Output:', result.result.structured_output);
} catch (error) {
if (error.status === 400) {
console.error('Invalid request:', error.message);
// Check schema, messages, or model
} else if (error.status === 403) {
console.error('Access denied to data plane');
} else if (error.status === 404) {
console.error('Data plane not found');
} else {
console.error('Unexpected error:', error);
}
}
```
## Best practices
| Practice | Description |
| -------------------------- | --------------------------------------------------------- |
| Use specific schemas | Define precise JSON Schema to get consistent outputs |
| Choose appropriate models | Use smaller models for simple tasks to save cost and time |
| Set reasonable max\_tokens | Avoid unnecessarily large values that increase latency |
| Include system prompts | Guide model behavior with clear instructions |
| Handle failures gracefully | Implement retries for transient errors |
## Related content
Deep dive into JSON Schema for inference
Select the right model for your task
Complete API reference
Monitor job status and handle completion
# Using Structured Output
Source: https://docs.narrative.io/guides/sdk/structured-inference-output
Define JSON Schema to get predictable, typed responses from model inference
Structured output ensures that model inference returns data in a predictable format you can parse programmatically. By providing a JSON Schema, you constrain the model to return valid JSON matching your specification.
## Prerequisites
* Familiarity with [Running Model Inference](/guides/sdk/running-model-inference)
* Basic understanding of JSON Schema
## Why structured output matters
Without structured output, LLM responses are free-form text that requires parsing and error handling. With structured output:
| Without Schema | With Schema |
| ----------------------- | -------------------------------- |
| Free-form text response | Guaranteed JSON structure |
| Manual parsing required | Direct property access |
| Inconsistent formats | Consistent field names and types |
| Runtime type errors | TypeScript type safety |
## Defining a schema
The `output_format_schema` field accepts a [JSON Schema](https://json-schema.org/) object that defines your expected response structure:
```typescript theme={null}
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Classify this text: "Great product, fast shipping!"' }]
}
],
inference_config: {
output_format_schema: {
type: 'object',
properties: {
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral']
},
confidence: {
type: 'number',
minimum: 0,
maximum: 1
}
},
required: ['sentiment', 'confidence']
}
}
});
```
The model will return:
```json theme={null}
{
"sentiment": "positive",
"confidence": 0.95
}
```
## Common schema patterns
### Simple object with required fields
```typescript theme={null}
const schema = {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
priority: { type: 'integer', minimum: 1, maximum: 5 }
},
required: ['title', 'description']
};
```
### Arrays of items
```typescript theme={null}
const schema = {
type: 'object',
properties: {
categories: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 5
}
},
required: ['categories']
};
```
### Nested objects
```typescript theme={null}
const schema = {
type: 'object',
properties: {
analysis: {
type: 'object',
properties: {
summary: { type: 'string' },
key_points: {
type: 'array',
items: { type: 'string' }
}
},
required: ['summary', 'key_points']
},
metadata: {
type: 'object',
properties: {
word_count: { type: 'integer' },
language: { type: 'string' }
}
}
},
required: ['analysis']
};
```
### Enum constraints
```typescript theme={null}
const schema = {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['retail', 'finance', 'healthcare', 'technology', 'other']
},
data_type: {
type: 'string',
enum: ['pii', 'aggregated', 'anonymous']
}
},
required: ['category', 'data_type']
};
```
### Array of typed objects
```typescript theme={null}
const schema = {
type: 'object',
properties: {
entities: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['person', 'organization', 'location'] },
confidence: { type: 'number' }
},
required: ['name', 'type']
}
}
},
required: ['entities']
};
```
## TypeScript integration
Define TypeScript interfaces that match your schema for type-safe access:
```typescript theme={null}
// Define the interface matching your schema
interface ClassificationResult {
category: 'retail' | 'finance' | 'healthcare' | 'technology' | 'other';
confidence: number;
reasoning: string;
tags: string[];
}
// Define the schema (must match the interface)
const schema = {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['retail', 'finance', 'healthcare', 'technology', 'other']
},
confidence: { type: 'number', minimum: 0, maximum: 1 },
reasoning: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['category', 'confidence', 'reasoning', 'tags']
};
// Submit the request
const job = await api.runModelInference({
data_plane_id: 'dp_your_data_plane_id',
model: 'anthropic.claude-sonnet-4.5',
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Classify this dataset...' }] }
],
inference_config: { output_format_schema: schema }
});
// After polling for completion, cast the result
import { ModelInferenceRunResult } from '@narrative.io/data-collaboration-sdk-ts';
const result = completedJob.result as ModelInferenceRunResult;
// Now you have full type safety
const category: string = result.structured_output.category; // 'retail' | 'finance' | ...
const confidence: number = result.structured_output.confidence;
const tags: string[] = result.structured_output.tags;
```
## Handling optional fields
Use `required` array to specify which fields must be present:
```typescript theme={null}
const schema = {
type: 'object',
properties: {
title: { type: 'string' }, // Required
subtitle: { type: 'string' }, // Optional
author: { type: 'string' }, // Optional
published_date: { type: 'string' } // Required
},
required: ['title', 'published_date'] // Only these are required
};
// TypeScript interface
interface ArticleInfo {
title: string;
subtitle?: string;
author?: string;
published_date: string;
}
```
## Validating responses
While the model is constrained to the schema, you may want additional runtime validation:
```typescript theme={null}
function validateResult(
result: ModelInferenceRunResult,
validate: (output: T) => boolean
): T | null {
const output = result.structured_output;
if (!validate(output)) {
console.error('Validation failed for output:', output);
return null;
}
return output;
}
// Usage
const validated = validateResult(result, (output: ClassificationResult) => {
return output.confidence >= 0.5 && output.tags.length > 0;
});
if (validated) {
console.log('Valid classification:', validated.category);
}
```
## Best practices
| Practice | Description |
| ------------------------------- | --------------------------------------- |
| Keep schemas focused | Define only the fields you need |
| Use enums for known values | Constrain strings to valid options |
| Set numeric bounds | Use `minimum`/`maximum` for numbers |
| Make fields required explicitly | Don't rely on defaults |
| Match TypeScript interfaces | Keep schema and types in sync |
| Add descriptions | Help the model understand field purpose |
### Adding descriptions for clarity
```typescript theme={null}
const schema = {
type: 'object',
properties: {
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral'],
description: 'Overall sentiment of the text'
},
confidence: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'Confidence score from 0 (uncertain) to 1 (certain)'
},
key_phrases: {
type: 'array',
items: { type: 'string' },
description: 'Most important phrases that influenced the sentiment'
}
},
required: ['sentiment', 'confidence', 'key_phrases']
};
```
## Troubleshooting
| Issue | Cause | Solution |
| ----------------------- | --------------------------------- | --------------------------------------- |
| Missing required fields | Schema mismatch or unclear prompt | Verify required array, improve prompt |
| Wrong types | Schema not enforced | Check property types match expectations |
| Empty arrays | Model unsure what to include | Add `minItems` or clearer prompt |
| Enum violations | Value not in enum list | Verify enum covers all possibilities |
## Related content
Supported JSON Schema features
Complete inference guide
API reference with all types
Why structured output matters
# Tracking Job Status
Source: https://docs.narrative.io/guides/sdk/tracking-jobs
Monitor asynchronous jobs and handle completion events
Many Narrative operations are asynchronous, returning a job ID that you can use to track progress. This guide covers how to monitor jobs and handle their completion.
For a detailed list of job types, see [Job Types Reference](/reference/architecture/job-types).
Need real-time notifications instead of polling? [Webhook subscriptions](/guides/webhooks/subscribing-to-notifications) push job state changes to your endpoint the moment they happen.
## Prerequisites
* SDK installed and configured (see [SDK Quickstart](/getting-started/sdk-quickstart))
* An API key with appropriate permissions
## Understanding jobs
Operations like file uploads, dataset refreshes, and certain queries run asynchronously. When you start one of these operations, you receive a job ID that you can poll for status updates.
### Job states
| State | Description |
| ---------------------- | ------------------------------------------------ |
| `pending` | Job is queued and waiting to start |
| `running` | Job is currently executing |
| `completed` | Job finished successfully |
| `pending_cancellation` | Job is marked for cancellation but still running |
| `cancelled` | Job was cancelled before completion |
| `failed` | Job encountered an error |
## Getting job status
Retrieve the status of a specific job:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
const job = await api.getJob('job-id-here');
console.log('Job ID:', job.id);
console.log('State:', job.state);
console.log('Created:', job.created_at);
console.log('Updated:', job.updated_at);
```
## Listing jobs
List all jobs, optionally filtered by state, type, or data plane:
```typescript theme={null}
// List all jobs
const allJobs = await api.getJobs();
console.log(`Total jobs: ${allJobs.total_records}`);
// List with pagination
const paginatedJobs = await api.getJobs({
page: 1,
per_page: 50,
});
// Filter and process
for (const job of paginatedJobs.records) {
console.log(`${job.id}: ${job.state} - ${job.type}`);
}
```
The list response includes pagination metadata alongside `records`:
| Field | Description |
| --------------- | ------------------------------------------------- |
| `records` | The jobs on this page |
| `current_page` | The 1-indexed page number returned |
| `total_pages` | Total number of pages available for this query |
| `total_records` | Total number of jobs that match the query |
| `prev_page` | Previous page number, or `null` on the first page |
| `next_page` | Next page number, or `null` on the last page |
`per_page` is capped at `500`. Requests with a larger value are rejected with a `400`.
### Filtering by state, type, and tag
The `state`, `type`, and `tag` query parameters are repeatable — pass each one multiple times to match any of several values. This is useful for building active-vs-finished views or scoping to a job family.
```typescript theme={null}
// Only jobs that are currently active
const active = await api.getJobs({
state: ['pending', 'running', 'pending_cancellation'],
});
// All jobs for a given dataset on a specific data plane
const datasetJobs = await api.getJobs({
dataset_id: 'dataset-id',
data_plane_id: 'dp_your_data_plane_id',
});
```
Job responses always include a `data_plane_id`. Jobs that target the default Narrative-managed data plane report its id explicitly rather than returning `null`.
## Polling for completion
A common pattern is to poll until a job completes:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
async function waitForJob(jobId: string, maxWaitMs = 300000) {
const startTime = Date.now();
const pollInterval = 5000; // 5 seconds
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
console.log(`Job ${jobId}: ${job.state}`);
if (job.state === 'completed') {
return { success: true, job };
}
if (job.state === 'failed') {
return { success: false, job, error: job.failures };
}
if (job.state === 'cancelled') {
return { success: false, job, error: 'Job was cancelled' };
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Job ${jobId} did not complete within ${maxWaitMs}ms`);
}
// Usage
const result = await waitForJob('job-id-here');
if (result.success) {
console.log('Job completed successfully');
} else {
console.error('Job failed:', result.error);
}
```
## Exponential backoff
For long-running jobs, use exponential backoff to reduce API calls:
```typescript theme={null}
async function waitForJobWithBackoff(jobId: string, maxWaitMs = 600000) {
const startTime = Date.now();
let pollInterval = 1000; // Start with 1 second
const maxInterval = 30000; // Cap at 30 seconds
while (Date.now() - startTime < maxWaitMs) {
const job = await api.getJob(jobId);
console.log(`Job ${jobId}: ${job.state} (next check in ${pollInterval}ms)`);
if (job.state === 'completed') {
return { success: true, job };
}
if (job.state === 'failed' || job.state === 'cancelled') {
return { success: false, job };
}
// Wait with exponential backoff
await new Promise(resolve => setTimeout(resolve, pollInterval));
pollInterval = Math.min(pollInterval * 1.5, maxInterval);
}
throw new Error(`Job ${jobId} timed out`);
}
```
## Handling job results
Different job types return different results:
### NQL query jobs
```typescript theme={null}
const queryResult = await api.getNqlByJobId(jobId);
if (queryResult.state === 'completed') {
console.log('Rows returned:', queryResult.result.rows);
console.log('Query cost:', queryResult.result.cost);
}
```
### Dataset refresh jobs
```typescript theme={null}
const job = await api.getJob(refreshJobId);
if (job.state === 'completed') {
console.log('Materialized view refreshed');
// Fetch updated dataset statistics
const stats = await api.getStatistics(datasetId);
}
```
### Model inference jobs
```typescript theme={null}
import type { ModelInferenceRunResult } from '@narrative.io/data-collaboration-sdk-ts';
const job = await api.getJob(inferenceJobId);
if (job.state === 'completed' && job.result) {
const result = job.result as ModelInferenceRunResult;
console.log('Tokens used:', result.usage.total_tokens);
console.log('Output:', result.structured_output);
}
```
For inference-specific patterns, see [Running Model Inference](/guides/sdk/running-model-inference).
## Error handling
Handle job failures gracefully:
```typescript theme={null}
async function executeWithRetry(operation: () => Promise, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const jobId = await operation();
const result = await waitForJob(jobId);
if (result.success) {
return result.job;
}
console.error(`Attempt ${attempt} failed:`, result.error);
if (attempt < maxRetries) {
const backoff = Math.pow(2, attempt) * 1000;
console.log(`Retrying in ${backoff}ms...`);
await new Promise(resolve => setTimeout(resolve, backoff));
}
} catch (error) {
console.error(`Attempt ${attempt} error:`, error);
if (attempt === maxRetries) {
throw error;
}
}
}
throw new Error('All retry attempts failed');
}
// Usage
const job = await executeWithRetry(async () => {
const result = await api.refreshMaterializedView(datasetId);
return result.id;
});
```
## Batch job monitoring
Monitor multiple jobs concurrently:
```typescript theme={null}
async function waitForAllJobs(jobIds: string[]) {
const results = await Promise.all(
jobIds.map(async (jobId) => {
try {
const result = await waitForJob(jobId);
return { jobId, ...result };
} catch (error) {
return { jobId, success: false, error };
}
})
);
const succeeded = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
console.log(`Completed: ${succeeded.length}/${results.length}`);
console.log(`Failed: ${failed.length}/${results.length}`);
return { results, succeeded, failed };
}
// Usage
const jobIds = ['job-1', 'job-2', 'job-3'];
const { succeeded, failed } = await waitForAllJobs(jobIds);
```
## Best practices
| Practice | Description |
| ----------------------- | ----------------------------------------------------------------- |
| Use exponential backoff | Reduce API calls for long-running jobs |
| Set reasonable timeouts | Prevent indefinite waiting |
| Log progress | Track job state changes for debugging |
| Handle all states | Account for all terminal states: completed, failed, and cancelled |
| Implement retries | Retry failed operations when appropriate |
## Troubleshooting
| Issue | Cause | Solution |
| --------------------- | ------------------------------------- | ---------------------------------------- |
| Job stays pending | Queue backlog or resource constraints | Wait longer or contact support |
| Job fails immediately | Invalid input or permissions | Check error details and fix input |
| Timeout waiting | Job taking longer than expected | Increase timeout or check job complexity |
| 404 on job ID | Job expired or invalid ID | Verify job ID and check retention |
## Related content
Reference for all job types
Handle SDK errors gracefully
Upload files and track ingestion
Dataset operations that create jobs
# Uploading Data
Source: https://docs.narrative.io/guides/sdk/uploading-data
Upload files to Narrative datasets using the TypeScript SDK
The Narrative SDK provides methods to upload data files to your datasets programmatically. This guide covers the upload workflow and best practices.
## Prerequisites
* SDK installed and configured (see [SDK Quickstart](/getting-started/sdk-quickstart))
* A target dataset (see [Managing Datasets](/guides/sdk/managing-datasets))
* An API key with write permissions
* Data file in a supported format (CSV, JSON, Parquet)
## Supported formats
| Format | Extension | Notes |
| ------- | ---------- | ---------------------------------------------- |
| CSV | `.csv` | Comma-separated values with headers |
| JSON | `.json` | JSON Lines (one object per line) or JSON array |
| Parquet | `.parquet` | Apache Parquet columnar format |
## Upload workflow
The upload process involves these steps:
1. **Get an upload URL** - Request a pre-signed URL for uploading
2. **Upload the file** - Send the file to the pre-signed URL
3. **Confirm the upload** - Notify Narrative that the upload is complete
4. **Monitor ingestion** - Track the ingestion job status
## Basic upload
Here's a complete example using Node.js:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
import fs from 'fs';
import path from 'path';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
async function uploadFile(datasetId: number, filePath: string) {
const fileName = path.basename(filePath);
const fileContent = fs.readFileSync(filePath);
// Step 1: Get upload URL
const uploadUrl = await api.getUploadUrl(datasetId, fileName);
console.log('Got upload URL');
// Step 2: Upload file to S3
await api.uploadFileToS3(uploadUrl.url, fileContent, uploadUrl.headers);
console.log('File uploaded to S3');
// Step 3: Confirm upload
await api.confirmUpload(datasetId, uploadUrl.upload_id);
console.log('Upload confirmed');
return uploadUrl.upload_id;
}
// Usage
const uploadId = await uploadFile(12345, './data/customer_events.csv');
console.log('Upload ID:', uploadId);
```
## Ingesting from a URL
If your data is already accessible via URL, you can ingest directly:
```typescript theme={null}
await api.ingestDatasetFile(
{ source_file: 'https://example.com/data/events.csv' },
12345 // dataset ID
);
console.log('Ingestion started');
```
## Monitoring upload status
After confirming an upload, monitor the ingestion job:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
async function waitForIngestion(jobId: string) {
while (true) {
const job = await api.getJob(jobId);
console.log(`Status: ${job.state}`);
if (job.state === 'completed') {
console.log('Ingestion completed successfully');
return job;
}
if (job.state === 'failed') {
throw new Error(`Ingestion failed: ${JSON.stringify(job.failures)}`);
}
// Wait before checking again
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
```
## Uploading multiple files
Upload multiple files to the same dataset:
```typescript theme={null}
import { NarrativeApi } from '@narrative.io/data-collaboration-sdk-ts';
import fs from 'fs';
import path from 'path';
const api = new NarrativeApi({
apiKey: process.env.NARRATIVE_API_KEY,
});
async function uploadMultipleFiles(datasetId: number, filePaths: string[]) {
const results = [];
for (const filePath of filePaths) {
const fileName = path.basename(filePath);
const fileContent = fs.readFileSync(filePath);
console.log(`Uploading ${fileName}...`);
const uploadUrl = await api.getUploadUrl(datasetId, fileName);
await api.uploadFileToS3(uploadUrl.url, fileContent, uploadUrl.headers);
await api.confirmUpload(datasetId, uploadUrl.upload_id);
results.push({
file: fileName,
uploadId: uploadUrl.upload_id,
});
console.log(`Completed ${fileName}`);
}
return results;
}
// Usage
const files = [
'./data/events_2024_01.csv',
'./data/events_2024_02.csv',
'./data/events_2024_03.csv',
];
const results = await uploadMultipleFiles(12345, files);
console.log('Uploaded:', results);
```
## Data preparation
Before uploading, ensure your data meets the dataset schema requirements.
### CSV files
* Include a header row matching schema field names
* Use consistent formatting for dates and timestamps
* Handle special characters properly (escape or quote)
```csv theme={null}
customer_id,event_type,event_timestamp,event_value
cust_123,purchase,2024-01-15T10:30:00Z,99.99
cust_456,view,2024-01-15T11:00:00Z,0
```
### JSON files
Use JSON Lines format (one object per line):
```json theme={null}
{"customer_id": "cust_123", "event_type": "purchase", "event_timestamp": "2024-01-15T10:30:00Z", "event_value": 99.99}
{"customer_id": "cust_456", "event_type": "view", "event_timestamp": "2024-01-15T11:00:00Z", "event_value": 0}
```
### Hashing PII
If your data contains PII, hash it before uploading:
```typescript theme={null}
import crypto from 'crypto';
function hashEmail(email: string): string {
const normalized = email.toLowerCase().trim();
return crypto.createHash('sha256').update(normalized).digest('hex');
}
// Before uploading, transform your data
const hashedData = rawData.map(row => ({
...row,
sha256_hashed_email: hashEmail(row.email),
}));
```
For detailed hashing instructions, see [Hashing PII for Upload](/guides/ingestion/hashing-pii).
## Error handling
```typescript theme={null}
try {
const uploadUrl = await api.getUploadUrl(datasetId, fileName);
await api.uploadFileToS3(uploadUrl.url, fileContent, uploadUrl.headers);
await api.confirmUpload(datasetId, uploadUrl.upload_id);
} catch (error) {
if (error.status === 400) {
console.error('Invalid request:', error.message);
} else if (error.status === 403) {
console.error('Permission denied - check API key permissions');
} else if (error.status === 404) {
console.error('Dataset not found');
} else {
console.error('Upload failed:', error);
}
}
```
## Best practices
| Practice | Description |
| ----------------------- | ------------------------------------------------ |
| Validate before upload | Check file format and schema match |
| Hash PII | Never upload unhashed personal data |
| Use appropriate formats | Parquet for large datasets, CSV/JSON for smaller |
| Monitor jobs | Track ingestion status to catch failures |
| Handle errors | Implement retry logic for transient failures |
## Troubleshooting
| Issue | Cause | Solution |
| ----------------- | --------------------------------------- | ------------------------------------- |
| Schema mismatch | File columns don't match dataset schema | Verify column names and types |
| Upload timeout | File too large or slow connection | Try smaller files or increase timeout |
| Permission denied | API key lacks write access | Check API key permissions |
| Invalid format | File format not recognized | Verify file extension and content |
## Related content
Prepare PII for upload
Monitor upload progress
Dataset operations
# Secret Sharing
Source: https://docs.narrative.io/guides/tools/secret-sharing
Send sensitive information through one-time-use encrypted links
Secret Sharing lets you send sensitive information—like API keys, passwords, or credentials—to another person through a one-time-use encrypted link. Once the recipient opens the link and views the secret, it is permanently deleted. No one, including you, can view it again.
Secret Sharing is a **Tools** feature. Tools features are experimental and may change or be removed in future releases.
## How to share a secret
Navigate to **[Tools > Secrets Sharing](https://app.narrative.io/platform/tools/secrets-sharing)** in the platform navigation.
Type or paste your secret into the input field.
Click **Share**. A unique retrieval URL is automatically copied to your clipboard.
Send the retrieval URL to your recipient through whatever channel you prefer (Slack, email, etc.).
**Do not open the retrieval link yourself.** The secret is destroyed the moment anyone views it—including you.
***
## How to retrieve a secret
Open the retrieval URL you received from the sender.
Your secret is displayed in a read-only field. Click **Copy** to copy it to your clipboard.
Save the secret somewhere secure. You will not be able to access this link again.
If the link has already been used, the secret will no longer be available. Ask the sender to create a new link.
***
## How it works
When you share a secret, the following happens behind the scenes:
1. Your secret is sent to the server over HTTPS
2. The server encrypts your secret using **AES-256-GCM** with a key that only the server knows
3. Each secret is encrypted with its own unique random salt and initialization vector
4. The encrypted secret is stored in a database—the plaintext is never saved
5. A unique ID is generated and returned to you as part of the retrieval URL
When someone opens the retrieval link:
1. The server looks up the encrypted secret by its unique ID
2. The secret is **immediately deleted** from the database
3. The server decrypts the secret and returns it to the recipient
4. The secret no longer exists anywhere on the server
***
## Why is this secure?
* **Encrypted at rest** — Your secret is encrypted before it is stored. Even if someone accessed the database directly, they would only see unreadable ciphertext.
* **One-time access** — The secret is permanently deleted from the database the instant it is viewed. There is no way to retrieve it a second time.
* **Unique per secret** — Each secret is encrypted with its own random salt and initialization vector, so even identical secrets produce completely different ciphertext.
* **Server-side encryption** — Encryption and decryption happen on the server. The encryption key never leaves the server environment and is not exposed to the browser.
* **No accounts required** — Anyone with the link can retrieve the secret. No login is needed, which means you can share secrets with people who don't have a Narrative account.
***
## Limitations
Keep these limitations in mind when using Secret Sharing:
* **No expiration** — If a secret is never retrieved, it remains stored indefinitely. Share links only with people you expect to use them.
* **No access log** — There is no way to confirm who retrieved the secret or when. You only know that once the link stops working, someone has viewed it.
* **Single viewer** — Only one person can ever view the secret. If you need to share the same secret with multiple people, create a separate link for each recipient.
* **Designed for short secrets** — The feature is intended for keys and passwords, not large documents.
***
## Common use cases
* Sharing API keys or tokens with a teammate
* Sending a database password to a contractor
* Passing credentials to a support engineer during troubleshooting
* Any time you need to send sensitive text and don't want it sitting in a chat history or email thread
***
## Related content
How Narrative protects your data
Create and manage API keys for the Narrative API
# Subscribing to Notifications
Source: https://docs.narrative.io/guides/webhooks/subscribing-to-notifications
Create and manage webhook subscriptions to receive push notifications for job state changes
Webhook subscriptions let you receive HTTP POST notifications when jobs change state, eliminating the need to poll the jobs API. This guide walks through creating, receiving, and managing webhook subscriptions.
For background on how webhooks work and when to use them, see [Webhooks](/concepts/webhooks/overview). For complete API details, see the [Webhooks API Reference](/api-reference/webhooks/list-webhook-subscriptions).
## Prerequisites
* An API token with Webhooks `read` and `write` [permissions](/reference/security/permissions)
* An HTTPS endpoint ready to receive POST requests
* Familiarity with [job types](/reference/architecture/job-types) and [job states](/guides/sdk/tracking-jobs#job-states)
## What you'll learn
* How to create a job webhook subscription with filters
* How to receive and verify webhook events
* How to list, inspect, and archive subscriptions
***
## Creating a job webhook subscription
Use the `POST /webhooks` endpoint to create a subscription. Set the `type` to `webhook_subscription_jobs` and provide your endpoint URL.
### Choose your filters
Decide which job events you want to receive. You can filter by job IDs, job types, job states, or any combination.
| Scenario | Filters |
| -------------------------------- | ---------------------------------------------------------- |
| All completed materialized views | `job_types: ["materialize-view"]`, `states: ["completed"]` |
| Track a specific job | `job_ids: [""]` |
| All failed jobs | `states: ["failed"]` |
| All state changes (no filter) | Omit `job_ids`, `job_types`, and `states` |
### Create the subscription
```bash theme={null}
curl -X POST https://api.narrative.io/webhooks \
-H "Authorization: Bearer $NIO_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "webhook_subscription_jobs",
"url": "https://your-app.example.com/webhooks/narrative",
"name": "completed_matviews",
"job_types": ["materialize-view"],
"states": ["completed", "failed"]
}'
```
### Store the secret
The response includes a `secret` field — a UUID used to verify that incoming requests are from Narrative. Store this value securely.
```json theme={null}
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"secret": "8b6dba8c-9a8b-4a3a-8469-9dc7a33c3e17",
"status": "active",
"companyId": 42,
"jobTypes": ["materialize-view"],
"jobStates": ["completed", "failed"],
"url": "https://your-app.example.com/webhooks/narrative",
"createdAt": "2025-04-29T16:12:45.123456",
"updatedAt": "2025-04-29T16:12:45.123456"
}
```
***
## Receiving events
When a job matches your subscription's filters, Narrative sends an HTTP POST to your URL with a JSON payload.
### Example event payload
```json theme={null}
{
"context": {
"companyId": "42"
},
"eventTimestamp": "2025-04-29T16:15:30.456789",
"eventType": {
"value": "job_state_change"
},
"idempotencyKey": {
"value": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"payload": {
"value": {
"jobId": "d35a12c1-7a2f-46b9-8d60-9e0a2a84c205",
"jobType": "materialize-view",
"state": "completed"
}
}
}
```
Your endpoint should return a `2xx` status code to acknowledge receipt.
***
## Handling idempotency
Each event includes an `idempotencyKey` — a unique identifier for that specific delivery. If Narrative delivers the same event more than once (for example, due to a network retry), the idempotency key remains the same.
Use the idempotency key to detect duplicates:
1. When you receive an event, check if you've already processed that `idempotencyKey`
2. If yes, return `200 OK` without processing again
3. If no, process the event and store the key
***
## Managing subscriptions
### List all subscriptions
```bash theme={null}
curl https://api.narrative.io/webhooks \
-H "Authorization: Bearer $NIO_API_TOKEN"
```
Returns an array of all webhook subscriptions for your company, including both `active` and `archived` subscriptions.
### Get a specific subscription
```bash theme={null}
curl https://api.narrative.io/webhooks/{webhook_subscription_id} \
-H "Authorization: Bearer $NIO_API_TOKEN"
```
### Archive a subscription
Archiving stops event delivery but retains the subscription record.
```bash theme={null}
curl -X DELETE https://api.narrative.io/webhooks/{webhook_subscription_id} \
-H "Authorization: Bearer $NIO_API_TOKEN"
```
Archiving is permanent — there is no way to reactivate an archived subscription. Create a new subscription if you need to resume notifications.
***
## Best practices
| Practice | Why |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Respond quickly** | Return a `2xx` response immediately, then process the event asynchronously. Slow responses may cause delivery timeouts. |
| **Store the secret** | Save the `secret` from the creation response. You cannot retrieve it later. |
| **Implement idempotency** | Use the `idempotencyKey` to deduplicate events in case of retries. |
| **Use HTTPS** | Webhook URLs must use HTTPS to protect payloads in transit. |
| **Filter narrowly** | Subscribe only to the job types and states you need. This reduces noise and processing overhead. |
***
## Related content
How webhooks work and when to use them
Complete field reference for subscription requests and responses
Poll-based alternative for monitoring job progress
Create and manage API tokens for webhook access
# Automating Multi-Step Pipelines
Source: https://docs.narrative.io/guides/workflows/workflow-orchestration
Define and run multi-step data workflows that execute automatically based on task dependencies
If you're running multi-step data operations manually—creating datasets, refreshing views, then building derived tables—workflows let you define the entire pipeline once and execute it automatically. Tasks run in sequence, each waiting for the previous one to finish before starting.
For a deeper understanding of how workflows relate to the jobs system, see [Workflow Orchestration](/concepts/workflows/workflow-orchestration). For complete API details, see the [Workflows API Reference](/api-reference/workflows/list-workflows).
## Prerequisites
* An API token with workflow permissions (`read` and `write` access to `workflows`)
* A [data plane](/reference/glossary) configured for your account
* Familiarity with [NQL syntax](/nql/general/syntax) and [materialized views](/concepts/nql/materialized-views)
## What you'll learn
* How to view and manage workflows in the UI
* How to define a workflow specification in YAML
* How to create and trigger workflows via the API
* How to chain multiple tasks with dependencies
* How to pass data between tasks using export and variable expressions
* How to schedule workflows for automatic execution
* How to monitor workflow runs and troubleshoot failures
***
## Managing workflows in the UI
You can view and manage your workflows from **Settings > Workflows** in the platform sidebar. The workflows page displays all your workflows in a table with the following information:
* **Name** — the workflow's name (links to the detail page)
* **Status** — whether the workflow is Active or Archived
* **Data Plane** — which data plane the workflow runs on
* **Created** — when the workflow was created
The workflows list is scoped to the data plane currently selected in the [context selector](/guides/platform/using-the-context-selector). Switch data planes from the context selector to see workflows that run on a different data plane. Status filter chip counts reflect only the workflows visible for the selected data plane.
### Workflow actions
From the workflows table or the detail page, you can perform these actions on each workflow:
* **Trigger** — manually start a new run of an active workflow
* **Archive** — deactivate a workflow so it no longer runs on its schedule
* **Copy ID** — copy the workflow ID to your clipboard
### Viewing workflow details
Click any workflow name to open its detail page, which has two tabs:
* **Definition** — displays the full YAML specification with syntax highlighting
* **Runs** — shows the run history with status, start time, and duration for each execution
Workflow creation and editing are currently available through the API only. Use the UI to monitor, trigger, and archive workflows you've created via the API.
***
## Defining a workflow
Workflows use the [Serverless Workflow DSL](https://github.com/serverlessworkflow/specification/blob/main/dsl-reference.md) in YAML format. A workflow is a list of tasks that execute sequentially in the order you define them.
### Minimal example
Here's the simplest possible workflow—a single task that creates a materialized view:
```yaml theme={null}
document:
dsl: '1.0.0'
namespace: analytics
name: daily-user-summary
version: '1.0.0'
do:
- createUserSummary:
call: CreateMaterializedViewIfNotExists
with:
nql: |
CREATE MATERIALIZED VIEW user_event_summary AS
SELECT
user_id,
COUNT(1) as event_count
FROM events
GROUP BY user_id
```
Every workflow has two sections:
| Section | Purpose |
| ---------- | -------------------------------------------------- |
| `document` | Metadata—DSL version, namespace, name, and version |
| `do` | Ordered list of tasks to execute |
Each task has a unique name (the key), a `call` specifying which task to run, and `with` providing the task's parameters.
### Supported tasks
Workflows support these operations:
| Task | Description |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `CreateMaterializedViewIfNotExists` | Creates a new materialized view from an NQL query |
| `RefreshMaterializedView` | Refreshes an existing materialized view with the latest data |
| `ExecuteDml` | Executes a DML statement ([INSERT](/nql/commands/insert), [UPDATE](/nql/commands/update), [DELETE](/nql/commands/delete)) |
| `RunModelInference` | Runs a model inference job |
| `CreateRosettaStoneMappingsIfNotExist` | Creates Rosetta Stone attribute mappings for a dataset |
Direct `ExecuteNQL` is not supported. To execute a SELECT query and persist results, use `CreateMaterializedViewIfNotExists`.
***
## Creating and running a workflow
### Step 1: Create the workflow
Send your YAML specification to the API:
```bash theme={null}
curl -X POST https://api.narrative.io/workflows \
-H "Authorization: Bearer $NIO_API_TOKEN" \
-H "Content-Type: application/x-yaml" \
-d @my-workflow.yaml
```
The response includes a `workflow_id` that you'll use for all subsequent operations.
### Step 2: Trigger a run
Execute the workflow on a specific data plane:
```bash theme={null}
curl -X POST https://api.narrative.io/workflows/{workflow_id}/trigger \
-H "Authorization: Bearer $NIO_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"data_plane_id": 123}'
```
Each trigger creates a new **run** with its own `run_id`. The workflow's tasks execute sequentially—each task waits for the previous one to complete before starting.
### Step 3: Check the status
Monitor the run to see how it's progressing:
```bash theme={null}
curl https://api.narrative.io/workflows/{workflow_id}/runs \
-H "Authorization: Bearer $NIO_API_TOKEN"
```
Each task in a workflow creates its own job in the underlying jobs system. Jobs created by workflows are automatically tagged with `workflow_enqueued`, making them easy to filter when checking job history. On the **Jobs** page, select **Workflow Enqueued** in the Status filter or paste a `workflow_id` or `workflow_run_id` into the search bar to scope the table to a single workflow or run.
***
## Chaining dependent tasks
The real power of workflows is chaining operations that depend on each other. Tasks run in order, so later tasks can reference datasets created by earlier ones.
### Example: Create a source dataset, then derive from it
```yaml theme={null}
document:
dsl: '1.0.0'
namespace: analytics
name: user-comparison-workflow
version: '1.0.0'
do:
- createSourceData:
call: CreateMaterializedViewIfNotExists
with:
nql: |
CREATE MATERIALIZED VIEW active_users_source AS
SELECT
id,
name,
email
FROM users
WHERE active = true
- createDerivedView:
call: CreateMaterializedViewIfNotExists
with:
nql: |
CREATE MATERIALIZED VIEW active_users_with_names AS
SELECT
id,
name
FROM company_data.active_users_source
WHERE name IS NOT NULL
```
The second task references `company_data.active_users_source`—the dataset created by the first task. The workflow ensures the first task completes before the second one starts.
When referencing datasets created by earlier tasks, use the fully qualified path: `company_data.dataset_name`.
### Example: Refresh multiple views in sequence
```yaml theme={null}
document:
dsl: '1.0.0'
namespace: etl
name: daily-refresh
version: '1.0.0'
do:
- refreshSource:
call: RefreshMaterializedView
with:
datasetName: raw_events
- refreshAggregates:
call: RefreshMaterializedView
with:
datasetName: event_aggregates
```
This ensures `raw_events` is refreshed before `event_aggregates`, which may depend on it.
***
## Passing data between tasks
Beyond referencing datasets by name, workflows can pass structured data between tasks using **export** and **variable expressions**. This is useful when a downstream task needs metadata from an upstream task — like a dataset ID, row count, or status value.
For the complete syntax reference, see [Task output](/reference/workflows/specification-syntax#task-output), [Export](/reference/workflows/specification-syntax#export), and [Variable expressions](/reference/workflows/specification-syntax#variable-expressions) in the specification reference.
### Exporting task output to workflow context
Every task produces a JSON output object after execution. Use `export.as` with a jq expression to capture values into the workflow context (`$context`):
```yaml theme={null}
- createView:
call: CreateMaterializedViewIfNotExists
with:
nql: "CREATE MATERIALIZED VIEW active_users AS SELECT user_id, email FROM company_data.users WHERE is_active = true"
export:
as: '$context + { datasetId: .datasetId }'
```
After this task runs, `$context.datasetId` contains the ID of the created dataset.
### Using variable expressions in parameters
Use `${…}` in parameter values to inject data from the previous task's output (`.`) or the workflow context (`$context`). Use jq string interpolation `\(expr)` to embed values inside strings:
```yaml theme={null}
- logResult:
call: ExecuteDml
with:
nql: |
${"INSERT INTO company_data.pipeline_log (dataset_id, status) VALUES (\($context.datasetId), 'created')"}
```
A pure `${…}` expression (as the entire parameter value) preserves the JSON type. When used inside a string or with `${"..."}`, the result is always a string.
### Complete example
This workflow creates a materialized view, captures its dataset ID in the workflow context, then logs the operation using both direct output access and context:
```yaml theme={null}
document:
dsl: '1.0.0'
namespace: etl
name: create-and-log
version: '1.0.0'
do:
- createView:
call: CreateMaterializedViewIfNotExists
with:
nql: "CREATE MATERIALIZED VIEW active_users AS SELECT user_id, email FROM company_data.users WHERE is_active = true"
export:
as: '$context + { datasetId: .datasetId }'
- logCreatedDataset:
call: ExecuteDml
with:
nql: |
${"INSERT INTO company_data.pipeline_log (dataset_id, status) VALUES (\(.datasetId), 'created')"}
- logFromContext:
call: ExecuteDml
with:
nql: |
${"INSERT INTO company_data.pipeline_log (dataset_id, status) VALUES (\($context.datasetId), 'created')"}
```
Here's what happens at each step:
1. **`createView`** creates the materialized view. Its output includes `datasetId`. The `export.as` expression merges this into `$context` as `datasetId`.
2. **`logCreatedDataset`** uses `.datasetId` — the direct output of the previous task — in a variable expression to insert a log record.
3. **`logFromContext`** uses `$context.datasetId` to access the same value via the accumulated workflow context. This is useful when the value was exported several tasks ago and is no longer in the immediately preceding output.
***
## Scheduling workflows
Instead of triggering runs manually, you can set workflows to run on a schedule using cron expressions.
Add a `schedule` block to your workflow specification:
```yaml theme={null}
document:
dsl: '1.0.0'
namespace: etl
name: hourly-refresh
version: '1.0.0'
schedule:
cron: '0 * * * *'
do:
- refreshData:
call: RefreshMaterializedView
with:
datasetName: hourly_metrics
```
### Common schedules
| Expression | Schedule |
| ----------- | ------------------ |
| `0 * * * *` | Every hour |
| `0 0 * * *` | Daily at midnight |
| `0 0 * * 0` | Weekly on Sunday |
| `0 0 1 * *` | Monthly on the 1st |
Run your workflow manually at least once before adding a schedule. This lets you verify it works correctly without waiting for the next scheduled run.
***
## Handling errors
Workflows use fail-fast behavior: if any task fails, execution stops immediately and remaining tasks are skipped. The workflow run status becomes `failed`.
To investigate a failure:
1. Check the workflow run status via the [runs endpoint](/api-reference/workflows/list-workflow-runs)
2. Identify which task failed from the run details
3. Query the individual job using its `job_id` for detailed error information
### Common errors
| Error | Cause | Solution |
| -------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- |
| `Unsupported task` | Using a task not in the supported list | Use only the [supported tasks](/reference/workflows/tasks) |
| `Missing required field` | Incomplete YAML specification | Ensure `document`, `dsl`, `namespace`, `name`, `version`, and `do` are all present |
| `Invalid workflow specification` | Malformed YAML | Validate your YAML syntax before submitting |
***
## Best practices
* **Name workflows clearly.** Use descriptive names that explain what the workflow does (e.g., `daily-audience-refresh` rather than `workflow-1`).
* **Version your workflows.** Increment the version when you change the specification to maintain a clear history.
* **Start with a single task.** Build and test one task at a time before assembling multi-step pipelines.
* **Test before scheduling.** Run workflows manually to confirm they work before setting up automatic execution.
***
## Limitations
* **Sequential only** — tasks execute one at a time in order; parallel execution is not supported
* **No conditional logic** — all tasks run unconditionally; there is no if/else branching
* **No loops** — iterative operations are not supported
* **No automatic retries** — failed tasks are not retried automatically
***
## Related content
How workflows work and why they're designed this way
Complete reference for all supported workflow tasks and parameters
Complete API documentation for workflow endpoints
How to create and manage materialized views used in workflow tasks
# Materialized View Syntax
Source: https://docs.narrative.io/nql/commands/create-materialized-view
Complete reference for CREATE MATERIALIZED VIEW statement
The `CREATE MATERIALIZED VIEW` statement creates a stored dataset from a query result. Unlike regular queries that execute each time, materialized views persist their results for faster access.
## Syntax
```sql theme={null}
CREATE MATERIALIZED VIEW "view_name"
[REFRESH_SCHEDULE = ]
[DISPLAY_NAME = '']
[DESCRIPTION = '']
[EXPIRE = ]
[TAGS = ( '', '', ... )]
[WRITE_MODE = '']
[EXTENDED_STATS = '']
[PARTITIONED_BY [, ]]
AS
[MERGE ON
WHEN MATCHED THEN UPDATE SET = source., ...
WHEN NOT MATCHED THEN INSERT () VALUES ()]
```
## Options summary
| Option | Type | Required | Description |
| ------------------ | ----------------------- | -------- | ---------------------------------------- |
| `REFRESH_SCHEDULE` | enum or CRON | No | When to automatically refresh the view |
| `DISPLAY_NAME` | string | No | Human-readable name for the UI |
| `DESCRIPTION` | string | No | Documentation text |
| `EXPIRE` | ISO 8601 period or enum | No | Data retention policy |
| `TAGS` | array | No | Labels for organization and filtering |
| `WRITE_MODE` | enum | No | How refresh operations write data |
| `EXTENDED_STATS` | enum | No | Whether to compute additional statistics |
| `PARTITIONED_BY` | field + transform | No | How to partition stored data |
| `MERGE ON` | condition + clauses | No | Upsert logic for incremental updates |
***
## REFRESH\_SCHEDULE
Defines when the materialized view automatically refreshes with new data.
**Syntax:**
```sql theme={null}
REFRESH_SCHEDULE = '@daily'
REFRESH_SCHEDULE = '0 2 * * *'
```
**Values:**
| Value | Description |
| --------------- | ------------------------------------------ |
| `@hourly` | Refresh every hour |
| `@daily` | Refresh once per day |
| `@weekly` | Refresh once per week |
| `@monthly` | Refresh once per month |
| `@once` | Refresh only once (no recurring schedule) |
| CRON expression | Custom schedule using standard CRON syntax |
**CRON format:**
```
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *
```
**Example:** Refresh every Monday at 3:15 PM UTC:
```sql theme={null}
REFRESH_SCHEDULE = '15 15 * * 1'
```
For large datasets, consider using [Incremental View Maintenance](/concepts/nql/incremental-view-maintenance) to refresh only changed data.
***
## DISPLAY\_NAME
A human-readable name shown in the Narrative I/O interface.
**Syntax:**
```sql theme={null}
DISPLAY_NAME = 'Monthly Sales Summary'
```
If not specified, the view name is used as the display name.
***
## DESCRIPTION
Documentation text describing the view's purpose and contents.
**Syntax:**
```sql theme={null}
DESCRIPTION = 'Aggregated sales metrics by month for the executive dashboard'
```
***
## EXPIRE
Sets a data retention policy that determines how long data is kept.
**Syntax:**
```sql theme={null}
EXPIRE = 'P30D'
EXPIRE = 'retain_everything'
EXPIRE = 'expire_everything'
```
**Values:**
| Value | Description |
| ------------------- | ------------------------------------------------------------------------------------ |
| ISO 8601 duration | Data expires after the specified period (e.g., `P30D` for 30 days, `P1Y` for 1 year) |
| `retain_everything` | Keep all data indefinitely |
| `expire_everything` | Expire data immediately (effectively clears on each refresh) |
**ISO 8601 duration format:**
* `P30D` - 30 days
* `P6M` - 6 months
* `P1Y` - 1 year
* `P1Y6M` - 1 year and 6 months
***
## TAGS
Labels for organizing and filtering materialized views.
**Syntax:**
```sql theme={null}
TAGS = ( '_nio_materialized_view', 'sales', 'monthly' )
```
Tags help categorize views and can be used to filter views in the UI or via API.
***
## WRITE\_MODE
Determines how data is written when the view refreshes.
**Syntax:**
```sql theme={null}
WRITE_MODE = 'overwrite'
WRITE_MODE = 'append'
```
**Values:**
| Value | Description |
| ----------- | --------------------------------------------------- |
| `overwrite` | Replace all existing data with the new query result |
| `append` | Add new data to the existing dataset |
**When to use each:**
* **overwrite**: Use when you want a complete snapshot of the current state. Good for dashboards and reports where you always want the latest data.
* **append**: Use when building a historical record. Good for event logs or time-series data where you want to accumulate data over time.
When using `append` mode, ensure your query includes appropriate filters to avoid inserting duplicate records.
***
## EXTENDED\_STATS
Controls whether additional statistics are computed for the dataset.
**Syntax:**
```sql theme={null}
EXTENDED_STATS = 'all'
EXTENDED_STATS = 'none'
```
**Values:**
| Value | Description |
| ------ | ------------------------------------------------------------------------------------------------- |
| `all` | Compute extended statistics including cardinality estimates, value distributions, and null counts |
| `none` | Skip extended statistics (faster refresh, less metadata) |
Extended statistics help the query optimizer but increase refresh time. Use `all` for frequently queried views and `none` for large views where refresh speed is critical.
***
## PARTITIONED\_BY
Defines how the stored data is partitioned for improved query performance.
**Syntax:**
```sql theme={null}
PARTITIONED_BY
PARTITIONED_BY ,
```
**Transforms:**
| Transform | Description | Example |
| --------- | ------------------ | --------------------------------- |
| `DAY` | Partition by day | `PARTITIONED_BY event_date DAY` |
| `MONTH` | Partition by month | `PARTITIONED_BY created_at MONTH` |
| `YEAR` | Partition by year | `PARTITIONED_BY order_date YEAR` |
Partitioning improves query performance when filtering on the partitioned field. Choose a partition scheme that matches your most common query patterns.
***
## MERGE ON
Defines upsert logic that matches incoming rows against existing data during refresh. Use MERGE ON to update existing records and insert new ones without creating duplicates.
**Syntax:**
```sql theme={null}
MERGE ON
WHEN MATCHED THEN UPDATE SET
= source.,
...
WHEN NOT MATCHED THEN INSERT () VALUES ()
```
**Auto-generated aliases:**
| Alias | Description |
| -------- | ------------------------------------------------------ |
| `source` | References columns from the incoming query result |
| `target` | References columns from the existing materialized view |
**Example:**
```sql theme={null}
MERGE ON target.user_id IS NOT DISTINCT FROM source.user_id
WHEN MATCHED THEN UPDATE SET
email = source.email,
last_seen = source.last_seen
WHEN NOT MATCHED THEN INSERT (user_id, email, last_seen)
VALUES (source.user_id, source.email, source.last_seen)
```
MERGE ON is only valid within `CREATE MATERIALIZED VIEW` statements. It cannot be used as a standalone command.
For step-by-step guidance and practical examples, see [Incremental Upserts with MERGE ON](/guides/nql/incremental-upserts).
***
## Complete example
```sql theme={null}
CREATE MATERIALIZED VIEW "female_audience"
REFRESH_SCHEDULE = '@daily'
DISPLAY_NAME = 'Female Audience'
DESCRIPTION = 'Audience containing demographic of female users.'
EXPIRE = 'retain_everything'
TAGS = ( '_nio_materialized_view', 'user_engagement', 'metrics' )
WRITE_MODE = 'overwrite'
EXTENDED_STATS = 'all'
PARTITIONED_BY event_date DAY
AS
SELECT
"unique_identifier",
"hl7_gender",
"age"
FROM narrative.rosetta_stone
WHERE
"hl7_gender"."gender" = 'female'
AND "event_timestamp" > CURRENT_TIMESTAMP - INTERVAL 160 DAYS
AND rosetta_stone._price_cpm_usd <= 2.00
```
This example creates a materialized view that:
* Refreshes daily
* Stores female user demographics from the last 160 days
* Partitions data by day for efficient time-based queries
* Retains all historical data indefinitely
* Computes full statistics for query optimization
***
If you need a lightweight query abstraction that re-evaluates at query time instead of storing results, consider using a [view dataset](/concepts/nql/materialized-views#view-datasets) instead. View datasets are created through the SDK or API with the `create_as_view` option rather than through NQL syntax.
## Related content
Step-by-step guide to creating your first materialized view
Use MERGE ON for deduplication and incremental updates
Understand when and why to use materialized views
Learn how NQL optimizes view refreshes
# DELETE
Source: https://docs.narrative.io/nql/commands/delete
Reference for the DELETE statement
The `DELETE` statement removes rows from a dataset. A `WHERE` clause restricts which rows are removed.
DELETE runs against datasets you own. To drop an entire dataset rather than delete rows, delete the dataset through the platform UI or API.
## Syntax
```sql theme={null}
DELETE FROM table_reference
[WHERE condition]
```
* The `WHERE` clause is optional. If omitted, **every row in the dataset is removed**. The dataset itself remains but is empty.
* The `WHERE` condition can reference any column on the target dataset and use any NQL [operators](/nql/operators) or [functions](/nql/functions/index).
* The `WHERE` condition can reference only the target dataset. Subqueries that read from another dataset (for example, `WHERE name IN (SELECT name FROM company_data."456")`) are not supported.
## Examples
**Delete a single row by key:**
```sql theme={null}
DELETE FROM company_data."123"
WHERE user_id = 42
```
**Delete rows matching a condition:**
```sql theme={null}
DELETE FROM company_data.pipeline_log
WHERE
status = 'stale'
AND created_at < CURRENT_TIMESTAMP - INTERVAL '90' DAY
```
**Delete rows with null required fields:**
```sql theme={null}
DELETE FROM company_data."123"
WHERE email IS NULL
```
## Execution context
DELETE statements can run in two places:
| Execution surface | When to use |
| -------------------------------------------------------------------------------------- | --------------------------------------------------- |
| NQL query endpoint | Ad-hoc deletes from an API client or Data Studio |
| [`ExecuteDml` workflow task](/guides/workflows/workflow-orchestration#supported-tasks) | Scheduled or event-driven deletes inside a workflow |
DELETE is irreversible. Running `DELETE FROM table_reference` without a `WHERE` clause empties the dataset. Run the equivalent `SELECT` with the same predicate first to confirm exactly which rows will be removed.
***
## Related content
Add rows to a dataset
Modify existing rows in a dataset
Preview rows before deleting
Run DML statements from a workflow
# SQL Commands
Source: https://docs.narrative.io/nql/commands/index
Reference for NQL statement types and command syntax
NQL supports statement types for querying, creating stored datasets, and modifying data in existing datasets.
| Statement | Purpose | Reference |
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------ |
| `SELECT` | Execute a query and return results | [SELECT](/nql/commands/select) |
| `INSERT` | Add rows to an existing dataset | [INSERT](/nql/commands/insert) |
| `UPDATE` | Modify existing rows in a dataset | [UPDATE](/nql/commands/update) |
| `DELETE` | Remove rows from a dataset | [DELETE](/nql/commands/delete) |
| `CREATE MATERIALIZED VIEW` | Create a stored dataset from a query | [CREATE MATERIALIZED VIEW](/nql/commands/create-materialized-view) |
## Query Templates
NQL also supports parameterized queries through [query templates](/nql/commands/query-templates), which allow you to define reusable queries with placeholder variables.
## Related content
Primitive and complex types supported in NQL
Built-in and Narrative-specific functions
# INSERT
Source: https://docs.narrative.io/nql/commands/insert
Reference for the INSERT statement
The `INSERT` statement adds rows to an existing dataset. NQL supports two forms: `INSERT ... VALUES (...)` for literal rows, and `INSERT ... SELECT ...` for inserting the result of a query.
INSERT runs against datasets you own. To create a new dataset from a query, use [CREATE MATERIALIZED VIEW](/nql/commands/create-materialized-view). To upsert into an existing materialized view during refresh, use the [MERGE ON clause](/guides/nql/incremental-upserts).
## Syntax
### VALUES form
```sql theme={null}
INSERT INTO table_reference (column1, column2, ...)
VALUES
(value1, value2, ...)
[, (value1, value2, ...), ...]
```
### SELECT form
```sql theme={null}
INSERT INTO table_reference (column1, column2, ...)
SELECT
column1,
column2,
...
FROM source_reference
[WHERE condition]
```
## Explicit column list is required
The column list after `INSERT INTO table_reference` is required. NQL does not infer columns from the dataset schema, and wildcards (`SELECT *`) are not supported in the SELECT form either.
```sql theme={null}
-- Correct
INSERT INTO company_data."123" (user_id, email)
VALUES (42, 'alice@example.com')
-- Not supported — missing column list
INSERT INTO company_data."123"
VALUES (42, 'alice@example.com')
```
See [Explicit column selection](/nql/general/explicit-columns) for the rationale and how it applies across NQL.
## Source restrictions for the SELECT form
When you use `INSERT ... SELECT`, the source query can read only from datasets and views your company owns. The following sources are rejected:
* Datasets owned by another company (including those shared with you through access rules)
* Access rules (e.g., `provider_slug."access_rule_name"`)
* `narrative.rosetta_stone`
If you need to ingest data from a shared access rule, materialize it first with [`CREATE MATERIALIZED VIEW`](/nql/commands/create-materialized-view), then `INSERT ... SELECT` from your materialized view.
Subqueries inside the SELECT — including joins, CTEs, and `IN (SELECT ...)` predicates — are subject to the same restriction.
## Column matching and types
* The number of expressions in each `VALUES` row (or columns in the `SELECT` clause) must match the column list.
* Expressions are matched to columns by position, not by name.
* Each expression must be assignable to the target column's [data type](/nql/data-types). Implicit coercions follow the same rules as comparisons elsewhere in NQL.
## Examples
**Single-row VALUES:**
```sql theme={null}
INSERT INTO company_data."123" (dataset_id, status, created_at)
VALUES (456, 'created', CURRENT_TIMESTAMP)
```
**Multi-row VALUES:**
```sql theme={null}
INSERT INTO company_data.pipeline_log (dataset_id, status, created_at)
VALUES
(101, 'created', CURRENT_TIMESTAMP),
(102, 'created', CURRENT_TIMESTAMP),
(103, 'skipped', CURRENT_TIMESTAMP)
```
**INSERT from a SELECT:**
```sql theme={null}
INSERT INTO company_data."456" (user_id, email, created_at)
SELECT
company_data."123".user_id,
company_data."123".email,
company_data."123".created_at
FROM company_data."123"
WHERE company_data."123".created_at >= CURRENT_DATE - INTERVAL '7' DAY
```
**INSERT with transformations and a join:**
```sql theme={null}
INSERT INTO company_data."789" (user_id, email, region)
SELECT
u.user_id,
LOWER(u.email) AS email,
g.region
FROM company_data."123" AS u
JOIN company_data."456" AS g
ON u.user_id = g.user_id
WHERE u.is_active = TRUE
```
## Execution context
INSERT statements can run in two places:
| Execution surface | When to use |
| -------------------------------------------------------------------------------------- | --------------------------------------------------- |
| NQL query endpoint | Ad-hoc inserts from an API client or Data Studio |
| [`ExecuteDml` workflow task](/guides/workflows/workflow-orchestration#supported-tasks) | Scheduled or event-driven inserts inside a workflow |
INSERT does not create datasets. The target dataset must already exist. Use `CREATE MATERIALIZED VIEW` to create datasets from a query, then `INSERT` to add rows afterward.
For bulk loads from one dataset to another, `INSERT ... SELECT` is almost always faster and cheaper than issuing many single-row `INSERT ... VALUES` statements.
***
## Related content
Modify existing rows in a dataset
Remove rows from a dataset
Update or insert rows during materialized view refresh
Create a new dataset from a query
Run DML statements from a workflow
Type rules for INSERT expressions
# Query Template Syntax
Source: https://docs.narrative.io/nql/commands/query-templates
Complete reference for query template placeholders and structure
Query templates use placeholders to mark variable parts of a query. When a user runs a template, they provide values for each placeholder, and the system substitutes them into the query before execution.
## Placeholder syntax
Placeholders use double curly braces with a name:
```
{{placeholder_name}}
```
Placeholder names should be:
* Descriptive and self-documenting
* Lowercase with underscores for word separation
* Unique within the template
**Examples:**
* `{{start_date}}` - a date parameter
* `{{min_revenue_threshold}}` - a numeric threshold
* `{{target_region}}` - a string filter value
***
## Placeholder types
Each placeholder has a type that determines what values it accepts and how the value is substituted into the query.
### Literal value placeholders
Replace constants like strings, numbers, booleans, or dates.
**Supported types:**
| Type | Description | Example values |
| ----------- | -------------------- | ----------------------------- |
| `string` | Text values | `'North America'`, `'active'` |
| `number` | Numeric values | `100`, `3.14`, `-50` |
| `boolean` | True/false values | `true`, `false` |
| `date` | Date values | `'2024-01-15'` |
| `timestamp` | Date and time values | `'2024-01-15 14:30:00'` |
**Example:**
```sql theme={null}
SELECT user_id, email, created_at
FROM users
WHERE
status = {{user_status}}
AND created_at >= {{start_date}}
AND account_balance > {{min_balance}}
```
| Placeholder | Type | Description |
| ------------- | ------ | --------------------------------- |
| `user_status` | string | Account status to filter by |
| `start_date` | date | Beginning of date range |
| `min_balance` | number | Minimum account balance threshold |
***
### Column placeholders
Replace column names in SELECT, GROUP BY, or ORDER BY clauses. Use these when users need to choose which fields appear in results.
**Example:**
```sql theme={null}
SELECT
{{grouping_column}},
COUNT(1) as record_count,
SUM(amount) as total_amount
FROM transactions
GROUP BY {{grouping_column}}
ORDER BY {{sort_column}} DESC
```
| Placeholder | Type | Description |
| ----------------- | ------ | ------------------------- |
| `grouping_column` | column | Field to group results by |
| `sort_column` | column | Field to sort results by |
Column placeholders should specify which columns are valid options in the placeholder metadata. This prevents users from selecting columns that don't exist or aren't appropriate for the query context.
***
### Filter placeholders
Replace entire WHERE conditions. Use these when the filtering logic itself varies, not just the filter values.
**Example:**
```sql theme={null}
SELECT order_id, customer_id, total, order_date
FROM orders
WHERE
order_date >= {{start_date}}
AND order_date < {{end_date}}
AND {{custom_filter}}
```
| Placeholder | Type | Description |
| --------------- | ------ | ------------------------------ |
| `start_date` | date | Beginning of date range |
| `end_date` | date | End of date range |
| `custom_filter` | filter | Additional filtering condition |
A user might provide `region = 'EMEA' AND total > 1000` for the `custom_filter` placeholder.
Filter placeholders give users significant flexibility. Use them only when necessary, and document expected filter formats clearly.
***
### Output field placeholders
Replace parts of the SELECT list. Use these when the shape of the output depends on user choice.
**Example:**
```sql theme={null}
SELECT
customer_id,
customer_name,
{{output_fields}}
FROM customers
WHERE region = {{region}}
```
| Placeholder | Type | Description |
| --------------- | ------ | ---------------------------------------- |
| `output_fields` | output | Additional columns to include in results |
| `region` | string | Region to filter by |
A user might provide `email, phone, account_type` for the `output_fields` placeholder.
***
## Placeholder metadata
Each placeholder includes metadata that describes how users should interact with it.
| Property | Type | Required | Description |
| ------------- | ------- | -------- | -------------------------------------------------------------------------------------------- |
| `name` | string | Yes | Unique identifier for the placeholder |
| `type` | enum | Yes | Data type (`string`, `number`, `boolean`, `date`, `timestamp`, `column`, `filter`, `output`) |
| `description` | string | Yes | Explanation of what this placeholder controls |
| `required` | boolean | No | Whether a value must be provided (default: `true`) |
| `default` | varies | No | Default value used when not provided |
### Required vs. optional placeholders
* **Required placeholders** must have values before the query can execute
* **Optional placeholders** use their default value if the user doesn't provide one
**Example with optional placeholder:**
```sql theme={null}
SELECT region, COUNT(1) as user_count
FROM users
WHERE
created_at >= {{start_date}}
AND status = {{status}}
GROUP BY region
```
| Placeholder | Type | Required | Default | Description |
| ------------ | ------ | -------- | ---------- | ------------------------ |
| `start_date` | date | Yes | — | Beginning of date range |
| `status` | string | No | `'active'` | User status to filter by |
If the user doesn't provide a `status` value, the query uses `'active'`.
***
## Complete example
Here's a complete template with multiple placeholder types:
```sql theme={null}
SELECT
{{grouping_column}},
COUNT(1) as record_count,
SUM(revenue) as total_revenue,
AVG(revenue) as avg_revenue
FROM sales_data
WHERE
sale_date >= {{start_date}}
AND sale_date < {{end_date}}
AND region = {{region}}
AND revenue >= {{min_revenue}}
GROUP BY {{grouping_column}}
ORDER BY total_revenue DESC
LIMIT {{result_limit}}
```
**Placeholder definitions:**
| Placeholder | Type | Required | Default | Description |
| ----------------- | ------ | -------- | ------- | ------------------------------------------------------------- |
| `grouping_column` | column | Yes | — | Field to aggregate by (e.g., `product_category`, `sales_rep`) |
| `start_date` | date | Yes | — | Beginning of reporting period (inclusive) |
| `end_date` | date | Yes | — | End of reporting period (exclusive) |
| `region` | string | Yes | — | Geographic region to analyze |
| `min_revenue` | number | No | `0` | Minimum transaction amount to include |
| `result_limit` | number | No | `100` | Maximum rows to return |
***
## Materialized view templates
Templates can create materialized views, allowing users to generate scheduled data products with customized parameters.
```sql theme={null}
CREATE MATERIALIZED VIEW {{view_name}}
REFRESH_SCHEDULE = {{refresh_schedule}}
DISPLAY_NAME = {{display_name}}
AS
SELECT
user_id,
COUNT(1) as event_count,
MAX(event_timestamp) as last_activity
FROM events
WHERE
event_type = {{event_type}}
AND event_timestamp > CURRENT_TIMESTAMP - INTERVAL {{lookback_days}} DAY
GROUP BY user_id
```
| Placeholder | Type | Required | Description |
| ------------------ | ------ | -------- | ----------------------------------------------- |
| `view_name` | string | Yes | Internal identifier for the materialized view |
| `refresh_schedule` | string | Yes | When to refresh (e.g., `'@daily'`, `'@hourly'`) |
| `display_name` | string | Yes | Human-readable name shown in UI |
| `event_type` | string | Yes | Type of event to aggregate |
| `lookback_days` | number | Yes | Days of history to include |
For materialized view options, see [Materialized View Syntax](/nql/commands/create-materialized-view).
***
## Related content
Step-by-step guide to creating and using templates
Understand when and why to use templates
Complete reference for NQL data types
Core NQL syntax reference
# SELECT
Source: https://docs.narrative.io/nql/commands/select
Reference for the SELECT statement and all its clauses
The `SELECT` statement retrieves data from one or more datasets.
## Syntax
```sql theme={null}
SELECT [DISTINCT]
column1 [AS alias1],
column2 [AS alias2],
...
FROM table_reference [AS alias]
[JOIN table_reference ON condition]
[WHERE condition]
[GROUP BY column1, column2, ...]
[HAVING condition]
[QUALIFY condition]
[ORDER BY column1 [ASC|DESC], ...]
[LIMIT n [OFFSET m] ROWS]
```
NQL requires explicit column lists. Wildcards (`SELECT *` and `COUNT(*)`) are not supported. See [Explicit Column Selection](/nql/general/explicit-columns) for details.
## Clause order
Clauses must appear in this order:
1. `SELECT` — columns to return
2. `FROM` — data source(s)
3. `JOIN` — additional data sources with join conditions
4. `WHERE` — row-level filters applied before aggregation
5. `GROUP BY` — grouping for aggregations
6. `HAVING` — filters applied after aggregation
7. `QUALIFY` — filters based on window function results
8. `ORDER BY` — result ordering
9. `LIMIT` — result count restriction
## Examples
**Basic query:**
```sql theme={null}
SELECT
user_id,
email,
created_at
FROM company_data."123"
WHERE created_at > '2024-01-01'
```
**Query with aggregation:**
```sql theme={null}
SELECT
region,
COUNT(1) AS user_count,
AVG(lifetime_value) AS avg_ltv
FROM company_data."456"
GROUP BY region
HAVING COUNT(1) > 100
ORDER BY avg_ltv DESC
```
**Query with window function and QUALIFY:**
```sql theme={null}
SELECT
user_id,
email,
last_seen
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
***
## Table references
### Dataset references
Reference your datasets through the `company_data` schema, using either the dataset's numeric ID or its name:
```sql theme={null}
FROM company_data."123"
FROM company_data.my_dataset
```
Numeric IDs must be quoted because they are numeric. Dataset names don't need quoting unless the name is a reserved keyword or contains special characters, in which case wrap it in double quotes.
### Table aliases
Assign aliases to simplify column references:
```sql theme={null}
FROM company_data."123" AS users
SELECT users.email, users.created_at
```
### Rosetta Stone table references
Rosetta Stone provides normalized access to data through three scope levels: global, company-scoped, and dataset-scoped. Each scope determines which data sources your query accesses.
To access another company's data through Rosetta Stone, that company must have
shared their data with you via an access rule. Your own company's data
(`company_data`) is always accessible. See [Access Rules](/concepts/primitives/access-rules)
for details on data sharing.
#### Global access
Query all normalized data from all datasets shared with you:
```sql theme={null}
SELECT
narrative.rosetta_stone.unique_id,
narrative.rosetta_stone.event_timestamp
FROM narrative.rosetta_stone
WHERE narrative.rosetta_stone.event_timestamp >= CURRENT_DATE - INTERVAL '30' DAY
```
#### Company-scoped access
Query normalized data from a specific company's datasets:
```sql theme={null}
-- All datasets from your company
SELECT
company_data._rosetta_stone.unique_id,
company_data._rosetta_stone.event_timestamp
FROM company_data._rosetta_stone
-- All datasets from a specific company (by slug)
SELECT
partner_company._rosetta_stone.unique_id,
partner_company._rosetta_stone.event_timestamp
FROM partner_company._rosetta_stone
```
#### Dataset-scoped access
Query normalized data from a specific dataset:
```sql theme={null}
-- By dataset ID
SELECT
company_data."123"._rosetta_stone.unique_id,
company_data."123"._rosetta_stone.event_timestamp
FROM company_data."123"._rosetta_stone
-- By dataset slug
SELECT
company_data.my_dataset._rosetta_stone.unique_id,
company_data.my_dataset._rosetta_stone.event_timestamp
FROM company_data.my_dataset._rosetta_stone
-- From another company's dataset
SELECT
partner_company.shared_dataset._rosetta_stone.unique_id,
partner_company.shared_dataset._rosetta_stone.event_timestamp
FROM partner_company.shared_dataset._rosetta_stone
```
#### Choosing a scope level
| Scope | Syntax | Use when |
| ------- | ----------------------------------- | ------------------------------------------------------------------------------- |
| Global | `narrative.rosetta_stone` | You want all available normalized data from all sources |
| Company | `company_data._rosetta_stone` | You want data from a specific company only |
| Dataset | `company_data."123"._rosetta_stone` | You need to combine normalized and non-normalized columns from the same dataset |
See [Scoping Your Queries](/concepts/rosetta-stone/normalization-model#scoping-your-queries) for guidance on choosing the right scope.
### Access rules
Query through pre-configured access rules using the provider's company slug:
```sql theme={null}
FROM provider_company."access_rule_name"
SELECT
provider_company."ar_fitness".Total_Output
FROM provider_company."ar_fitness"
```
***
## Column references
### Fully qualified names
Use fully qualified column names to avoid ambiguity, especially in joins:
```sql theme={null}
SELECT
company_data."123".user_id,
company_data."123".email,
company_data."456".order_id
FROM company_data."123"
JOIN company_data."456" ON company_data."123".user_id = company_data."456".customer_id
```
### Column aliases
Assign aliases to rename columns in results:
```sql theme={null}
SELECT
user_id AS id,
email AS contact_email,
UPPER(name) AS display_name
FROM company_data."123"
```
### Accessing nested fields
Access struct fields using dot notation:
```sql theme={null}
SELECT
company_data."1".nested_field.deep_field.value
FROM company_data."1"
```
Access array elements using bracket notation:
```sql theme={null}
SELECT
identifiers[0] AS first_id,
identifiers[0].type AS first_id_type
FROM company_data."1"
```
Bracket notation also works for struct fields: `data['field_name']`. This avoids needing to quote reserved keywords. See [Reserved Keywords best practices](/nql/general/reserved-keywords#prefer-bracket-notation-for-property-access).
***
## Special columns
### Price column
Every dataset includes `_price_cpm_usd` (price per 1,000 rows). Use it to filter by cost:
```sql theme={null}
WHERE company_data."123"._price_cpm_usd <= 1.00
```
### Rosetta Stone field access
Access normalized identity data through the `._rosetta_stone` special field on any dataset:
```sql theme={null}
SELECT
company_data."123"._rosetta_stone.unique_id,
company_data."123".original_column
FROM company_data."123"
```
This allows you to access both normalized Rosetta Stone attributes and non-normalized columns from the same dataset row.
#### Access specific identifier types
Query specific identifier types using the `unique_id` struct:
```sql theme={null}
SELECT
narrative.rosetta_stone.unique_id.b."type",
narrative.rosetta_stone.unique_id.b."value"
FROM narrative.rosetta_stone
WHERE narrative.rosetta_stone.unique_id.b."type" NOT IN ('tdid', 'cookie')
```
For complete details on scope levels and table references, see [Rosetta Stone table references](#rosetta-stone-table-references).
***
## DELTA tables
The `DELTA` function returns only records that have changed since the last query execution. Use it for incremental data processing.
### Syntax
```sql theme={null}
FROM DELTA(TABLE table_reference)
FROM DELTA((subquery))
```
### Examples
**Delta on a dataset:**
```sql theme={null}
SELECT column1
FROM DELTA(TABLE company_data."1") AS t
```
**Delta on Rosetta Stone:**
```sql theme={null}
SELECT long_value
FROM DELTA(TABLE narrative.rosetta_stone)
```
**Delta with filtering:**
```sql theme={null}
SELECT long_value
FROM DELTA((
SELECT long_value
FROM company_data.rosetta_stone
WHERE long_value > 42
))
```
***
## Subqueries
### Subquery in WHERE
```sql theme={null}
SELECT column1
FROM company_data."1"
WHERE value > (
SELECT AVG(value)
FROM company_data."1"
)
```
### Subquery in FROM
```sql theme={null}
SELECT sub.total
FROM (
SELECT SUM(amount) AS total
FROM company_data."1"
GROUP BY category
) AS sub
WHERE sub.total > 1000
```
### EXISTS and NOT EXISTS
```sql theme={null}
SELECT t1.column1
FROM company_data."1" t1
WHERE NOT EXISTS (
SELECT 1
FROM company_data."2" t2
WHERE t1.id = t2.id
)
```
***
## Common Table Expressions (WITH)
CTEs let you define named subqueries for reuse within a statement.
### Syntax
```sql theme={null}
WITH cte_name AS (
SELECT ...
),
another_cte AS (
SELECT ...
)
SELECT ...
FROM cte_name
```
### Example
```sql theme={null}
CREATE MATERIALIZED VIEW "high_value_users"
AS (
WITH daily_totals AS (
SELECT
user_id,
DATE_TRUNC('day', event_timestamp) AS event_date,
SUM(amount) AS daily_spend
FROM company_data."1"
GROUP BY user_id, DATE_TRUNC('day', event_timestamp)
),
user_averages AS (
SELECT
user_id,
AVG(daily_spend) AS avg_daily_spend
FROM daily_totals
GROUP BY user_id
)
SELECT
user_id,
avg_daily_spend
FROM user_averages
WHERE avg_daily_spend > 100
)
BUDGET 25 USD
```
***
## QUALIFY clause
The `QUALIFY` clause filters results based on window function values. It's evaluated after window functions, making it useful for deduplication and ranking.
### Syntax
```sql theme={null}
SELECT columns
FROM table
QUALIFY window_function_condition
```
### Deduplication example
Keep only the most recent record per user:
```sql theme={null}
SELECT
user_id,
email,
last_seen
FROM company_data."123"
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY last_seen DESC
) = 1
```
### Multiple window conditions
```sql theme={null}
SELECT column1
FROM company_data."1"
QUALIFY
ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col) = 1
AND SUM(1) OVER (PARTITION BY other_col) < 10
```
***
## Budget clauses
Budget clauses control spending on data queries. They are required for `CREATE MATERIALIZED VIEW` statements.
### BUDGET
Set a maximum total spend:
```sql theme={null}
BUDGET 50 USD
```
### LIMIT with USD
Alternative syntax for budget limits:
```sql theme={null}
LIMIT 100 USD
```
### Recurring limits
Set budget limits per time period:
```sql theme={null}
LIMIT 500 USD PER CALENDAR_MONTH
LIMIT 50 USD PER CALENDAR_DAY
```
### Row limits
Limit by row count instead of cost:
```sql theme={null}
LIMIT 1000 ROWS
LIMIT 100 OFFSET 10 ROWS
```
***
## SELECT DISTINCT
Remove duplicate rows from results:
```sql theme={null}
SELECT DISTINCT category
FROM company_data."123"
```
***
## CASE expressions
Conditional logic within queries:
```sql theme={null}
SELECT
user_id,
CASE
WHEN lifetime_value > 1000 THEN 'high'
WHEN lifetime_value > 100 THEN 'medium'
ELSE 'low'
END AS value_tier
FROM company_data."123"
```
***
## Related content
Primitive and complex types supported in NQL
Comparison, logical, and arithmetic operators
Built-in and Narrative-specific functions
Complete CREATE MATERIALIZED VIEW reference
# UPDATE
Source: https://docs.narrative.io/nql/commands/update
Reference for the UPDATE statement
The `UPDATE` statement modifies existing rows in a dataset. Each `UPDATE` assigns new values to one or more columns, optionally filtered by a `WHERE` clause.
UPDATE runs against datasets you own. To update rows during a materialized view refresh, use the [MERGE ON clause](/guides/nql/incremental-upserts) inside `CREATE MATERIALIZED VIEW` instead.
## Syntax
```sql theme={null}
UPDATE table_reference
SET
column1 = expression1
[, column2 = expression2, ...]
[WHERE condition]
```
* The `SET` clause lists column assignments. The right-hand side of each assignment can be any NQL expression valid for the target column's [data type](/nql/data-types), including references to other columns in the same row.
* The `WHERE` clause is optional. If omitted, **every row in the dataset is updated** — include a `WHERE` clause unless that is the intended behavior.
* The `SET` expressions and the `WHERE` condition can reference only the target dataset. Subqueries that read from another dataset (for example, `WHERE id IN (SELECT id FROM company_data."456")`) are not supported.
## Examples
**Update a single column with a literal:**
```sql theme={null}
UPDATE company_data."123"
SET status = 'archived'
WHERE user_id = 42
```
**Update multiple columns using an expression:**
```sql theme={null}
UPDATE company_data."123"
SET
email = LOWER(email),
last_updated_at = CURRENT_TIMESTAMP
WHERE email IS NOT NULL
```
**Conditional update across many rows:**
```sql theme={null}
UPDATE company_data.pipeline_log
SET status = 'stale'
WHERE
status = 'created'
AND created_at < CURRENT_TIMESTAMP - INTERVAL '30' DAY
```
## Execution context
UPDATE statements can run in two places:
| Execution surface | When to use |
| -------------------------------------------------------------------------------------- | --------------------------------------------------- |
| NQL query endpoint | Ad-hoc updates from an API client or Data Studio |
| [`ExecuteDml` workflow task](/guides/workflows/workflow-orchestration#supported-tasks) | Scheduled or event-driven updates inside a workflow |
Running UPDATE without a `WHERE` clause modifies every row in the target dataset. Review the statement — or run the corresponding `SELECT` with the same predicate first — before executing.
***
## Related content
Add rows to a dataset
Remove rows from a dataset
Update or insert rows during materialized view refresh
Run DML statements from a workflow
# Data Types
Source: https://docs.narrative.io/nql/data-types
Primitive and complex types supported in NQL
NQL supports primitive types for scalar values and complex types for structured data. This reference covers all supported types and type conversion.
## Primitive types
| Type | Aliases | Description | Example literals |
| ----------- | --------- | -------------------------- | --------------------------- |
| `STRING` | `VARCHAR` | Variable-length text | `'hello world'` |
| `BOOLEAN` | — | True or false | `true`, `false` |
| `LONG` | `BIGINT` | 64-bit signed integer | `12345678901234` |
| `DOUBLE` | — | 64-bit floating point | `3.14159` |
| `DECIMAL` | — | Arbitrary precision number | `CAST(10.12345 AS DECIMAL)` |
| `DATE` | — | Date without time | `'2024-01-15'` |
| `TIMESTAMP` | — | Date with time | `'2024-01-15 10:30:00'` |
***
## STRING / VARCHAR
Variable-length character data. String literals use single quotes.
```sql theme={null}
SELECT
'Hello, World!' AS greeting,
CONCAT(first_name, ' ', last_name) AS full_name
FROM company_data."123"
```
### Escape sequences
| Sequence | Meaning |
| -------- | ------------ |
| `''` | Single quote |
| `\\` | Backslash |
```sql theme={null}
SELECT 'It''s a test' AS escaped_quote
```
***
## BOOLEAN
Logical true/false values.
```sql theme={null}
SELECT
is_active,
CASE WHEN score > 90 THEN true ELSE false END AS is_high_performer
FROM company_data."123"
WHERE is_verified = true
```
***
## LONG / BIGINT
64-bit signed integers. Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807.
```sql theme={null}
SELECT
user_id,
CAST(timestamp_value AS LONG) AS epoch_millis
FROM company_data."123"
```
***
## DOUBLE
64-bit IEEE 754 floating-point numbers. Use for approximate numeric calculations.
```sql theme={null}
SELECT
amount * 1.0825 AS amount_with_tax,
SQRT(value) AS square_root
FROM company_data."123"
```
Floating-point arithmetic can produce rounding errors. Use DECIMAL for financial calculations requiring exact precision.
***
## DECIMAL
Arbitrary-precision decimal numbers. Use for exact calculations where precision matters.
```sql theme={null}
SELECT
CAST(price AS DECIMAL) AS exact_price,
CAST(quantity AS DECIMAL) * CAST(unit_price AS DECIMAL) AS line_total
FROM company_data."123"
```
***
## DATE
Calendar date without time component. Format: `YYYY-MM-DD`.
```sql theme={null}
SELECT
event_date,
DATE_TRUNC('month', event_date) AS month_start
FROM company_data."123"
WHERE event_date >= '2024-01-01'
```
### Date arithmetic
```sql theme={null}
SELECT
event_date,
DATE_ADD(event_date, 30) AS plus_30_days,
event_date - INTERVAL '7' DAY AS minus_7_days
FROM company_data."123"
```
***
## TIMESTAMP
Date and time with precision. Format: `YYYY-MM-DD HH:MM:SS`.
```sql theme={null}
SELECT
event_timestamp,
DATE_TRUNC('hour', event_timestamp) AS hour_start
FROM company_data."123"
WHERE event_timestamp > CURRENT_TIMESTAMP - INTERVAL '24' HOUR
```
### Current timestamp functions
| Function | Returns |
| ------------------- | ----------------- |
| `CURRENT_DATE` | Current date |
| `CURRENT_TIMESTAMP` | Current timestamp |
***
## Complex types
NQL supports three complex types for structured data: arrays, structs, and maps.
***
## ARRAY
Ordered collection of elements of the same type.
### Array literal
```sql theme={null}
SELECT ARRAY('value1', 'value2', 'value3') AS my_array
```
### Array type declaration
```sql theme={null}
CAST(column AS ARRAY)
CAST(column AS ARRAY)
CAST(column AS ARRAY>)
```
### Accessing array elements
Use zero-based indexing with bracket notation:
```sql theme={null}
SELECT
identifiers[0] AS first_id,
identifiers[1] AS second_id,
identifiers[0].type AS first_id_type
FROM company_data."123"
```
### Array functions
| Function | Description |
| -------------------------- | -------------------------- |
| `SIZE(array)` | Returns number of elements |
| `ELEMENT_AT(array, index)` | Returns element at index |
| `ARRAY_FLATTEN(array)` | Flattens nested arrays |
See [Functions Reference](/nql/functions/index) for complete array function documentation.
***
## STRUCT
Named collection of fields with potentially different types. Similar to a row or record.
### Struct literal
```sql theme={null}
SELECT NAMED_STRUCT(
'name', 'John Doe',
'age', 30,
'active', true
) AS person
```
### Struct type declaration
```sql theme={null}
CAST(column AS STRUCT)
CAST(column AS STRUCT<
user:STRUCT,
created_at:TIMESTAMP
>)
```
### Accessing struct fields
Use dot notation to access fields:
```sql theme={null}
SELECT
person.name,
person.age,
address.city
FROM company_data."123"
```
### Nested struct access
```sql theme={null}
SELECT
company_data."1".nested_field.deep_field.value
FROM company_data."1"
```
***
## MAP
Collection of key-value pairs. Keys must be of a single type, as must values.
### Map type declaration
```sql theme={null}
CAST(column AS MAP)
CAST(column AS MAP)
CAST(column AS MAP>)
```
### Accessing map values
Use `ELEMENT_AT` to retrieve values by key:
```sql theme={null}
SELECT
ELEMENT_AT(properties, 'color') AS color,
ELEMENT_AT(metadata, 'source') AS source
FROM company_data."123"
```
***
## Nested types
Complex types can be nested to represent hierarchical data structures.
### Array of structs
```sql theme={null}
CAST(column AS ARRAY>)
```
Access nested struct fields within arrays:
```sql theme={null}
SELECT
identifiers[0].type AS first_type,
identifiers[0].value AS first_value
FROM company_data."123"
```
### Map with struct values
```sql theme={null}
CAST(column AS MAP>)
```
### Deeply nested structures
```sql theme={null}
CAST(column AS STRUCT<
field1:STRING,
field2:MAP>,
field3:ARRAY