diff --git a/tutorials/signals-google-adk-agent/adk-architecture.png b/tutorials/signals-google-adk-agent/adk-architecture.png
deleted file mode 100644
index 91e4c2455..000000000
Binary files a/tutorials/signals-google-adk-agent/adk-architecture.png and /dev/null differ
diff --git a/tutorials/signals-google-adk-agent/build-agent.md b/tutorials/signals-google-adk-agent/build-agent.md
index 406859696..c93dceae1 100644
--- a/tutorials/signals-google-adk-agent/build-agent.md
+++ b/tutorials/signals-google-adk-agent/build-agent.md
@@ -2,16 +2,21 @@
title: "Connect Signals to the Google ADK agent"
sidebar_label: "Connect Signals and agent"
position: 5
-description: "Fetch Signals attributes from Python, build a Google ADK agent that injects them into its system instruction each turn, and forward the Snowplow session ID from the React frontend through CopilotKit."
-keywords: ["Google ADK", "CopilotKit", "AG-UI", "Signals", "LlmAgent", "before_model_callback"]
-date: "2026-04-17"
+description: "Fetch Signals profile attributes and the agentic context narrative from Python, inject both into a Google ADK agent's system instruction each turn, and forward the Snowplow session ID from the React front-end through CopilotKit."
+keywords: ["Google ADK", "CopilotKit", "AG-UI", "Signals", "agentic context", "LlmAgent", "before_model_callback"]
+date: "2026-08-04"
---
The next step is to connect Signals to the Google ADK agent, and forward the Snowplow session ID through CopilotKit so the agent knows which user to fetch context for.
## Fetch Signals context from Python
-Create a module inside the `agent/` directory that wraps the [Snowplow Signals Python SDK](/docs/signals/attributes/attribute-groups/):
+Your agent will fetch two kinds of context from Signals on every turn, using the same session ID for both:
+
+* Profile attributes, from the [service](/docs/signals/concepts/#services): computed aggregates that you format into an instruction 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 a module inside the `agent/` directory that fetches both, wrapping the [Snowplow Signals Python SDK](/docs/signals/connection/):
```python
# agent/signals_context.py
@@ -46,51 +51,126 @@ def _get_signals_client() -> Optional[Signals]:
)
return _signals_client
-def _format_attributes(attributes: dict) -> str:
- """Render a Signals attribute dict as a markdown block for the system prompt."""
- lines = [f"- {key}: {value}" for key, value in attributes.items()]
+def _get_profile_section(client: Signals, domain_session_id: str) -> str:
+ """Profile attributes: computed aggregates, served by the Signals service."""
+ service_name = os.getenv("SNOWPLOW_SIGNALS_SERVICE_NAME")
+ if not service_name:
+ return ""
+
+ # get_service_attributes() returns a plain dict[str, Any], with a key for
+ # every attribute in the service. Attributes the session hasn't produced a
+ # value for yet come back as None, so drop them rather than telling the
+ # model "page_views_count: None".
+ attributes = client.get_service_attributes(
+ name=service_name,
+ attribute_key="domain_sessionid",
+ identifier=domain_session_id,
+ )
+ known = {key: value for key, value in attributes.items() if value is not None}
+ if not known:
+ return ""
+
+ lines = [f"- {key}: {value}" for key, value in known.items()]
return "\n".join(
[
- "## 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,
]
)
-def get_signals_context(domain_session_id: str) -> str:
- """Fetch attributes for the given session and return a markdown context block.
+def _get_activity_section(client: Signals, domain_session_id: str) -> str:
+ """Session activity: LLM-ready narrative, served by the agentic context."""
+ context_name = os.getenv("SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME")
+ if not context_name:
+ return ""
+
+ # format="narrative" returns a ready-to-use string, not a response object
+ narrative = client.get_agentic_context(
+ name=context_name,
+ identifier=domain_session_id,
+ format="narrative",
+ )
+ if not narrative:
+ return ""
+
+ return "\n".join(
+ [
+ "## Recent session activity (Snowplow Signals agentic context)",
+ narrative,
+ ]
+ )
+
+def get_signals_sections(domain_session_id: str) -> list[str]:
+ """Fetch both kinds of Signals context as instruction sections.
- Returns an empty string when Signals is not configured, the session ID is
- empty, or the fetch fails — the agent then degrades gracefully to its base
- instruction.
+ Each fetch is independent, so one section can appear without the other.
+ Returns an empty list when Signals is not configured, the session ID is
+ empty, or both fetches come back empty — the agent then degrades
+ gracefully to its base instruction.
"""
client = _get_signals_client()
if client is None or not domain_session_id:
- return ""
+ return []
+
+ sections: list[str] = []
+ for label, fetch in (
+ ("profile attributes", _get_profile_section),
+ ("session activity", _get_activity_section),
+ ):
+ try:
+ section = fetch(client, domain_session_id)
+ except Exception as exc: # noqa: BLE001
+ print(f"[signals-context] {label} fetch failed: {exc}")
+ continue
+ if section:
+ sections.append(section)
+
+ return sections
+```
- service_name = os.getenv("SNOWPLOW_SIGNALS_SERVICE_NAME")
- if not service_name:
- return ""
+The profile fetch returns raw attribute values from the service, which `_get_profile_section()` formats into a Markdown list:
+
+```json
+{
+ "first_event_timestamp": "2026-07-30T14:02:53.477Z",
+ "last_event_timestamp": "2026-07-30T14:03:12.840Z",
+ "page_views_count": 6,
+ "unique_pages_viewed": [
+ "http://localhost:3000/",
+ "http://localhost:3000/products/electronics",
+ "http://localhost:3000/products/clothing/linen-overshirt",
+ "http://localhost:3000/products/electronics/wireless-headphones",
+ "http://localhost:3000/pricing"
+ ]
+}
+```
- try:
- # get_service_attributes() returns a plain dict[str, Any]
- attributes = client.get_service_attributes(
- name=service_name,
- attribute_key="domain_sessionid",
- identifier=domain_session_id,
- )
- if not attributes:
- return ""
- return _format_attributes(attributes)
- except Exception as exc: # noqa: BLE001
- print(f"[signals-context] failed to fetch attributes: {exc}")
- return ""
+`unique_pages_viewed` holds full URLs, because the Basic Web template builds it from the `page_url` atomic property. Your agentic context selects `page_urlpath` instead, so the same six page views appear there as paths. Both describe the same browsing at different levels of detail.
+
+The activity fetch needs no formatting. With `format="narrative"`, `get_agentic_context()` returns the prompt you configured, followed by a block delimited by `[START CONTEXT]` and `[END CONTEXT]`. For the same six-page browsing session, that looks like:
+
+```text
+You are a helpful assistant for Signal Shop. Use this recent activity to understand what the user is exploring right now, and tailor your answers to it.
+[START CONTEXT]
+59 seconds on the current page. Session started 78 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'}
+3, page_view, /products/electronics, {page_title: 'Electronics | Signal Shop'}
+7, page_view, /products/electronics/wireless-headphones, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
+11, page_view, /products/clothing/linen-overshirt, {page_title: 'Linen Overshirt | Signal Shop'}
+16, page_view, /products/electronics/wireless-headphones, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
+19, page_view, /pricing, {page_title: 'Pricing | Signal Shop'}
+[END CONTEXT]
```
+Signals generates the opening summary and the event table from the events you selected when defining the agentic context.
+
## Build the agent
-Replace the scaffold's `agent/main.py` with one that reads the Snowplow session ID from state and calls `get_signals_context` on every turn.
+Replace the scaffold's `agent/main.py` with one that reads the Snowplow session ID from state and calls `get_signals_sections` on every turn.
```python
# agent/main.py
@@ -111,7 +191,7 @@ from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from starlette.requests import Request
-from signals_context import get_signals_context
+from signals_context import get_signals_sections
load_dotenv()
@@ -125,14 +205,16 @@ log = logging.getLogger("signals_agent")
BASE_INSTRUCTION = """You are a helpful assistant for Signal Shop.
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."""
def inject_signals_context(
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> Optional[LlmResponse]:
- """Fetch Signals attributes each turn and append them to the system instruction."""
+ """Fetch both kinds of Signals context each turn and append them to the instruction."""
state_dict = callback_context.state.to_dict()
domain_session_id = state_dict.get("snowplowDomainSessionId", "")
log.info(
@@ -144,17 +226,17 @@ def inject_signals_context(
if not domain_session_id:
return None
- signals_block = get_signals_context(domain_session_id)
- if not signals_block:
+ sections = get_signals_sections(domain_session_id)
+ if not sections:
return None
- log.info("injecting signals block (%d chars)", len(signals_block))
- llm_request.append_instructions([signals_block])
+ log.info("injecting %d signals section(s)", len(sections))
+ llm_request.append_instructions(sections)
return None
root_agent = LlmAgent(
name="SignalsAgent",
- model="gemini-3-flash-preview",
+ model="gemini-2.5-flash",
instruction=BASE_INSTRUCTION,
before_model_callback=inject_signals_context,
)
@@ -202,31 +284,43 @@ if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=port)
```
-The `inject_signals_context` method is the `before_model_callback`. It runs every turn, just before the LLM is called. It reads the session ID from state, fetches fresh Signals attributes, and appends them to the system instruction. Because it runs on every turn, the context reflects the user's latest behavior, including pages they've visited during the conversation.
+The `inject_signals_context` function is the `before_model_callback`. It runs every turn, just before ADK calls the LLM. It reads the session ID from state, fetches both kinds of fresh Signals context, and appends them to the system instruction. Because it runs on every turn, the context reflects the user's most recent behavior, including pages they've visited during the conversation.
-Within `inject_signals_context`, `append_instructions` is an ADK `LlmRequest` method that adds content to the system instruction for this turn only. It doesn't mutate the agent's `instruction` field permanently; every turn starts fresh and gets the latest Signals data appended.
+The resulting system instruction for a turn where Signals has both kinds of context looks like:
-The `extract_snowplow_session` method is an `ag_ui_adk` hook that runs before the ADK session is created. It reads from `input_data.forwarded_props`, which is populated by CopilotKit's `properties` prop. It returns a dictionary that gets merged into the ADK session state. Because `forwarded_props` is sent on every AG-UI request, the session ID is available for each chat message.
-
-The resulting system prompt for a turn where Signals has data looks like:
-
-```
+```text
You are a helpful assistant for Signal Shop.
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.
-
-## 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: ["http://localhost:3000/products/electronics",
- "http://localhost:3000/products/electronics/wireless-headphones"]
-- first_event_timestamp: 2026-04-09T14:23:01.000Z
-- last_event_timestamp: 2026-04-09T14:41:03.000Z
+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.
+
+## User profile (Snowplow Signals attributes)
+Computed attributes describing the current user's session so far:
+- first_event_timestamp: 2026-07-30T14:02:53.477Z
+- last_event_timestamp: 2026-07-30T14:03:12.840Z
+- page_views_count: 6
+- unique_pages_viewed: ['http://localhost:3000/', 'http://localhost:3000/products/electronics', 'http://localhost:3000/products/clothing/linen-overshirt', 'http://localhost:3000/products/electronics/wireless-headphones', 'http://localhost:3000/pricing']
+
+## Recent session activity (Snowplow Signals agentic context)
+You are a helpful assistant for Signal Shop. Use this recent activity to understand what the user is exploring right now, and tailor your answers to it.
+[START CONTEXT]
+89 seconds on the current page. Session started 109 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'}
+3, page_view, /products/electronics, {page_title: 'Electronics | Signal Shop'}
+7, page_view, /products/electronics/wireless-headphones, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
+11, page_view, /products/clothing/linen-overshirt, {page_title: 'Linen Overshirt | Signal Shop'}
+16, page_view, /products/electronics/wireless-headphones, {page_title: 'Aurora Wireless Headphones | Signal Shop'}
+19, page_view, /pricing, {page_title: 'Pricing | Signal Shop'}
+[END CONTEXT]
```
-Gemini treats the Signals block as factual context about the current user. No special prompting is needed beyond including it: the model naturally incorporates the context when formulating responses.
+The agentic context's own `prompt` instructions arrive at the top of the narrative, ahead of `[START CONTEXT]`, so you can steer the agent from your Signals configuration as well as from `BASE_INSTRUCTION`.
## Forward the session ID from front-end to agent
@@ -317,9 +411,7 @@ export const POST = async (req: NextRequest) => {
This is a thin proxy. `CopilotRuntime` handles the AG-UI envelope including state sync, tool calls, and readables. `HttpAgent` relays every request to your FastAPI service at `http://localhost:8000/`, where `ADKAgent` decodes AG-UI messages back into ADK sessions. In the scaffold this lives in a Next.js API route.
-## Try it out
-
-Your application is now ready to try out.
+## Try it out and verify Signals context
Run the stack:
@@ -327,27 +419,18 @@ Run the stack:
npm run dev
```
-Make sure you've replaced the placeholder values in `.env` with real credentials.
+Open [http://localhost:3000](http://localhost:3000) and browse a handful of pages, revisiting one product page after looking at another. Then open the CopilotKit sidebar and ask a general question, such as "Can you help me understand your pricing?".
-Open [http://localhost:3000](http://localhost:3000) and browse around for a few minutes. Visit different pages, click some links. The Browser tracker will record these interactions, and Signals will compute your attributes in real time.
+A grounded response names what you just did: for the six-page session above, it picks out the return to the wireless headphones and the pricing page, rather than listing the plans generically.
-Open the CopilotKit sidebar and ask a general question. If your Signals service is returning attributes for your session, the agent's response will reference what you've been doing.
-
-## Verify Signals context
-
-You can verify that the app is receiving the Signals context by checking the agent logs. When you run `npm run dev`, watch the `[agent]` prefix. You should see the session ID present from the first turn:
+The `[agent]` lines in your `npm run dev` output confirm both fetches landed:
+```text
+[agent] injecting 2 signals section(s)
```
-[agent] before_model_callback: state_keys=['snowplowDomainSessionId', '_ag_ui_thread_id', '_ag_ui_app_name', '_ag_ui_user_id'] snowplow_session_id='472f97c1-eec1-45fe-b081-3ff695c30415'
-[agent] injecting signals block (287 chars)
-```
-If the session ID is missing, the Snowplow tracker never initialized. Check:
-* The browser's Network tab for requests to your Collector
-* That your Collector URL environment variable is set and exposed to the browser (`NEXT_PUBLIC_` prefix in the scaffold)
-* That `SnowplowProvider` wraps `CopilotProvider` in `layout.tsx`
+Two sections means the profile attributes and the activity narrative both arrived.
+
+On the Signals side, check in Console that your attribute group, service, and agentic context are published under the names you set in `.env`, or ask the [Snowplow Assistant](/docs/llms-support/console-agent/) to confirm them for you.
-If the Signals block is empty but the session ID is present, check:
-* Is your attribute group published?
-* Did you create a service with the right name?
-* Have you been browsing for long enough for events to flow through the pipeline?
+To confirm the tracker and Signals agree on your session, use the [Snowplow Inspector browser extension](/docs/testing/snowplow-inspector/signals-integration/). Connect it to Console, add your API credentials in the extension options, then browse the app with the extension open and compare its **Attributes** tab with its **Events** tab.
diff --git a/tutorials/signals-google-adk-agent/conclusion.md b/tutorials/signals-google-adk-agent/conclusion.md
index bb24f5ec0..634d2ff4f 100644
--- a/tutorials/signals-google-adk-agent/conclusion.md
+++ b/tutorials/signals-google-adk-agent/conclusion.md
@@ -2,9 +2,9 @@
title: "Conclusion and next steps"
sidebar_label: "Conclusion"
position: 6
-description: "Run the full stack, debug common issues, and explore extensions like interventions, richer attributes, generative UI, multi-agent routing, and Vertex AI deployment."
-keywords: ["debugging", "interventions", "generative UI", "multi-agent", "Vertex AI Agent Engine"]
-date: "2026-04-17"
+description: "Review what you built with Signals, Google ADK, and CopilotKit, and explore extensions like interventions, richer attributes, generative UI, multi-agent routing, and Vertex AI deployment."
+keywords: ["Signals", "interventions", "agentic context", "generative UI", "multi-agent", "Vertex AI Agent Engine"]
+date: "2026-08-04"
---
In this tutorial, you've built a Next.js app with a Google ADK agent that uses Snowplow Signals to deliver personalized, context-aware responses based on live user behavior.
@@ -14,42 +14,20 @@ 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 buffering the session's recent events as an LLM-ready narrative
* A CopilotKit sidebar that passes the Snowplow session ID with every request
-* A Google ADK agent that fetches and injects those attributes into its system prompt
+* A Google ADK agent whose `before_model_callback` fetches both kinds of context and injects them into its system instruction each turn
-Here are some next steps ideas for extending what you've built.
+## Next steps
-## Interventions
+Here are some ways to extend what you've built:
-Signals also includes [interventions](/docs/signals/concepts/#interventions). These are push-based triggers that fire when a user crosses a behavioral threshold.
-
-Rather than waiting for the user to open the chat, you can proactively provide context to your agent when something significant happens. For example, a user who has viewed pricing five times without converting. Combine this with CopilotKit's `useCopilotChatSuggestions` to surface contextual prompts in the sidebar.
-
-Try exploring how you could use interventions within this application.
-
-## Richer attributes
-
-The Basic Web attribute group template covers session-level behavior. For further personalization, try extending your attribute group with:
-- **Product affinity**: count of views per product category to understand user interest
-- **Engagement score**: a computed signal of session depth and intent
-- **Return visitor flag**: whether this is a new or returning user
-- **Funnel stage**: where the user is in a defined conversion journey
-
-## Generative UI
-
-CopilotKit's `useCopilotAction` lets the agent render React components instead of plain text. Combine it with Signals attributes to build contextual UI. For example, a pricing comparison card that only renders for users Signals has flagged as high-intent enterprise browsers.
-
-## Multi-agent routing
-
-Google ADK supports `SequentialAgent`, `ParallelAgent`, and `LoopAgent` for composing multi-step workflows. You can route users to different sub-agents based on their Signals profile. For example, a support specialist for confused new users, or a technical deep-dive agent for developers in the docs.
-
-## Multi-dimensional context
-
-The injection happens in a plain Python callback, so you can pull context from as many sources as you need. Combine Signals real-time attributes with batch data from your warehouse. This could include attributes such as user profile data, CRM attributes, or product usage history.
-
-## Deploy to Vertex AI Agent Engine
-
-The ADK integration supports deployment to [Vertex AI Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/overview), Google's managed runtime for ADK agents. Set `GOOGLE_GENAI_USE_VERTEXAI=True`, swap your API key for a service account, and deploy.
+* **Interventions**: Signals also includes [interventions](/docs/signals/concepts/#interventions), push-based triggers that fire when a user crosses a behavioral threshold. Rather than waiting for the user to open the chat, you can proactively provide context to your agent when something significant happens, such as a user who has viewed pricing five times without converting. Combine this with CopilotKit's `useCopilotChatSuggestions` to surface contextual prompts in the sidebar.
+* **Richer attributes**: the Basic Web template covers session-level behavior. Extend your attribute group with product affinity (views per category), an engagement score, a return visitor flag, or a funnel stage.
+* **Generative UI**: CopilotKit's `useCopilotAction` lets the agent render React components instead of plain text. Combine it with Signals attributes to build contextual UI, such as a pricing comparison card that renders only for users Signals has flagged as high-intent enterprise browsers.
+* **Multi-agent routing**: Google ADK supports `SequentialAgent`, `ParallelAgent`, and `LoopAgent` for composing multi-step workflows, so you can route users to different sub-agents based on their Signals profile.
+* **Multi-dimensional context**: the injection happens in a plain Python callback, so you can pull context from as many sources as you need. Combine Signals real-time attributes with batch data from your warehouse, such as user profile data, CRM attributes, or product usage history.
+* **Deploy to Vertex AI Agent Engine**: the ADK integration supports deployment to [Vertex AI Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/overview), Google's managed runtime for ADK agents. Set `GOOGLE_GENAI_USE_VERTEXAI=True`, swap your API key for a service account, and deploy.
## Other Signals tutorials
@@ -58,4 +36,4 @@ Check out these other Signals tutorials and solution accelerators for inspiratio
* [Set up Signals for real-time calculation](/tutorials/signals-quickstart/start)
* [Implement real-time interventions using Signals](/tutorials/signals-interventions/start)
* [Score prospects in real time using Signals and ML](/tutorials/signals-ml-prospect-scoring/intro)
-* [Use AWS BedRock to supplement Signals with persistent user context](/tutorials/signals-agentic-accelerator/intro)
+* [Build a Signals-powered AI agent with AWS Bedrock AgentCore](/tutorials/signals-agentic-accelerator/intro)
diff --git a/tutorials/signals-google-adk-agent/configure-signals.md b/tutorials/signals-google-adk-agent/configure-signals.md
index 0d2e8e3ac..ad5c82a96 100644
--- a/tutorials/signals-google-adk-agent/configure-signals.md
+++ b/tutorials/signals-google-adk-agent/configure-signals.md
@@ -1,43 +1,69 @@
---
-title: "Configure Snowplow Signals"
+title: "Configure Signals attributes and an agentic context"
sidebar_label: "Configure Signals"
position: 4
-description: "Create an attribute group from the Basic Web template, publish it, and expose the attributes through a Signals service for lookup by session ID."
-keywords: ["Signals", "attribute group", "service", "Basic Web", "domain_sessionid"]
-date: "2026-04-17"
+description: "Create an attribute group, a service, and an agentic context to serve real-time profile attributes and session activity to your Google ADK agent."
+keywords: ["Signals", "attribute group", "service", "agentic context", "Basic Web", "domain_sessionid"]
+date: "2026-08-04"
---
-The next step is to define the user attributes you want to compute. You'll do this within [Snowplow Console](https://console.snowplowanalytics.com).
+```mdx-code-block
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+```
+
+The next step is to define the real-time context you want Signals to serve. You'll set up two complementary resources:
+
+* An [attribute group](/docs/signals/concepts/#attribute-groups) and [service](/docs/signals/concepts/#services) that compute and serve profile attributes: aggregate metrics that describe the session, such as how many pages the user has viewed
+* An [agentic context](/docs/signals/agentic-contexts/) that captures recent session activity: the user's most recent events, readable as an LLM-ready narrative
+
+The two work together. Attributes describe the session in aggregate, while the agentic context records what the user has just been doing, event by event. Both are scoped to the same `domain_sessionid` attribute key, so your agent can fetch both with the session ID it already reads from the tracker cookie.
+
+## Ask the Snowplow Assistant
+
+You can create all three resources by asking an AI assistant: the [Snowplow Assistant](/docs/llms-support/console-agent/) in Console, or your own assistant connected to the [Snowplow MCP server](/docs/llms-support/snowplow-mcp/), which you can install with `npx plugins add snowplow/skills`. Paste this prompt:
+
+```text
+In Signals, create and publish an attribute group from the Basic Web template with
+domain_sessionid as the attribute key, and a service called web_agent_context that serves
+it. Then create and publish an agentic context called web_agent_activity, also keyed on
+domain_sessionid, buffering the last 50 page_view events from the last 30 minutes and
+keeping the event_name, page_urlpath, and page_title properties. Set its prompt to: "You
+are a helpful assistant for Signal Shop. Use this recent activity to understand what the
+user is exploring right now, and tailor your answers to it."
+```
+
+Use these exact names, because they match the `SNOWPLOW_SIGNALS_SERVICE_NAME` and `SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME` variables you configured in your `.env` file. To set the same resources up by hand instead, work through the sections below.
## Create a Basic Web attribute group
-Use one of Signals' built-in [attribute group](/docs/signals/concepts/#attribute-groups) templates to define attributes. Use the `domain_sessionid` as attribute key to compute session-level attributes.
+Use one of Signals' built-in [attribute group](/docs/signals/concepts/#attribute-groups) templates to define attributes, with `domain_sessionid` as the attribute key so the attributes are scoped to a session:
-1. In [Console](https://console.snowplowanalytics.com), navigate to **Signals** > **Attribute Groups**
-2. Click **Create attribute group** and choose **Basic Web**
-3. Set the **Attribute Key** to `domain_sessionid`
+1. In [Console](https://console.snowplowanalytics.com), navigate to **Signals** > **Attribute groups**
+2. Click **Create attribute group**, then click **Use** on the **Basic Web** card
+3. Set the **Attribute key** to `domain_sessionid`
-The Basic Web template includes these attributes:
+Applying the template fills in the name `basic_web` and its four attributes. Rename it if you like, and leave **Source** as **Stream** so Signals computes from the event stream in real time.
-| Attribute | Description |
-| ----------------------- | ------------------------------------------ |
-| `page_views_count` | Total number of page views in the session |
-| `unique_pages_viewed` | List of unique URLs visited in the session |
-| `first_event_timestamp` | When the session started |
-| `last_event_timestamp` | When the most recent event was recorded |
+| Attribute | Type | Aggregation | Property | Description |
+| ----------------------- | ------------- | ------------- | ----------------------- | ---------------------------------------------- |
+| `page_views_count` | `int32` | `counter` | None | Total number of page views in the session |
+| `unique_pages_viewed` | `string_list` | `unique_list` | Atomic `page_url` | Full URLs of the pages visited in the session |
+| `first_event_timestamp` | `string` | `first` | Atomic `derived_tstamp` | When the session started |
+| `last_event_timestamp` | `string` | `last` | Atomic `derived_tstamp` | When the most recent event was recorded |
-Test your attribute group by clicking **Run Preview** before saving, to verify it's computing correctly based on recent events in your pipeline. This runs a query against your event data in your data warehouse and shows the computed attributes for recent sessions.
+Note the property behind `unique_pages_viewed`: the template reads `page_url`, so the values are full URLs rather than paths. The agentic context you define later reads `page_urlpath` instead, which is worth knowing when you compare the two in your agent's instruction.
-Click **Create attribute group** when you're happy with the attribute group.
+Click **Create attribute group** when you're happy with it.
## Publish the attribute group
-[Attribute groups](/docs/signals/concepts/#attribute-groups) need to be published before Signals will start computing:
+You need to publish an [attribute group](/docs/signals/concepts/#attribute-groups) before Signals starts computing. A new group starts as `v1 (Not published)`:
1. Open your attribute group and click **Publish**
-2. Confirm the publish to deploy the computation logic to the pipeline
+2. Confirm the publish to deploy the group and its four attributes to the Profiles Store
-Once published, Signals starts computing attributes for each user session as events arrive.
+The version label changes to `v1 (Published)`, and Signals starts computing attributes for each user session as events arrive.
## Create a service
@@ -45,30 +71,132 @@ A [service](/docs/signals/concepts/#services) provides a pull-based API endpoint
Services allow you to combine multiple attribute groups if needed, but for this tutorial, use just the one you created in the last step.
-Use this exact service name. It's the same as the `SNOWPLOW_SIGNALS_SERVICE_NAME` environment variable you configured in your `.env` file.
+Use this exact service name. It's the same as the `SNOWPLOW_SIGNALS_SERVICE_NAME` environment variable you configured in your `.env` file. Service names take letters, numbers, and underscores only:
1. Navigate to **Signals** > **Services**
2. Click **Create service**
3. Configure:
- **Name**: `web_agent_context`
- - **Attribute groups**: Select the attribute group you just published
+ - **Attribute groups**: select the attribute group you just published
4. Click **Create service**
+The **Attribute groups** picker only lists published attribute groups, so publishing first isn't optional. If your group is still a draft, the picker reports no options.
+
The service returns attributes for a given session ID in this format:
```json
{
- "page_views_count": 12,
+ "first_event_timestamp": "2026-07-30T14:02:53.477Z",
+ "last_event_timestamp": "2026-07-30T14:03:12.840Z",
+ "page_views_count": 6,
"unique_pages_viewed": [
"http://localhost:3000/",
"http://localhost:3000/products/electronics",
+ "http://localhost:3000/products/clothing/linen-overshirt",
"http://localhost:3000/products/electronics/wireless-headphones",
- "http://localhost:3000/products/electronics/smart-speaker-mini",
"http://localhost:3000/pricing"
- ],
- "first_event_timestamp": "2026-04-09T14:23:01.000Z",
- "last_event_timestamp": "2026-04-09T14:41:03.000Z"
+ ]
}
```
-The `unique_pages_viewed` attribute is a list of URLs the user has visited during the session, showing the agent which pages they have been browsing.
+In this six-page session, six page views produce five entries in `unique_pages_viewed`, because one product page was visited twice.
+
+## Create an agentic context
+
+The service you just created serves computed aggregates. To also give your agent a chronological record of what the user is doing, [define an agentic context](/docs/signals/agentic-contexts/): a rolling record of the user's recent events that Signals can return as a plain-language narrative, ready to drop into your agent's instruction.
+
+For this app, capture page view events, keeping three properties from each one:
+
+| Property | Purpose |
+| -------------- | ---------------------------------------------------------------- |
+| `event_name` | Populates the event column of the narrative table |
+| `page_urlpath` | Populates the URL column of the narrative table |
+| `page_title` | Extra detail, included in the narrative's `event_context` column |
+
+The `event_name` and `page_urlpath` atomic properties feed the narrative's dedicated columns. Any other property you select appears in its `event_context` column.
+
+Your app also tracks page pings and link clicks. Leave the page pings out: the buffer holds a limited number of events, and heartbeat pings would crowd out the meaningful activity. Link clicks are worth adding once you've seen the narrative working.
+
+Use this exact agentic context name. It's the same as the `SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME` environment variable you configured in your `.env` file.
+
+
reads the session ID from the tracker cookie"]
+ end
+
+ subgraph react["React app, port 3000"]
+ proxy["CopilotRuntime proxy
/api/copilotkit"]
+ end
+
+ subgraph fastapi["FastAPI server, port 8000"]
+ adk["ADKAgent middleware"]
+ callback["before_model_callback"]
+ llm["LlmAgent, Gemini"]
+ end
-
+ subgraph snowplow["Snowplow"]
+ collector["Collector"]
+ enriched["Enriched events"]
+ subgraph signals["Signals"]
+ service["Service
profile attributes"]
+ agentic["Agentic context
session activity"]
+ end
+ end
+
+ tracker -->|"page views, page pings, link clicks"| collector
+ collector --> enriched
+ enriched --> service
+ enriched --> agentic
+
+ sidebar -->|"session ID in properties"| proxy
+ proxy -->|"HttpAgent, AG-UI, session ID in forwarded_props"| adk
+ adk --> callback
+ callback <-->|"get_service_attributes"| service
+ callback <-->|"get_agentic_context, narrative"| agentic
+ callback -->|"append_instructions, both sections"| llm
+```
## Prerequisites
-- A Snowplow account with [Signals deployed](/docs/signals/connection/)
+To follow this tutorial, you'll need:
+
+- A Snowplow account and pipeline with [Signals enabled](/docs/signals/setup/)
- Node.js 18+ and npm/pnpm
- Python 3.12+
- A Google AI Studio API key
- [AI Studio](https://aistudio.google.com/app/apikey)
- [Vertex AI](https://docs.cloud.google.com/agent-builder/agent-engine/quickstart-adk)
- Basic familiarity with React, Python, and TypeScript
+
+This tutorial should take approximately 45 minutes to complete.
diff --git a/tutorials/signals-google-adk-agent/project-setup.md b/tutorials/signals-google-adk-agent/project-setup.md
index ef429249c..0b676b833 100644
--- a/tutorials/signals-google-adk-agent/project-setup.md
+++ b/tutorials/signals-google-adk-agent/project-setup.md
@@ -2,9 +2,9 @@
title: "Set up the project"
sidebar_label: "Set up the project"
position: 2
-description: "Scaffold a Google ADK agent and React frontend with the CopilotKit starter, install Snowplow dependencies, and configure environment variables."
+description: "Scaffold a Google ADK agent and React front-end with the CopilotKit starter, install Snowplow dependencies, and configure environment variables."
keywords: ["CopilotKit", "Google ADK", "scaffold", "setup", "uv", "Next.js"]
-date: "2026-04-17"
+date: "2026-08-04"
---
CopilotKit ships a starter that scaffolds both the ADK Python back-end and a React front-end in one command.
@@ -23,15 +23,11 @@ This creates:
- `scripts/` — `setup-agent.sh` (runs `uv sync`) and `run-agent.sh`
- `package.json` scripts: `dev:ui`, `dev:agent`, and `dev` (runs both concurrently)
-The scaffold's Python side uses `uv` rather than pip. The agent virtual environment lives at `agent/.venv`.
+The scaffold's Python side uses `uv` rather than `pip`. The agent virtual environment lives at `agent/.venv`.
-:::note[React frameworks]
-The scaffold uses Next.js as a convenience. It provides a development server, routing, and an API endpoint to host the CopilotKit proxy.
+The scaffold uses Next.js as a convenience, for its development server, routing, and an API endpoint to host the CopilotKit proxy. CopilotKit itself is a React library that works with any framework.
-CopilotKit itself is a React library that works with any framework.
-:::
-
-## Install frontend and backend dependencies
+### Install front-end and back-end dependencies
```bash
npm install
@@ -40,7 +36,7 @@ npm install
Add the packages the tutorial needs on top of the scaffold:
```bash
-# Snowplow Browser SDK for the frontend
+# Snowplow Browser SDK for the front-end
npm install @snowplow/browser-tracker @snowplow/browser-plugin-link-click-tracking
# Snowplow Signals Python SDK for the agent
@@ -63,16 +59,19 @@ AGENT_URL=http://localhost:8000
NEXT_PUBLIC_SNOWPLOW_COLLECTOR_URL=https://your-collector-url.com
# Snowplow Signals (consumed server-side by the Python agent)
-SNOWPLOW_SIGNALS_BASE_URL=https://signals.snowplowanalytics.com
+SNOWPLOW_SIGNALS_BASE_URL=https://YOUR_ID.signals.snowplowanalytics.com
SNOWPLOW_SIGNALS_API_KEY=your-signals-api-key
SNOWPLOW_SIGNALS_API_KEY_ID=your-signals-api-key-id
SNOWPLOW_SIGNALS_ORG_ID=your-org-id
SNOWPLOW_SIGNALS_SERVICE_NAME=web_agent_context
+SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME=web_agent_activity
```
-You'll find your Snowplow Collector URL in [Snowplow Console](https://console.snowplowanalytics.com) > **Pipelines** > select your pipeline > **Configuration** > **Collector Domains**.
+`SNOWPLOW_SIGNALS_SERVICE_NAME` and `SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME` name the two Signals resources you'll create in a later step. Keep these values as they are, and use the same names when you create them.
+
+You'll find your Snowplow Collector URL in [Console](https://console.snowplowanalytics.com) > **Pipelines** > select your pipeline > **Configuration** > **Collector Domains**.
-You'll find your Signals base URL (also known as Profiles API URL) and credentials in [Snowplow Console](https://console.snowplowanalytics.com) > **Signals** > **Overview**.
+You'll find your Signals base URL (also known as Profiles API URL) and credentials in [Console](https://console.snowplowanalytics.com) > **Signals** > **Overview**.
## Verify the scaffold runs
@@ -84,7 +83,7 @@ npm run dev
This uses `concurrently` to launch FastAPI on `http://localhost:8000` and the React development server on `http://localhost:3000`, with agent logs prefixed `[agent]` and UI logs prefixed `[ui]`. Open the front-end, open the CopilotKit sidebar, and confirm you can chat with the Gemini-backed agent. Exit the development server with `Ctrl+C` before moving on.
-## Strip the scaffold's demo content
+## Replace the scaffold's demo content
Remove the demo components and types the scaffold ships with:
@@ -92,11 +91,7 @@ Remove the demo components and types the scaffold ships with:
rm src/components/proverbs.tsx src/components/weather.tsx src/lib/types.ts
```
-After this, `src/app/page.tsx` will fail to compile because it still imports those deleted files.
-
-## Replace the scaffold's page contents
-
-The scaffold's `src/app/page.tsx` imports the demo components you just deleted, so it needs to be replaced. The chat sidebar is already mounted by `ChatShell` in `layout.tsx`, so the page itself only needs homepage content:
+The scaffold's `src/app/page.tsx` still imports those deleted files, so you need to replace it too. `ChatShell` in `layout.tsx` already mounts the chat sidebar, so the page itself only needs homepage content:
```tsx
// src/app/page.tsx
@@ -114,13 +109,13 @@ export default function HomePage() {
}
```
-The project should now compile again. Check it runs with `npm run dev`.
+The project should compile again. Check it runs with `npm run dev`.
## Add more pages
The scaffolded project starts with only a single page.
-To see Signals in action, you'll need multiple pages so there's meaningful browsing behavior to track. The easiest way to add pages is to ask an AI coding assistant to generate some.
+To see Signals in action, you'll need multiple pages so there's meaningful browsing behavior to track. To add pages, ask an AI coding assistant to generate some.
Example prompt:
@@ -128,7 +123,7 @@ Example prompt:
Create a simple multi-page ecommerce site for a fictional store called "Signal Shop". It should have:
- A homepage with featured products
- Category landing pages at `/products/[category]` for at least three categories (electronics, clothing, home)
-- Individual product detail pages at `/products/[category]/[slug]` (e.g. `/products/electronics/wireless-headphones`) with at least 8 distinct products spread across the categories
+- Individual product detail pages at `/products/[category]/[slug]` (e.g. `/products/electronics/wireless-headphones`) with at least eight distinct products spread across the categories
- A `/pricing` page
Every product must have a descriptive, human-readable slug — never a numeric ID. The product name, category, and price should be prominent in the page `