# 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** Narrative sign up page showing email field and SSO options for Google, GitHub, and 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. Check your email confirmation page with quick links to open Gmail, Yahoo, or Outlook Open your email and find the message from Narrative. Click **Continue** to verify your email address. Magic link email with Continue button to verify your account 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**. Profile completion form with fields for name and company Review the Terms of Service. Check the box to confirm you've read and agree to the terms, then click **Agree**. Terms of Service agreement page You'll see a success message confirming your account was created. Click **Login** to access Narrative I/O. Success page confirming account registration 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.