diff --git a/tutorials/signals-account-attributes/conclusion.md b/tutorials/signals-account-attributes/conclusion.md
new file mode 100644
index 000000000..057327c87
--- /dev/null
+++ b/tutorials/signals-account-attributes/conclusion.md
@@ -0,0 +1,60 @@
+---
+title: "Conclusion"
+position: 6
+sidebar_label: "Conclusion"
+description: "Recap of building account-level attributes with a custom Signals attribute key, with variations and next steps."
+keywords: ["snowplow signals", "custom attribute keys", "account-level attributes", "next steps"]
+date: "2026-07-31"
+---
+
+You've built a real-time, account-level personalization loop for a multi-tenant B2B app. Along the way you:
+
+* Attached an `account` entity, carrying a UUID-formatted `account_id`, to events from multiple users
+* Defined a custom attribute key from the entity property, in Console or with the Signals Python SDK
+* Computed cross-user account attributes with three different aggregations, including a distinct count of active users
+* Retrieved an account's live profile by `account_id` and reacted to an account-level intervention
+
+The pattern generalizes to any grouping your events carry. The attribute key doesn't have to mean "account": it's whatever identifier you point it at.
+
+## Clean up
+
+Published definitions keep computing whether or not anything reads them, so tear yours down if this was an experiment. Signals only deletes definitions that aren't published, so unpublish each one first.
+
+Unpublish one definition per call, in reverse dependency order: the intervention, then the service, then the attribute group, then the attribute key. A single `delete()` call is enough afterwards, because `delete()` works through the dependency order for you.
+
+```python
+for definition in (
+ account_expansion_nudge,
+ account_service,
+ account_activity,
+ account_id_key,
+):
+ sp_signals.unpublish([definition])
+
+sp_signals.delete(
+ [account_expansion_nudge, account_service, account_activity, account_id_key]
+)
+```
+
+If you built your definitions in Console, ask the [Snowplow Assistant](/docs/llms-support/console-agent/) to tear them down instead: "Unpublish and delete my account_expansion_nudge intervention, my account_activity_service service, version 1 of my account_activity attribute group, and my account_id attribute key, in that order."
+
+Your two data structures aren't Signals resources and aren't affected. Leave them in place if you want to keep tracking against them, or hide them from the **Data structures** list in Console.
+
+## Variations
+
+These other custom keys follow the same recipe:
+
+* A `workspace_id` or `project_id` entity property, for attributes per workspace or project rather than per tenant
+* An event property, such as an `order_id` in a checkout event, for attributes scoped to a business process
+* An atomic property, such as `app_id`, for attributes per application. See [attribute keys](/docs/signals/attributes/attribute-keys/) for the property types you can key on.
+
+Whatever the key, the same rule applies: if you'll target interventions at it, its values must be non-enumerable UUIDs.
+
+## Next steps
+
+Where you go from here depends on where your account-level data lives and how much you want to act on it:
+
+* If your account-level facts already live in a warehouse table, for example contract value or seat count, define a [warehouse attribute group](/docs/signals/attributes/warehouse-config/) instead, with an attribute key based on the table's account ID column via `external_column`
+* Explore richer [aggregations and criteria](/docs/signals/attributes/attributes/), such as `unique_list` to hold the set of active user IDs, or criteria filters to count only high-priority tasks
+* Follow the [interventions tutorial](/tutorials/signals-interventions/start) to build out more sophisticated intervention flows in a demo app
+* Read more about the different ways to [subscribe to interventions](/docs/signals/applications/subscribe/), including the browser plugin for web apps
diff --git a/tutorials/signals-account-attributes/define-account-attributes.md b/tutorials/signals-account-attributes/define-account-attributes.md
new file mode 100644
index 000000000..516252935
--- /dev/null
+++ b/tutorials/signals-account-attributes/define-account-attributes.md
@@ -0,0 +1,249 @@
+---
+title: "Define account attributes"
+position: 4
+sidebar_label: "Define account attributes"
+description: "Define a stream attribute group keyed on the custom account_id attribute key, with distinct-user, counter, and last-value aggregations, then publish it with a service."
+keywords: ["stream attribute group", "distinct count attributes", "signals aggregations", "signals python sdk", "signals service", "snowplow assistant"]
+date: "2026-07-31"
+---
+
+```mdx-code-block
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+```
+
+With the attribute key defined, you can describe what Signals should compute for each account. In this section you'll define two things:
+
+* An [attribute group](/docs/signals/attributes/attribute-groups/) that computes three account-level attributes
+* A [service](/docs/signals/applications/services/) that bundles the group, so you can retrieve all of its attributes in one call
+
+Build both in Snowplow Console or with the Signals Python SDK. Either way, start by setting up your Signals credentials, because the next section retrieves attributes and publishes an intervention in Python.
+
+## Connect to Signals
+
+The `Signals` client takes four keyword arguments, and all four are required. To find them:
+
+1. Go to **Signals** > **Overview** in [Snowplow Console](https://console.snowplowanalytics.com) to find your **Signals API URL** and **Organization ID**.
+2. Generate an **API key** and **API key ID** in Console. Both are UUIDs, and both are required together.
+
+See the [connection documentation](/docs/signals/connection/) for details.
+
+Your API key is a credential, so keep it out of your source files. Export the four values in the shell you'll start Python from:
+
+```bash
+export SIGNALS_API_URL="https://YOUR_ID.signals.snowplowanalytics.com"
+export SIGNALS_API_KEY="YOUR_API_KEY"
+export SIGNALS_API_KEY_ID="YOUR_API_KEY_ID"
+export SNOWPLOW_ORG_ID="YOUR_ORG_ID"
+```
+
+Then read them back in Python:
+
+```python
+import os
+from snowplow_signals import Signals
+
+OWNER = "YOUR_EMAIL@example.com" # a valid email; identifies the owner of a definition
+if "YOUR_" in OWNER:
+ raise SystemExit("Set OWNER to your own email address before publishing.")
+
+sp_signals = Signals(
+ api_url=os.environ["SIGNALS_API_URL"], # must include https://
+ api_key=os.environ["SIGNALS_API_KEY"],
+ api_key_id=os.environ["SIGNALS_API_KEY_ID"], # required, don't omit it
+ org_id=os.environ["SNOWPLOW_ORG_ID"],
+)
+```
+
+Every definition you create in Python carries an `owner`, which is why `OWNER` is set here alongside the client. In Console, the **Owner** field defaults to your Console user instead.
+
+## Define the attribute group
+
+A stream attribute group computes attributes from the live event stream for a given attribute key. Keying this group on `account_id` is what makes every attribute in it account-level: events from all of an account's users update the same profile.
+
+The three attributes deliberately use three different aggregations:
+
+* `active_users` uses `approx_count_distinct`, which approximates the number of unique values of a property. Pointed at the `domain_userid` atomic property, it counts how many distinct users have been active in the account.
+* `tasks_completed_count` uses `counter`, which counts matching events without reading a property.
+* `last_plan` uses `last`, which keeps the most recent value of a property. Pointed at the entity's `plan` property, it always reflects the account's current plan.
+
+`active_users` counts distinct `domain_userid` values rather than `user_id` values, and that choice is worth a moment. Both are [atomic properties](/docs/signals/attributes/attributes/#select-a-property), so swapping one for the other is a one-line change. `domain_userid` is the safer default in a real multi-tenant app, because web and mobile trackers set it on every event, including events sent before anyone signs in, while `user_id` only appears once your app knows who the user is. The cost is that you're counting devices, so one person on a laptop and a phone counts twice. If every event in your app carries a `user_id`, count that instead.
+
+
+
+
+The quickest way to build the group is to ask the [Snowplow Assistant](/docs/llms-support/console-agent/) in Console. Paste this prompt into the chat:
+
+```text
+Create a Signals stream attribute group called account_activity, version 1, keyed on
+my custom account_id attribute key, described as "Cross-user activity for each B2B
+account". Compute all three attributes from the com.example task_completed event,
+version 1-0-0, each over a rolling 7-day period:
+
+- active_users: type int32, approx_count_distinct aggregation of the atomic
+ domain_userid property
+- tasks_completed_count: type int32, counter aggregation
+- last_plan: type string, last aggregation of the plan property from the com.example
+ account entity, major version 1
+
+Don't publish it yet.
+```
+
+The Assistant shows you the group it's about to create and asks you to confirm. You can also build the group by hand under **Signals** > **Attribute groups** in Console.
+
+
+
+
+Define the group with `StreamAttributeGroup`, setting `attribute_key` to the `AttributeKey` object. Define that key here too, whether you [created it in Console](/tutorials/signals-account-attributes/define-the-attribute-key) or with the SDK. It refers to the same key by name, and republishing an identical key is harmless:
+
+```python
+from snowplow_signals import AttributeKey, EntityProperty
+
+account_id_key = AttributeKey(
+ name="account_id",
+ description="The B2B account the event belongs to",
+ property=EntityProperty(
+ vendor="com.example",
+ name="account",
+ major_version=1,
+ path="account_id",
+ ),
+)
+```
+
+```python
+from datetime import timedelta
+from snowplow_signals import (
+ Attribute,
+ AtomicProperty,
+ EntityProperty,
+ Event,
+ StreamAttributeGroup,
+)
+
+task_completed = Event(vendor="com.example", name="task_completed", version="1-0-0")
+
+account_activity = StreamAttributeGroup(
+ name="account_activity",
+ version=1,
+ attribute_key=account_id_key,
+ owner=OWNER,
+ description="Cross-user activity for each B2B account",
+ attributes=[
+ Attribute(
+ name="active_users",
+ type="int32",
+ aggregation="approx_count_distinct",
+ events=[task_completed],
+ property=AtomicProperty(name="domain_userid"),
+ period=timedelta(days=7),
+ ),
+ Attribute(
+ name="tasks_completed_count",
+ type="int32",
+ aggregation="counter",
+ events=[task_completed],
+ period=timedelta(days=7),
+ ),
+ Attribute(
+ name="last_plan",
+ type="string",
+ aggregation="last",
+ events=[task_completed],
+ property=EntityProperty(
+ vendor="com.example",
+ name="account",
+ major_version=1,
+ path="plan",
+ ),
+ period=timedelta(days=7),
+ ),
+ ],
+)
+```
+
+The two version arguments in that block look inconsistent, but they express different things. `Event(version="1-0-0")` selects one exact event schema version, and it's optional: leave it out and the attribute is computed from every version of `task_completed`. `EntityProperty(major_version=1)` selects a major version, so the property keeps resolving as you add minor and patch revisions to the `account` schema.
+
+
+
+
+It's worth knowing what an untouched account looks like, because you'll see it in the next section. For an `account_id` that Signals has no data for, `active_users` comes back as `0` and the other two come back as `None`. The `0` isn't a special case for missing profiles: it's what counting distinct values of nothing produces, whereas a counter and a last-value have no result at all to report. Retrieval code needs to cope with both shapes.
+
+Every attribute uses a rolling seven-day window, so the values describe the account's trailing week of activity. `approx_count_distinct` uses [HyperLogLog](https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/) internally: at high cardinality the count is a close approximation, and at the low counts in this tutorial it's exact. See [attributes](/docs/signals/attributes/attributes/) for all available aggregations.
+
+## Group the attributes into a service
+
+A service is a named bundle of attribute groups. Retrieving by service is the recommended way to read attributes in an application, because you fetch everything you need in one call.
+
+
+
+
+Ask the Assistant for the service as well:
+
+```text
+Create a Signals service called account_activity_service that bundles version 1 of my
+account_activity attribute group, so that my application can retrieve all three
+account attributes in one call. Don't publish it yet.
+```
+
+You can also create the service by hand under **Signals** > **Services** in Console.
+
+
+
+
+```python
+from snowplow_signals import Service
+
+account_service = Service(
+ name="account_activity_service",
+ owner=OWNER,
+ attribute_groups=[account_activity],
+)
+```
+
+
+
+
+## Publish your definitions
+
+Definitions don't take effect until you publish them. Publish the attribute key first, because an attribute group can only be published once its key exists in Signals.
+
+
+
+
+Publish each definition from its own page in Console, or ask the Assistant to publish all three in the right order:
+
+```text
+Publish my account_id attribute key, then version 1 of my account_activity attribute
+group, then my account_activity_service service.
+```
+
+
+
+
+Include the attribute key in the same `publish()` call as the group. The order of the list doesn't matter, because the SDK publishes by type, sending all attribute keys first, then groups, then services, then interventions. If you already created the key in Console, publishing it again is harmless.
+
+```python
+sp_signals.publish([account_id_key, account_activity, account_service])
+```
+
+
+
+
+Open **Signals** in Console to confirm that your attribute key, attribute group, and service appear there.
+
+A published attribute group can't be edited in place, so to change an attribute, unpublish the group first, or publish your change as a new version of the group.
+
+:::note[Counting events from before you published]
+If you want an attribute group to start life with history rather than at zero, [enable backfill](/docs/signals/attributes/attribute-groups/#backfill-attributes) by setting `backfill_since_tstamp` on the group. Signals then computes the initial values from your `atomic` events table for the period between that timestamp and the publish time. This needs a warehouse connection, and only Snowflake and BigQuery are supported, so it isn't an option on a bare trial pipeline. This tutorial starts from zero instead.
+:::
+
+## Troubleshooting
+
+Publishing rejects invalid definitions. If `publish()` fails, check these:
+
+* Every attribute group must be published together with the attribute key it uses, so include the `AttributeKey` object in the `publish()` list alongside the group, or create the key in Console first.
+* Value-reading aggregations such as `approx_count_distinct` and `last` need a `property` (an `AtomicProperty`, `EventProperty`, or `EntityProperty`). Only `counter` works without one.
+* `owner` must be a valid email address.
+* If Python can't read the credential environment variables, they weren't set in the shell that started this session. Export them and restart the session, since a notebook kernel won't pick up variables exported after it launched.
+* Attributes stay empty later: check that the `EntityProperty` vendor, name, and version match the schema exactly, and that your events actually attach the `account` entity.
diff --git a/tutorials/signals-account-attributes/define-the-attribute-key.md b/tutorials/signals-account-attributes/define-the-attribute-key.md
new file mode 100644
index 000000000..6ca424cd6
--- /dev/null
+++ b/tutorials/signals-account-attributes/define-the-attribute-key.md
@@ -0,0 +1,65 @@
+---
+title: "Define the attribute key"
+position: 3
+sidebar_label: "Define the attribute key"
+description: "Create a custom Signals attribute key from the account entity's account_id property, in Snowplow Console or with the Signals Python SDK."
+keywords: ["custom attribute key", "entity property", "signals console", "signals python sdk", "attribute key from schema property"]
+date: "2026-07-31"
+---
+
+```mdx-code-block
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+```
+
+With account-carrying events flowing, you can tell Signals to group them by account. An [attribute key](/docs/signals/attributes/attribute-keys/) is the identifier that attributes are calculated against. Signals ships with four built-in keys, all scoped to a user, device, or session, and lets you define custom keys from any property in your events.
+
+Here you'll create a custom attribute key named `account_id`, based on the `account_id` property of the `account` entity. Any attribute group that uses this key aggregates events per account.
+
+You can create the key in Snowplow Console or with the Signals Python SDK. Both produce the same definition, so pick whichever fits your workflow.
+
+
+
+
+In [Snowplow Console](https://console.snowplowanalytics.com), go to **Signals** > **Attribute keys**. The list shows the four built-in keys. Click **Create attribute key**.
+
+The form offers two ways to define the key: a **Schema property** for stream attribute groups, or a **Warehouse table column** for warehouse attribute groups. You're building from the event stream, so under **Schema property**, click **Select property**.
+
+The property picker has three tabs: **Atomic**, **Event**, and **Entity**. Open the **Entity** tab, select the `account` entity from the `com.example` vendor, then select the `account_id` property.
+
+
+
+Click **Confirm** to close the picker, then add an optional description. The **Owner** field defaults to your Console user's email address.
+
+
+
+Click **Create attribute key** to save it. There's no name field: the key's name is taken from the selected property, so it appears in the list as `account_id`.
+
+
+
+
+Define the key with the `AttributeKey` class, pointing its `property` argument at the entity property with `EntityProperty`.
+
+```python
+from snowplow_signals import AttributeKey, EntityProperty
+
+account_id_key = AttributeKey(
+ name="account_id",
+ description="The B2B account the event belongs to",
+ property=EntityProperty(
+ vendor="com.example",
+ name="account",
+ major_version=1,
+ path="account_id",
+ ),
+)
+```
+
+`major_version=1` picks the schema's major version, so minor and patch revisions of the `account` schema keep working with this key.
+
+This only creates a local object. The key is registered with Signals when you publish it, which you'll do together with the attribute group in the next section. An attribute group can only reference keys that already exist in Signals, so the publish call there includes the key.
+
+
+
+
+The property reference is what makes this key account-scoped: for every event, Signals reads `account_id` from the attached `account` entity and uses that value as the profile identifier. That's specific to stream attribute groups. For [warehouse attribute groups](/docs/signals/attributes/warehouse-config/), the attributes are pre-calculated in a warehouse table, so the key names the table column holding the key values with `external_column` in place of `property`.
diff --git a/tutorials/signals-account-attributes/images/attribute-key-create-form.png b/tutorials/signals-account-attributes/images/attribute-key-create-form.png
new file mode 100644
index 000000000..5e94bc90f
Binary files /dev/null and b/tutorials/signals-account-attributes/images/attribute-key-create-form.png differ
diff --git a/tutorials/signals-account-attributes/images/attribute-key-entity-property.png b/tutorials/signals-account-attributes/images/attribute-key-entity-property.png
new file mode 100644
index 000000000..c546441c5
Binary files /dev/null and b/tutorials/signals-account-attributes/images/attribute-key-entity-property.png differ
diff --git a/tutorials/signals-account-attributes/introduction.md b/tutorials/signals-account-attributes/introduction.md
new file mode 100644
index 000000000..9b5f51274
--- /dev/null
+++ b/tutorials/signals-account-attributes/introduction.md
@@ -0,0 +1,43 @@
+---
+title: "Introduction"
+position: 1
+sidebar_label: "Introduction"
+description: "Aggregate real-time behavior across all users of a B2B account by defining a custom Signals attribute key from an entity property, then trigger account-level interventions."
+keywords: ["snowplow signals", "custom attribute key", "account-level attributes", "B2B personalization", "entity property"]
+date: "2026-07-31"
+---
+
+In this tutorial you'll compute real-time attributes for a B2B account, rather than for an individual user, using a custom Signals [attribute key](/docs/signals/attributes/attribute-keys/).
+
+Signals computes attributes against an attribute key: the identifier that event data is grouped by. The built-in keys (`user_id`, `domain_userid`, `domain_sessionid`, and `network_userid`) all identify a single user, device, or session. That's the right granularity for questions like "how many tasks has this user completed", but it can't answer "how active is this *account*", because an account's activity is spread across many users.
+
+Custom attribute keys solve this. You can key an [attribute group](/docs/signals/attributes/attribute-groups/) on any property in your events: an atomic field, a self-describing event property, or an [entity](/docs/fundamentals/entities/) property. Here you'll key on an `account_id` property carried by an entity, so Signals aggregates behavior from every user in the account into one profile.
+
+To make this concrete, you'll build the tracking for an imaginary multi-tenant SaaS project-management app. Every event carries an `account` entity, and Signals computes account-level attributes: how many users are active, how many tasks the whole team has completed, and which plan the account is on. When the team's activity crosses a threshold, Signals fires an [intervention](/docs/signals/interventions/) so your app can react, for example by suggesting an upgrade.
+
+By the end you'll have working code that:
+
+* Tracks `task_completed` events from multiple users, each carrying an `account` entity
+* Defines a custom attribute key from the entity's `account_id` property, in Console or with the Signals Python SDK
+* Computes account-level attributes, including a distinct count of active users
+* Retrieves an account's live attributes and reacts to an account-level intervention
+
+## Prerequisites
+
+This tutorial assumes that you have:
+
+* A Snowplow pipeline with a [Collector endpoint](/docs/sources/) you can send events to, because Signals computes attributes from your live event stream
+* [Signals enabled](/docs/signals/setup/) on your Snowplow account, since the account-level attributes and interventions depend on it
+* Access to [Snowplow Console](https://console.snowplowanalytics.com), where you'll create the [data structures](/docs/event-studio/data-structures/) and generate the Signals API key as steps of the tutorial
+* Python 3.11 or later, because `snowplow-signals` requires it
+* Basic familiarity with Python and with [Snowplow events, entities, and schemas](/docs/fundamentals/events/)
+
+:::note[Run the code in one session]
+The Python snippets build on each other and share variables, including `account_id`, the attribute key, and the `Signals` client. Run them all in one Python session, in page order. A Jupyter notebook or an IPython shell works well for this. The final page also collects everything into a single script if you'd rather run the whole loop at once.
+:::
+
+:::note[A full pipeline is required]
+The Signals parts of this tutorial compute attributes from real events flowing through your pipeline, so they can't be completed with [Snowplow Micro](/docs/testing/snowplow-micro/) or in a purely local setup. You need a running Snowplow pipeline with Signals enabled.
+
+If you don't have one, you can deploy and use a [Snowplow free trial](https://snowplow.io/get-started/snowplow-free-trial) to follow along.
+:::
diff --git a/tutorials/signals-account-attributes/meta.json b/tutorials/signals-account-attributes/meta.json
new file mode 100644
index 000000000..cb34e9065
--- /dev/null
+++ b/tutorials/signals-account-attributes/meta.json
@@ -0,0 +1,8 @@
+{
+ "title": "Personalize by account with custom Signals attribute keys",
+ "description": "Define a custom attribute key from an entity property to aggregate behavior across all users of a B2B account, and trigger interventions on account-level activity.",
+ "label": "Signals implementation",
+ "useCase": "Real-time personalization",
+ "technologies": ["Python"],
+ "snowplowTech": ["Signals", "Console"]
+}
diff --git a/tutorials/signals-account-attributes/retrieve-and-intervene.md b/tutorials/signals-account-attributes/retrieve-and-intervene.md
new file mode 100644
index 000000000..c536e1580
--- /dev/null
+++ b/tutorials/signals-account-attributes/retrieve-and-intervene.md
@@ -0,0 +1,234 @@
+---
+title: "Retrieve attributes and intervene on account activity"
+position: 5
+sidebar_label: "Retrieve and intervene"
+description: "Retrieve live account-level attributes by account_id, define an intervention targeting the custom attribute key, and run the full multi-user loop end to end."
+keywords: ["retrieve signals attributes", "rule intervention", "subscribe to interventions", "account-level intervention", "custom attribute key"]
+date: "2026-07-31"
+---
+
+With definitions published, your application can look up any account's live profile and react when the whole team's activity crosses a threshold. Everything keys on `account_id`, so it works the same no matter which user triggered the events.
+
+## Retrieve attributes by account
+
+Retrieve an account's attributes through the service you defined. Pass the custom key's name as `attribute_key`, and the account's UUID as the `identifier`. Use the same `account_id` value that your events carry in the `account` entity, which is the value printed by the tracking section.
+
+```python
+attributes = sp_signals.get_service_attributes(
+ name="account_activity_service",
+ attribute_key="account_id",
+ identifier=account_id,
+)
+
+print("active_users:", attributes.get("active_users"))
+print("tasks_completed_count:", attributes.get("tasks_completed_count"))
+print("last_plan:", attributes.get("last_plan"))
+```
+
+The result is a plain dictionary keyed by attribute name. Read it with `.get()` rather than `attributes["..."]`, so an attribute Signals hasn't computed yet returns `None` instead of raising `KeyError` halfway through your output.
+
+The first time you run this, expect `0`, `None`, and `None`: the empty profile described in the previous section. Signals computes attributes only from events processed after your published definitions reached the streaming engine, so the events you tracked earlier don't appear. Once your definitions have been applied, re-run the **Track team activity** block from the [tracking section](/tutorials/signals-account-attributes/track-the-account-entity#track-team-activity) to send fresh events under the same `account_id`, then retrieve again.
+
+Retrieving through a service is one of several options. See [retrieve attributes](/docs/signals/applications/retrieve-attributes/) for the alternatives, including reading a single attribute group directly, and the Node.js SDK and HTTP API equivalents.
+
+## Define an account-level intervention
+
+An [intervention](/docs/signals/interventions/) fires when its criteria are met for a target. This one fires when an account's team completes ten or more tasks in the rolling window: a natural moment to suggest an upgrade or invite more of the team.
+
+Reference the attribute as `group_name:attribute_name`, and target the custom key with a `LinkAttributeKey`. Targeting `account_id` means the intervention is delivered for the account, not for any individual user.
+
+```python
+from snowplow_signals import (
+ RuleIntervention,
+ InterventionCriterion,
+ LinkAttributeKey,
+)
+
+account_expansion_nudge = RuleIntervention(
+ name="account_expansion_nudge",
+ version=1,
+ owner=OWNER,
+ description="Fire when an account's team completes 10 or more tasks in the period",
+ criteria=InterventionCriterion(
+ attribute="account_activity:tasks_completed_count",
+ operator=">=",
+ value=10,
+ ),
+ target_attribute_keys=[LinkAttributeKey(name="account_id")],
+)
+
+sp_signals.publish([account_expansion_nudge])
+```
+
+A single condition goes straight into `criteria`. To combine several, wrap them in `InterventionCriteriaAll` or `InterventionCriteriaAny`.
+
+`target_attribute_keys` is technically optional. Left out, it defaults to the attribute keys of the groups named in the criteria, which here is `account_id` anyway. Set it explicitly when you want the targeting to be obvious to the next person reading the definition, or when your criteria span groups with different keys.
+
+## Subscribe to account interventions
+
+Subscribe to interventions for the accounts you care about by their key values. This is where the UUID requirement from the tracking section pays off: the subscription endpoint only accepts UUID-formatted identifiers.
+
+```python
+import queue
+from snowplow_signals import AttributeKeyIdentifiers
+
+targets = AttributeKeyIdentifiers({"account_id": [account_id]})
+
+subscription = sp_signals.pull_interventions(targets)
+subscription.add_handler(lambda intervention: print("INTERVENTION:", intervention))
+subscription.start()
+
+# Block until an intervention arrives, or the timeout expires.
+try:
+ print("Received:", subscription.get(timeout=30))
+except queue.Empty:
+ print("No intervention within the timeout")
+finally:
+ subscription.stop()
+```
+
+`subscription.start()` returns immediately, because it hands the request to a background thread and leaves your own code free to carry on. The handler runs for every intervention as it arrives, and `subscription.get()` additionally returns each one, blocking until an intervention is available or the timeout raises `queue.Empty`. In your app, the handler is where you'd act, for example by notifying the account's admin that the team is outgrowing its plan.
+
+:::tip[Test the delivery path]
+To check the delivery path independently of your criteria, open the intervention in Console, find **Test this intervention**, enter an `account_id` value, and click **Send**. A subscription listening for that value receives the intervention.
+:::
+
+:::note[Interventions fire only once per target]
+An intervention is sent only the first time its criteria are met for a given target. This account crossed the threshold before you subscribed to it, so there's nothing left to deliver and the subscription above times out. To see a delivery, mint a fresh `account_id` (a new UUID) and track enough `task_completed` events to cross the threshold under that new account, which is what the full script below does.
+:::
+
+## Put it all together
+
+Here's the full loop in one script: simulate a two-person team crossing the threshold, retrieve the live account attributes, and catch the intervention. Subscribe before tracking, so the subscription is listening when the threshold is crossed. It assumes the attribute key, attribute group, service, and intervention from the previous pages are already published, and it reads your Signals credentials from the same environment variables.
+
+```python
+import os
+import queue
+import sys
+import time
+import uuid
+from snowplow_tracker import (
+ SelfDescribing,
+ SelfDescribingJson,
+ Snowplow,
+ Subject,
+)
+from snowplow_signals import AttributeKeyIdentifiers, Signals
+
+# --- Configuration ---
+COLLECTOR_URL = "https://YOUR_COLLECTOR_HOST" # your Collector endpoint, including https://
+
+if "YOUR_" in COLLECTOR_URL:
+ sys.exit("Set COLLECTOR_URL to your own Collector endpoint before running.")
+
+# --- Identity: one account (a UUID), two team members ---
+account_id = str(uuid.uuid4())
+print("account_id:", account_id)
+alice = Subject().set_user_id(str(uuid.uuid4())).set_domain_user_id(str(uuid.uuid4()))
+bob = Subject().set_user_id(str(uuid.uuid4())).set_domain_user_id(str(uuid.uuid4()))
+
+# --- Tracker. The namespace differs from the earlier snippets, so this script
+# --- can run in a session that already created a "project-app" tracker.
+tracker = Snowplow.create_tracker(
+ namespace="project-app-full-run",
+ endpoint=COLLECTOR_URL,
+ app_id="project-app-backend",
+)
+
+# --- Signals client ---
+sp_signals = Signals(
+ api_url=os.environ["SIGNALS_API_URL"],
+ api_key=os.environ["SIGNALS_API_KEY"],
+ api_key_id=os.environ["SIGNALS_API_KEY_ID"],
+ org_id=os.environ["SNOWPLOW_ORG_ID"],
+)
+
+# --- Subscribe to the account's interventions before tracking ---
+targets = AttributeKeyIdentifiers({"account_id": [account_id]})
+subscription = sp_signals.pull_interventions(targets)
+subscription.add_handler(lambda intervention: print("INTERVENTION:", intervention))
+subscription.start()
+
+try:
+ # --- The team completes 12 tasks, crossing the threshold of 10 ---
+ account = SelfDescribingJson(
+ "iglu:com.example/account/jsonschema/1-0-0",
+ {"account_id": account_id, "plan": "team"},
+ )
+ for member in (alice, bob):
+ for _ in range(6):
+ tracker.track(
+ SelfDescribing(
+ SelfDescribingJson(
+ "iglu:com.example/task_completed/jsonschema/1-0-0",
+ {"task_id": str(uuid.uuid4()), "priority": "high"},
+ ),
+ event_subject=member,
+ context=[account],
+ )
+ )
+ tracker.flush()
+
+ # --- Poll until the attributes compute ---
+ attributes = {}
+ for _ in range(12):
+ time.sleep(5)
+ attributes = sp_signals.get_service_attributes(
+ name="account_activity_service",
+ attribute_key="account_id",
+ identifier=account_id,
+ )
+ if attributes.get("tasks_completed_count"):
+ break
+ print("active_users:", attributes.get("active_users"))
+ print("tasks_completed_count:", attributes.get("tasks_completed_count"))
+ print("last_plan:", attributes.get("last_plan"))
+
+ # --- Wait for the intervention to arrive ---
+ print("Waiting for intervention...")
+ try:
+ print("Received:", subscription.get(timeout=120))
+ except queue.Empty:
+ print("No intervention within the timeout")
+finally:
+ subscription.stop()
+```
+
+A successful run looks like this, with your own UUIDs and Collector host:
+
+```text
+account_id: 1d124aec-2a1d-4316-b0aa-d5b0a1e2962d
+INFO:snowplow_tracker.emitters:Emitter initialized with endpoint https://your-collector.example.com/com.snowplowanalytics.snowplow/tp2
+INFO:snowplow_tracker.snowplow:Tracker with namespace: 'project-app-full-run' added to Snowplow
+INFO:snowplow_tracker.emitters:Attempting to send 10 events
+INFO:snowplow_tracker.emitters:Sending POST request to https://your-collector.example.com/com.snowplowanalytics.snowplow/tp2...
+INFO:snowplow_tracker.emitters:Attempting to send 2 events
+INFO:snowplow_tracker.emitters:Sending POST request to https://your-collector.example.com/com.snowplowanalytics.snowplow/tp2...
+INFO:snowplow_tracker.emitters:Finished synchronous flush
+INTERVENTION: attributes={} intervention_id='105eed7f-19ff-4093-9a52-e74cac47192a' name='account_expansion_nudge' target_attribute_key=TargetAttributeKey(id='1d124aec-2a1d-4316-b0aa-d5b0a1e2962d', name='account_id') version=1
+active_users: 2
+tasks_completed_count: 12
+last_plan: team
+Waiting for intervention...
+Received: attributes={} intervention_id='105eed7f-19ff-4093-9a52-e74cac47192a' name='account_expansion_nudge' target_attribute_key=TargetAttributeKey(id='1d124aec-2a1d-4316-b0aa-d5b0a1e2962d', name='account_id') version=1
+```
+
+Five things in there are worth reading closely:
+
+* The `INFO` lines come from the tracker, which configures logging on import. Two POST requests carry the twelve events, because the emitter flushes automatically at its default batch size of ten and `tracker.flush()` sends the remaining two.
+* `active_users: 2`, even though no single event knows about more than one user. That's the whole point of the custom key: `approx_count_distinct` counted two `domain_userid` values across events keyed on one `account_id`.
+* The intervention appears twice, first from the handler and then from `subscription.get()`. It's the same delivery reaching two consumers, not two firings.
+* The handler line lands before the attribute prints because the intervention arrived while the script was still polling. That ordering varies between runs.
+* `attributes={}` is empty because this intervention carries no attribute payload. Set `payload_attribute_groups` on the `RuleIntervention` to have the group's most recent values delivered with it. See [interventions](/docs/signals/interventions/) for the details.
+
+The script mints a fresh account each run, so you can run it repeatedly. If it prints the empty profile (`0`, `None`, `None`) after all twelve polls, the streaming engine hadn't applied your definitions yet when the events flowed through. Wait a moment and run it again.
+
+## Troubleshooting
+
+If the values or the intervention don't turn up, work through these:
+
+* Attributes come back as the empty profile (`0`, `None`, `None`): confirm you're retrieving with the same `account_id` value your events carry, and that the group and service are published.
+* Attributes stay empty even though you tracked events after publishing: the streaming engine applies new definitions with a short delay, and events processed before then aren't counted retroactively. Wait, send fresh events, and retrieve again.
+* `active_users` is `1` instead of `2`: all the events carried the same `domain_userid`. Check that each `Subject` sets its own `domain_user_id` and that each `track()` call passes the right `event_subject`.
+* The intervention never arrives: it fires only the first time the threshold is crossed for a target. Use a fresh `account_id` and re-track enough events. Also confirm the intervention is published and its threshold matches how many events you sent.
+* No interventions arrive for an account at all: check that its `account_id` is a canonically formatted UUID. Attribute retrieval works with any string, but intervention subscriptions accept UUIDs only.
diff --git a/tutorials/signals-account-attributes/track-the-account-entity.md b/tutorials/signals-account-attributes/track-the-account-entity.md
new file mode 100644
index 000000000..bc92bb344
--- /dev/null
+++ b/tutorials/signals-account-attributes/track-the-account-entity.md
@@ -0,0 +1,168 @@
+---
+title: "Track the account entity"
+position: 2
+sidebar_label: "Track the account entity"
+description: "Create an account entity schema, attach it to task_completed events, and track activity from multiple users of the same B2B account with the Snowplow Python tracker."
+keywords: ["snowplow python tracker", "entity schema", "account entity", "multi-tenant tracking", "server-side tracking", "snowplow assistant"]
+date: "2026-07-31"
+---
+
+In this section you'll make your project-management app's tracking multi-tenant. You'll create a [schema](/docs/fundamentals/schemas/) for an `account` [entity](/docs/fundamentals/entities/), attach it to `task_completed` events, and track activity from two different users who belong to the same account.
+
+The key idea is identity: every event carries the account's `account_id` in the entity. Signals later uses that property as the attribute key, so all events with the same `account_id` roll up into one account profile, no matter which user sent them.
+
+Start by installing both Python SDKs. You'll use the tracker in this section, and the Signals SDK in the next ones.
+
+```bash
+pip install snowplow-tracker snowplow-signals
+```
+
+Run this on Python 3.11 or later.
+
+This tutorial is written against `snowplow-tracker` 1.1.0 and `snowplow-signals` 0.4.6. If your installed versions differ, check the [Python tracker](/docs/sources/python-tracker/) and [Signals](/docs/signals/) documentation for any signature changes.
+
+## Create the schemas
+
+Your app emits a custom `task_completed` [self-describing event](/docs/fundamentals/events/#self-describing-events) and attaches an `account` entity to it. Both need a schema before Signals can read their properties.
+
+You'll create two [data structures](/docs/event-studio/data-structures/) in Console, using the vendor `com.example` to match the rest of this tutorial:
+
+* `task_completed`, a self-describing event with a required `task_id` string and an optional `priority` of `low`, `medium`, or `high`
+* `account`, an entity with a required `account_id` string carrying a UUID, and an optional `plan` string
+
+The quickest way to build both is to ask the [Snowplow Assistant](/docs/llms-support/console-agent/) in Console. Paste this prompt into the chat:
+
+```text
+Create two data structures with the vendor com.example, then deploy both of them to
+production.
+
+1. A self-describing event called task_completed, for a task being completed in a
+ project-management app, with these properties:
+ - task_id: string, required, the identifier of the completed task
+ - priority: string, optional, one of low, medium, or high
+2. An entity called account, for the B2B account an event belongs to, with these
+ properties:
+ - account_id: string, required, the unique identifier for the account, as a UUID
+ - plan: string, optional, the account's subscription plan
+
+Neither schema should allow additional properties.
+```
+
+The Assistant asks you to confirm before it creates anything, and it shows you each schema so you can check it against the list above.
+
+You can also build the data structures by hand in the **Data structures** section of Console, or with the [Snowplow CLI](/docs/api-reference/snowplow-cli/). Whichever route you take, both data structures need to be in production, so that your pipeline validates events against them.
+
+Once in production, these schemas resolve to the following Iglu URIs, which you'll reference from both the tracker and Signals:
+
+* `iglu:com.example/task_completed/jsonschema/1-0-0`
+* `iglu:com.example/account/jsonschema/1-0-0`
+
+:::tip[Use your own vendor]
+`com.example` is a placeholder. In a real project, use your organization's vendor (for example `com.acme`) consistently across your schemas, tracking code, and Signals definitions.
+:::
+
+### Use a UUID for the account ID
+
+Choose the `account_id` format deliberately, because it becomes a Signals attribute key identifier. You'll retrieve attributes by it, and target interventions at it.
+
+The intervention subscription endpoint doesn't perform authentication: knowing an attribute key ID grants access to its interventions. That's why Signals requires key ID values to be [non-enumerable](/docs/signals/applications/subscribe/), so they can't be guessed. In practice, Signals only accepts canonically formatted UUIDs when you subscribe to interventions. Attribute computation and retrieval accept any string value, but because this tutorial subscribes to account-level interventions, use a UUID for every `account_id`.
+
+In a real application, don't expose your internal account identifiers. Map each account to a stable UUID (for example, a deterministic UUID derived from the internal ID) and use that mapped value in the entity.
+
+## Initialize the tracker
+
+Create a tracker using the `Snowplow` factory, which is the recommended initialization path. To simulate a team, create a `Subject` for each of two users in the same account.
+
+Each `Subject` sets two identifiers:
+
+* `user_id`: the signed-in user's ID, as set by your authentication layer
+* `domain_userid`: a device-level identifier. Web and mobile trackers set this automatically, but server-side events don't carry one unless you set it. The `active_users` attribute you'll define later counts distinct `domain_userid` values, for [reasons covered on that page](/tutorials/signals-account-attributes/define-account-attributes), so set it explicitly here to represent each user's device.
+
+```python
+import uuid
+from snowplow_tracker import Snowplow, Subject
+
+COLLECTOR_URL = "https://YOUR_COLLECTOR_HOST" # your Collector endpoint, including https://
+
+# One B2B account, identified by a UUID.
+account_id = str(uuid.uuid4())
+print("account_id:", account_id)
+
+# Two users who belong to the account.
+alice = Subject().set_user_id(str(uuid.uuid4())).set_domain_user_id(str(uuid.uuid4()))
+bob = Subject().set_user_id(str(uuid.uuid4())).set_domain_user_id(str(uuid.uuid4()))
+
+tracker = Snowplow.create_tracker(
+ namespace="project-app",
+ endpoint=COLLECTOR_URL,
+ app_id="project-app-backend",
+)
+```
+
+Once you're running this inside an application, the tracker's [emitter](/docs/sources/python-tracker/emitters/) also accepts success and failure callbacks through `EmitterConfiguration`, so your own code can react to each batch.
+
+Keep the printed `account_id` to hand: you'll retrieve attributes and interventions for exactly that value later. Re-running this block mints a new account, which is the right behavior for testing but means the old value stops being interesting.
+
+In production, you wouldn't mint these identifiers with `uuid.uuid4()` on every run. The account UUID comes from your tenant model, and each user's identifiers come from your authentication layer. What matters is that the same `account_id` value appears on every event from that account.
+
+## Track team activity
+
+Define the `account` entity once, then attach it to every event. Pass each event's `Subject` with the `event_subject` argument, so one tracker can send events on behalf of different users.
+
+```python
+from snowplow_tracker import SelfDescribing, SelfDescribingJson
+
+# The account entity, attached to every event from this account.
+account = SelfDescribingJson(
+ "iglu:com.example/account/jsonschema/1-0-0",
+ {"account_id": account_id, "plan": "team"},
+)
+
+# Each team member completes six tasks.
+for member in (alice, bob):
+ for _ in range(6):
+ tracker.track(
+ SelfDescribing(
+ SelfDescribingJson(
+ "iglu:com.example/task_completed/jsonschema/1-0-0",
+ {"task_id": str(uuid.uuid4()), "priority": "high"},
+ ),
+ event_subject=member,
+ context=[account],
+ )
+ )
+
+tracker.flush()
+```
+
+By default the tracker batches events before sending them, so the `tracker.flush()` call forces any buffered events to be sent immediately.
+
+When a later section asks you to send fresh events, re-run this block only. Re-running the whole tracker section would mint a new `account_id`, and creating a second tracker with the `project-app` namespace in the same session isn't allowed.
+
+## Verify your events in Console
+
+Confirm the events reached your pipeline before you move on.
+
+In [Snowplow Console](https://console.snowplowanalytics.com), go to **Monitoring** > **Collection volumes** and select **Group by App ID**. Your events appear under the `project-app-backend` app ID with a `py-1.1.0` tracker, and the **EVENT COUNT** and **LAST SEEN** columns show the twelve events landing. Use the **Refresh** button to pick up the latest numbers. **Monitoring** > **Data quality** then shows the same period's valid events alongside any failed events.
+
+The [Snowplow Assistant](/docs/llms-support/console-agent/) can run the same check conversationally:
+
+```text
+Have my project-app-backend events arrived successfully? Show me the pipeline's
+event volume and any failed events for the last hour, and confirm that the
+com.example task_completed event and the com.example account entity are both in
+the data catalog.
+```
+
+Events flowing, no failed events, and both schemas in the data catalog means you're ready for the next section.
+
+## Troubleshooting
+
+These are the failures you're most likely to hit in this section:
+
+* `pip` can't find a version of `snowplow-signals` to install: you're on Python 3.10 or earlier. Create a virtual environment with Python 3.11 or later and install again.
+* Creating the tracker fails: you can only call `Snowplow.create_tracker()` once per namespace in a session. Reuse the existing `tracker` variable, or call `Snowplow.remove_tracker_by_namespace("project-app")` first.
+* Constructing the event fails: `SelfDescribing` takes the `SelfDescribingJson` as its first positional argument. The keyword form is `event_json=`, not `event=`.
+* Events arrive but the entity is missing: pass the entity in the `context` list argument, not inside the event's data payload. The entity must be a `SelfDescribingJson` referencing the `account` schema.
+* Events fail validation: check that both data structures are in production, and that the entity payload matches the schema exactly, including property names. Failed events appear in Console under **Monitoring** > **Data quality**.
+* All events attributed to one user: pass `event_subject=member` on each `track()` call. Without it, events carry no per-user identifiers.