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..192571e92 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-07-31" --- 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] ``` +The opening summary and the event table are generated by Signals 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,49 @@ 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` method is the `before_model_callback`. It runs every turn, just before the LLM is called. 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 latest 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. +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. It takes a list, so the profile and activity sections are passed straight through as separate instruction blocks. 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: +The resulting system instruction for a turn where Signals has both kinds of context 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`. + +This example uses `gemini-2.5-flash`. To use a different model, change the model string in the `LlmAgent` constructor, for example `model="gemini-2.5-pro"`. The Signals integration works the same way whether you run against AI Studio or Vertex AI. ## Forward the session ID from front-end to agent @@ -317,7 +417,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 +## Try it out and verify Signals context Your application is now ready to try out. @@ -329,25 +429,23 @@ 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 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. - -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. +Open [http://localhost:3000](http://localhost:3000) and browse around for a few minutes. Visit different pages, click some links. Revisit one product page after looking at another, so the activity narrative has a pattern worth noticing. The Browser tracker records these interactions, Signals computes your attributes in real time, and the agentic context buffers the events themselves. -## Verify Signals context +Open the CopilotKit sidebar and ask a general question. If Signals is returning context for your session, the agent's response will reference what you've been doing. -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: +You can verify that the agent is receiving both kinds of 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, one request per fetch, and both sections injected: +```text +[agent] before_model_callback: state_keys=['snowplowDomainSessionId', '_ag_ui_thread_id', '_ag_ui_app_name', '_ag_ui_user_id'] snowplow_session_id='3c18a125-024d-44bb-93db-b6aed895a5a3' +[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` +A count of `2 signals section(s)` means both the profile attributes and the activity narrative arrived. Because each fetch is handled independently, one section can appear without the other, so a failure to reach either won't take the agent down. + +For the session ID, confirm the tracker initialized: look for requests to your Collector in the browser's Network tab, check that your Collector URL environment variable is set and exposed to the browser (`NEXT_PUBLIC_` prefix in the scaffold), and confirm `SnowplowProvider` wraps `CopilotProvider` in `layout.tsx`. + +For the profile section, confirm in Console that your attribute group is published and that your service uses the name in `SNOWPLOW_SIGNALS_SERVICE_NAME`, then browse for long enough that events flow through the pipeline. A service returns a key for every attribute it serves, valued `null` until the session has produced one. Because `_get_profile_section()` drops those, a session Signals has no data for yet yields no profile section at all rather than a section full of nulls. + +For the activity section, confirm your agentic context is published under the name in `SNOWPLOW_SIGNALS_AGENTIC_CONTEXT_NAME`, that you selected the `page_view` event when you defined it, and that your events are newer than the `max_age_seconds` you configured. An agentic context with nothing buffered yet still returns a narrative, with `No events buffered.` in place of the event table, which means your configuration is good and Signals is waiting on qualifying events for this session. -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 and add your API credentials in the extension options, then browse the app with the extension open so it can pick up attribute keys from the events it observes. Its **Attributes** tab then requests the attribute values Signals holds for those keys, and comparing them with the **Events** tab separates a tracking problem from a Signals configuration problem. diff --git a/tutorials/signals-google-adk-agent/conclusion.md b/tutorials/signals-google-adk-agent/conclusion.md index bb24f5ec0..a389f1aff 100644 --- a/tutorials/signals-google-adk-agent/conclusion.md +++ b/tutorials/signals-google-adk-agent/conclusion.md @@ -3,8 +3,8 @@ 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" +keywords: ["debugging", "interventions", "agentic context", "generative UI", "multi-agent", "Vertex AI Agent Engine"] +date: "2026-07-31" --- 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,18 @@ 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 - -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 diff --git a/tutorials/signals-google-adk-agent/configure-signals.md b/tutorials/signals-google-adk-agent/configure-signals.md index 0d2e8e3ac..86fd8dbb4 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-07-31" --- -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 latest 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: +[Attribute groups](/docs/signals/concepts/#attribute-groups) need to be published before Signals will start 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,7 +71,7 @@ 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** @@ -54,21 +80,123 @@ Use this exact service name. It's the same as the `SNOWPLOW_SIGNALS_SERVICE_NAME - **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. + + + + +Navigate to **Signals** > **Agentic contexts** and click **Create context**. The form is one scrolling page with four sections: **Details**, **Prompt**, **Lookback Window**, and **Events and Properties**. + +Fill in **Details** and **Prompt**: + +| Field | Value | +| ----- | ----- | +| Name | `web_agent_activity` | +| Primary owner | Your email address, filled in for you and read-only | +| Description | `Recent session activity for the Signal Shop support agent` | +| Prompt | `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.` | + +![The Create context form in Snowplow Console, with the Details section holding the name web_agent_activity, a greyed-out Primary owner field, and the description Recent session activity for the Signal Shop support agent, and the Prompt section below it holding the Signal Shop instructions under the helper text about adding them on top of the retrieved agentic context](./images/agentic-context-create-form.png) + +Under **Lookback Window**, set **Max events** to `50` and **Max age** to `30` minutes. Console restates the window underneath the fields, so you can check it reads as the last 50 events within 30 minutes. The details page later shows the same setting as **Max Age (seconds)** `1800`. + +Under **Events and Properties**, click **Add event** and choose `page_view` on the **Data structures** tab. The version fills in as `1-0-0`. Then use **Add property** three times to attach `event_name`, `page_urlpath`, and `page_title` from the **Atomic** tab. + +Click **Create**. Your agentic context now has a details page showing **Status** **Draft**. Click **Publish** there and confirm, and the status changes to **Published**. Later changes go through **Edit** and start as a draft, so the published version stays live while you work on it. + + + + +You can also define agentic contexts programmatically with the [Signals Python SDK](https://pypi.org/project/snowplow-signals/), where the `EventLog` class is the building block. You already added this SDK to the agent's dependencies during project setup. + +Start by [connecting to Signals](/docs/signals/connection/) to create a `Signals` object called `sp_signals`, then define the agentic context: + +```python +from snowplow_signals import ( + EventLog, + EventSelection, + EventLogEvent, + EventLogAtomicProperty, + domain_sessionid, +) + +web_agent_activity = EventLog( + name="web_agent_activity", + description="Recent session activity for the Signal Shop support agent", + owner="user@company.com", + prompt=( + "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." + ), + attribute_key=domain_sessionid, + max_events=50, + max_age_seconds=1800, + events=[ + EventSelection( + event=EventLogEvent( + name="page_view", + vendor="com.snowplowanalytics.snowplow", + version="1-0-0", + ), + properties=[ + EventLogAtomicProperty(name="event_name"), + EventLogAtomicProperty(name="page_urlpath"), + EventLogAtomicProperty(name="page_title"), + ], + ), + ], +) +``` + +Publish it to send the configuration to your Signals infrastructure: + +```python +sp_signals.publish([web_agent_activity]) +``` + +Run this as a one-off script, separately from the agent. + + + + +The `prompt` text travels with the agentic context: Signals hands it to your agent alongside the captured activity, so you can refine the instructions later without touching your agent code. + +With the attribute group, service, and agentic context all published, you're ready to wire them into the agent. diff --git a/tutorials/signals-google-adk-agent/images/agentic-context-create-form.png b/tutorials/signals-google-adk-agent/images/agentic-context-create-form.png new file mode 100644 index 000000000..e4838bb5a Binary files /dev/null and b/tutorials/signals-google-adk-agent/images/agentic-context-create-form.png differ diff --git a/tutorials/signals-google-adk-agent/introduction.md b/tutorials/signals-google-adk-agent/introduction.md index f5b30a40d..c3f982e29 100644 --- a/tutorials/signals-google-adk-agent/introduction.md +++ b/tutorials/signals-google-adk-agent/introduction.md @@ -3,17 +3,23 @@ title: "Build a real-time context-aware agent with Signals, Google ADK, and Copi sidebar_label: "Introduction" position: 1 description: "Add Snowplow Signals to a Google ADK agent so it gets real-time user context for every response, and embed it in a React app with CopilotKit." -keywords: ["Signals", "Google ADK", "CopilotKit", "AG-UI", "Gemini", "AI agents", "personalization"] -date: "2026-04-17" +keywords: ["Signals", "Google ADK", "CopilotKit", "AG-UI", "agentic context", "Gemini", "AI agents", "personalization"] +date: "2026-07-31" --- In this tutorial, you'll add [Snowplow Signals](/docs/signals/) to a Google ADK agent so the agent gets fresh context about the current user's session before every response. Instead of responding generically, the agent will know which pages the user has been browsing, how long they've been on the site, and what they've been looking at. +The agent will draw on two complementary kinds of Signals context: + +- Profile attributes: computed aggregates about the session, such as page view counts, served by a Signals [service](/docs/signals/concepts/#services). Use attributes when you want defined metrics that your agent, or any other consumer, can rely on. +- An [agentic context](/docs/signals/agentic-contexts/): the user's recent activity, returned as an LLM-ready narrative. Use it when you want to ground the agent in the user's immediate journey, without writing aggregation or formatting logic. + The app will: - Track user behavior automatically using the [Snowplow Browser tracker](/docs/sources/web-trackers/) - Compute live session attributes with Snowplow Signals -- Inject those attributes into the agent's system instruction on every turn, using a `before_model_callback` +- Capture recent session activity with a Signals agentic context +- Inject both into the agent's system instruction on every turn, using a `before_model_callback` - Deliver contextually aware responses via a React + CopilotKit chat sidebar Adding real-time context from Signals can improve responses. In this example, the user has spent 20 minutes browsing enterprise pricing: @@ -36,19 +42,58 @@ The agent can tailor its response based on the user's actual behavior. The flow works like this: - The Snowplow Browser tracker streams behavioral [events](/docs/fundamentals/events/) to your Collector -- Signals computes live session attributes from that stream +- Signals computes live session attributes from that stream, and buffers the session's recent events for the agentic context - On the front-end, `CopilotProvider` reads the Snowplow session ID from the tracker's cookie and passes it to CopilotKit via a `properties` prop - The session ID gets sent as `forwarded_props` in every CopilotKit request, including the very first turn -- The ADK agent's `before_model_callback` uses that session ID to fetch fresh attributes from Signals and append them to the system instruction +- The ADK agent's `before_model_callback` uses that session ID to fetch both the profile attributes and the activity narrative from Signals, and appends both to the system instruction + +```mermaid +flowchart TD + subgraph browser["Browser"] + tracker["Snowplow Browser tracker"] + sidebar["CopilotProvider and chat sidebar
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 -Architecture diagram showing the full data and request flow. In the browser, the Snowplow tracker fires page views, page pings, and link clicks to the Snowplow Collector. The CopilotKit client communicates with the React app via the AG-UI protocol. In the React app (port 3000), a CopilotKit sidebar renders the chat UI and a CopilotRuntime proxy forwards requests to the ADK backend as an HttpAgent, including the Snowplow session ID in forwarded_props. The FastAPI server (port 8000) runs ADKAgent middleware containing an LlmAgent (Gemini) with a before_model_callback. Before each model call, the callback fetches fresh session attributes from Signals using a GET attributes by session ID request. In the Snowplow layer, the Collector receives raw events, produces enriched events, and feeds them into Signals, which maintains real-time session attributes. + 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/) +- 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..bd0716aa4 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-07-31" --- CopilotKit ships a starter that scaffolds both the ADK Python back-end and a React front-end in one command. @@ -25,13 +25,9 @@ This creates: 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 @@ -63,13 +59,16 @@ 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 ``` +`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 [Snowplow 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**. diff --git a/tutorials/signals-google-adk-agent/tracking-setup.md b/tutorials/signals-google-adk-agent/tracking-setup.md index e420fb29b..b7ed96cd1 100644 --- a/tutorials/signals-google-adk-agent/tracking-setup.md +++ b/tutorials/signals-google-adk-agent/tracking-setup.md @@ -4,7 +4,7 @@ sidebar_label: "Set up Snowplow tracking" position: 3 description: "Initialize the Snowplow Browser tracker, track page views on client-side route changes, and wire the tracker into the React layout." keywords: ["Snowplow Browser SDK", "tracker", "page views", "activity tracking", "link clicks"] -date: "2026-04-17" +date: "2026-07-31" --- [Snowplow Signals](/docs/signals/get-started/) computes user attributes from your Snowplow behavioral event stream. Follow these steps to create events to work with. @@ -100,9 +100,7 @@ export function SnowplowProvider({ children }: { children: React.ReactNode }) { } ``` -:::note[Using a different framework?] The scaffold uses `usePathname` from `next/navigation` for route-change detection. In a Vite application, swap this for `useLocation` from `react-router-dom`. The Snowplow tracker itself is framework-agnostic. -::: ## Add to the root layout