Skip to content
168 changes: 117 additions & 51 deletions tutorials/signals-ai-agent-context/build-ai-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
title: "Build the Signals AI integration using the Vercel AI SDK"
position: 5
sidebar_label: "Connect Signals and AI agent"
description: "Connect Snowplow Signals to a Vercel AI SDK agent by fetching user attributes and injecting them into the system prompt."
keywords: ["vercel ai sdk", "system prompt", "signals context", "ai agent", "streaming", "next.js api route"]
date: "2026-04-10"
description: "Connect Snowplow Signals to a Vercel AI SDK agent by fetching profile attributes and the agentic context narrative, and injecting both into the system prompt."
keywords: ["vercel ai sdk", "system prompt", "signals context", "agentic context", "ai agent", "streaming", "next.js api route"]
date: "2026-07-31"
---

The next step is to connect Signals to your AI agent, via the Vercel AI SDK.

## Fetch Signals context

Create the module that fetches and formats user attributes from Signals:
Your agent will fetch two kinds of context from Signals on every chat request, using the same session ID for both:

* Profile attributes, from the service: computed aggregates that you format into a prompt section yourself
* Recent session activity, from the [agentic context](/docs/signals/applications/agentic-contexts/): fetched with `format: "narrative"`, which returns a ready-made text block, so there's no formatting code to write

Create the module that fetches both:

```tsx
// lib/signals-context.ts
Expand All @@ -35,70 +40,134 @@ function getSignalsClient(): Signals | null {
return signalsInstance;
}

const SERVICE_NAME = "web-agent-context";
const SERVICE_NAME = "web_agent_context";
const AGENTIC_CONTEXT_NAME = "web_agent_activity";

// Profile attributes: computed aggregates, served by the Signals service
async function getProfileSection(
signals: Signals,
domainSessionId: string,
): Promise<string> {
const attributes = await signals.getServiceAttributes({
name: SERVICE_NAME,
attribute_key: "domain_sessionid",
identifier: domainSessionId,
});

// The service returns a key for every attribute it serves, with a null value
// until the session has produced data. Drop those, so a brand new session
// yields no profile section rather than a list of nulls.
const populated = Object.entries(attributes ?? {}).filter(
([, value]) => value !== null && value !== undefined,
);

if (populated.length === 0) {
return "";
}

function formatAttributes(attributes: Record<string, unknown>): string {
const lines = Object.entries(attributes).map(
const lines = populated.map(
([key, value]) => `- ${key}: ${JSON.stringify(value)}`,
);
return [
"## Real-Time User Context (Snowplow Signals)",
"The following attributes describe the current user's session behavior on this application:",
"## User profile (Snowplow Signals attributes)",
"Computed attributes describing the current user's session so far:",
...lines,
].join("\n");
}

// Session activity: LLM-ready narrative, served by the agentic context
async function getActivitySection(
signals: Signals,
domainSessionId: string,
): Promise<string> {
const narrative = await signals.getAgenticContext({
name: AGENTIC_CONTEXT_NAME,
identifier: domainSessionId,
format: "narrative",
});

if (!narrative) return "";

return [
"## Recent session activity (Snowplow Signals agentic context)",
narrative,
].join("\n");
}

export async function getSignalsContext(
domainSessionId: string,
): Promise<string> {
const signals = getSignalsClient();
if (!signals) return "";

try {
const attributes = await signals.getServiceAttributes({
name: SERVICE_NAME,
attribute_key: "domain_sessionid",
identifier: domainSessionId,
});

if (!attributes || Object.keys(attributes).length === 0) {
return "";
// Fetch both in parallel; if one fails, the other is still used
const [profile, activity] = await Promise.allSettled([
getProfileSection(signals, domainSessionId),
getActivitySection(signals, domainSessionId),
]);

const sections: string[] = [];
for (const result of [profile, activity]) {
if (result.status === "fulfilled" && result.value) {
sections.push(result.value);
} else if (result.status === "rejected") {
console.error("[signals-context] Signals fetch failed:", result.reason);
}

return formatAttributes(attributes);
} catch (error) {
console.error(
"[signals-context] Failed to fetch signals attributes:",
error,
);
return "";
}

return sections.join("\n\n");
}
```

The raw response format from the service, pulled using `signals.getServiceAttributes()`, looks like this:
The profile fetch returns raw attribute values from the service, which `getProfileSection()` formats into a Markdown list:

```json
{
"page_views_count": 12,
"unique_pages_viewed": 5,
"first_event_timestamp": "2026-04-09T14:23:01.000Z",
"last_event_timestamp": "2026-04-09T14:41:03.000Z"
"page_views_count": 5,
"unique_pages_viewed": [
"https://signal-shop.example.com/",
"https://signal-shop.example.com/products",
"https://signal-shop.example.com/products/3",
"https://signal-shop.example.com/products/7"
],
"first_event_timestamp": "2026-07-29T14:14:13.013Z",
"last_event_timestamp": "2026-07-29T14:16:13.976Z"
}
```

The `formatAttributes()` function converts that into a markdown section that can be appended to the agent's system prompt, for example:
Note the null filter in `getProfileSection()`. A service returns a key for every attribute it serves, valued `null` until the session has produced data:

```markdown
## Real-Time User Context (Snowplow Signals)
The following attributes describe the current user's session behavior on this application:
- page_views_count: 12
- unique_pages_viewed: 5
- first_event_timestamp: "2026-04-09T14:23:01.000Z"
- last_event_timestamp: "2026-04-09T14:41:03.000Z"
```json
{
"page_views_count": null,
"unique_pages_viewed": null,
"first_event_timestamp": null,
"last_event_timestamp": null
}
```

Filter those nulls out before formatting, so a session with no data yet contributes no profile section at all, rather than a list of nulls the model could read as facts about the user.

The activity fetch needs no formatting. With `format: "narrative"`, `getAgenticContext()` returns the prompt you configured, followed by a block delimited by `[START CONTEXT]` and `[END CONTEXT]`. For a five-page browsing session, that looks like:

```text
You are a helpful assistant for the Signal Shop web store. Use this recent activity to understand what the user is exploring right now, and tailor your answers to it.
[START CONTEXT]
10 seconds on the current page. Session started 132 seconds ago. Based on last 50 recorded events for the last 1800 seconds.
## Real-time user behaviour
Events are ordered from oldest to most recent.
seconds_since_start_of_session, event, url, event_context
0, page_view, /, {page_title: 'Signal Shop'}
25, page_view, /products, {page_title: 'All products | Signal Shop'}
56, page_view, /products/3, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
91, page_view, /products/7, {page_title: 'Linen Overshirt | Signal Shop'}
122, page_view, /products/3, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
[END CONTEXT]
```

If Signals isn't configured or a fetch fails, the `getSignalsContext()` function returns an empty string. The agent still works without the Signals context.
The opening summary and the event table are generated by Signals from the events you selected when defining the agentic context.

If Signals isn't configured or both fetches fail, the `getSignalsContext()` function returns an empty string. The agent still works without the Signals context.

## Build the agent

Expand All @@ -109,8 +178,10 @@ Create the function that constructs the system prompt with the Signals context a
const BASE_INSTRUCTIONS = `You are a helpful assistant for this application.
Help users understand features, answer questions, and guide them through their journey.

When you have real-time user context available (provided below), use it to personalize
your responses. Reference what the user has been looking at to give more relevant answers.`;
When real-time user context is available below, use it to personalize your responses.
The user profile section describes the session in aggregate. The recent session
activity section lists what the user has just been doing, oldest event first.
Reference what the user has been looking at to give more relevant answers.`;

export function createAgent(signalsContext?: string) {
const systemPrompt =
Expand All @@ -120,7 +191,7 @@ export function createAgent(signalsContext?: string) {
}
```

The model treats the Signals block as factual context about the current user. No special prompting is needed beyond including it: LLMs naturally incorporate provided context when formulating responses.
The agentic context's own `prompt` instructions arrive at the top of the narrative string, ahead of `[START CONTEXT]`, so you can steer the agent from your Signals configuration as well as from `BASE_INSTRUCTIONS`.

## Build the chat API route

Expand All @@ -144,7 +215,8 @@ export async function POST(request: Request) {
// Extract the Snowplow session ID passed from the frontend
const snowplowDomainSessionId = pageContext?.snowplowDomainSessionId || "";

// Fetch real-time user attributes from Signals
// Fetch real-time user context from Signals:
// profile attributes + the session activity narrative
let signalsContext = "";
if (snowplowDomainSessionId) {
signalsContext = await getSignalsContext(snowplowDomainSessionId);
Expand All @@ -164,13 +236,7 @@ export async function POST(request: Request) {
}
```

:::note[Model providers]
This example uses [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), which routes requests to any supported model provider with a single API key.

To use a different model, change the model string e.g. `gateway("anthropic/claude-sonnet-4.5")` or `gateway("google/gemini-2.5-pro")`.

See the [full list of supported models](https://vercel.com/ai-gateway/models). The Signals integration works identically regardless of which model you choose.
:::
This example uses [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), which routes requests to any supported model provider with a single API key. To use a different model, change the model string, for example `gateway("anthropic/claude-sonnet-4.5")` or `gateway("google/gemini-2.5-pro")`. See the [full list of supported models](https://vercel.com/ai-gateway/models).

## Build the chat frontend

Expand Down
9 changes: 5 additions & 4 deletions tutorials/signals-ai-agent-context/conclusion.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ title: "Conclusion and next steps"
position: 7
sidebar_label: "Conclusion"
description: "Summary of the Signals AI agent tutorial and ideas for extending it with richer attributes, interventions, and multi-dimensional context."
keywords: ["signals next steps", "ai agent extensions", "interventions", "attribute groups"]
date: "2026-04-10"
keywords: ["signals next steps", "ai agent extensions", "interventions", "attribute groups", "agentic context"]
date: "2026-07-31"
---

In this tutorial, you've built a Next.js AI agent that uses Snowplow Signals to deliver personalized, context-aware responses based on live user behavior.
Expand All @@ -14,8 +14,9 @@ Here's what you set up:
* Snowplow Browser tracker capturing page views, page pings, and link clicks
* A Signals attribute group computing real-time session-level attributes
* A Signals service exposing those attributes via API
* A Signals agentic context capturing the session's recent activity as an LLM-ready narrative
* A floating chat widget that passes the Snowplow session ID with every request
* A Vercel AI SDK agent that fetches and injects those attributes into its system prompt
* A Vercel AI SDK agent that fetches both and injects them into its system prompt

Here are some next steps ideas for extending what you've built.

Expand All @@ -40,7 +41,7 @@ Try exploring how you could use interventions within this application.

You can combine Signals real-time stream attributes with automatic ingestion of batch attributes, using data sources within your warehouse, to give your agent a complete picture of the user. This could include attributes such as user profile data, CRM attributes, or product usage history.

Try setting up a batch attribute group to ingest as part of your `web-agent-context` service.
Try setting up a batch attribute group to ingest as part of your `web_agent_context` service.

The Vercel AI SDK's system prompt is just a string: you can compose it from as many sources as you need.

Expand Down
Loading