diff --git a/ee/api/conversation.py b/ee/api/conversation.py index ed67d9f4a308e..85a178a9471db 100644 --- a/ee/api/conversation.py +++ b/ee/api/conversation.py @@ -49,6 +49,7 @@ class MessageSerializer(serializers.Serializer): trace_id = serializers.UUIDField(required=True) session_id = serializers.CharField(required=False) deep_research_mode = serializers.BooleanField(required=False, default=False) + deep_research_template = serializers.JSONField(required=False) def validate(self, data): if data["content"] is not None: @@ -146,8 +147,10 @@ def create(self, request: Request, *args, **kwargs): {"error": "Cannot access other users' conversations"}, status=status.HTTP_400_BAD_REQUEST ) except Conversation.DoesNotExist: - # Conversation doesn't exist, create it if we have a message - if not has_message: + # Allow creation with either a message or a deep research template + if not has_message and not ( + is_deep_research and serializer.validated_data.get("deep_research_template") is not None + ): return Response( {"error": "Cannot stream from non-existent conversation"}, status=status.HTTP_400_BAD_REQUEST ) @@ -164,6 +167,10 @@ def create(self, request: Request, *args, **kwargs): if has_message and not is_idle: raise Conflict("Cannot resume streaming with a new message") + deep_research_template = None + if is_deep_research: + deep_research_template = serializer.validated_data.get("deep_research_template") + workflow_inputs = AssistantConversationRunnerWorkflowInputs( team_id=self.team_id, user_id=cast(User, request.user).pk, # Use pk instead of id for User model @@ -175,6 +182,7 @@ def create(self, request: Request, *args, **kwargs): session_id=request.headers.get("X-POSTHOG-SESSION-ID"), # Relies on posthog-js __add_tracing_headers billing_context=serializer.validated_data.get("billing_context"), mode=mode, + deep_research_template=deep_research_template, ) async def async_stream( diff --git a/ee/hogai/assistant/assistant.py b/ee/hogai/assistant/assistant.py index dd439acac1e73..0a31ae0e0378d 100644 --- a/ee/hogai/assistant/assistant.py +++ b/ee/hogai/assistant/assistant.py @@ -9,6 +9,8 @@ from ee.hogai.assistant.deep_research_assistant import DeepResearchAssistant from ee.hogai.assistant.insights_assistant import InsightsAssistant from ee.hogai.assistant.main_assistant import MainAssistant +from ee.hogai.graph.deep_research.types import DeepResearchState, PartialDeepResearchState +from ee.hogai.utils.types import AssistantState, PartialAssistantState from ee.hogai.utils.types.base import AssistantMode from ee.hogai.utils.types.composed import AssistantMaxGraphState, AssistantMaxPartialGraphState from ee.models import Conversation @@ -30,23 +32,59 @@ def create( trace_id: Optional[str | UUID] = None, initial_state: Optional[AssistantMaxGraphState | AssistantMaxPartialGraphState] = None, billing_context: Optional[MaxBillingContext] = None, + deep_research_template: Optional[dict[str, Any]] = None, ) -> BaseAssistant: - assistant_class: type[BaseAssistant] if mode == AssistantMode.ASSISTANT: - assistant_class = MainAssistant + assistant_initial_state: Optional[AssistantState | PartialAssistantState] = None + if initial_state is not None: + if isinstance(initial_state, (AssistantState | PartialAssistantState)): + assistant_initial_state = initial_state + return MainAssistant( + team, + conversation, + new_message=new_message, + user=user, + session_id=session_id, + contextual_tools=contextual_tools, + is_new_conversation=is_new_conversation, + trace_id=trace_id, + billing_context=billing_context, + initial_state=assistant_initial_state, + ) elif mode == AssistantMode.INSIGHTS_TOOL: - assistant_class = InsightsAssistant + assistant_initial_state = None + if initial_state is not None: + if isinstance(initial_state, (AssistantState | PartialAssistantState)): + assistant_initial_state = initial_state + return InsightsAssistant( + team, + conversation, + new_message=new_message, + user=user, + session_id=session_id, + contextual_tools=contextual_tools, + is_new_conversation=is_new_conversation, + trace_id=trace_id, + billing_context=billing_context, + initial_state=assistant_initial_state, + ) elif mode == AssistantMode.DEEP_RESEARCH: - assistant_class = DeepResearchAssistant - return assistant_class( - team, - conversation, - new_message=new_message, - user=user, - session_id=session_id, - contextual_tools=contextual_tools, - is_new_conversation=is_new_conversation, - trace_id=trace_id, - billing_context=billing_context, - initial_state=initial_state, # type: ignore - ) + deep_research_initial_state: Optional[DeepResearchState | PartialDeepResearchState] = None + if initial_state is not None: + if isinstance(initial_state, (DeepResearchState | PartialDeepResearchState)): + deep_research_initial_state = initial_state + return DeepResearchAssistant( + team, + conversation, + new_message=new_message, + user=user, + session_id=session_id, + contextual_tools=contextual_tools, + is_new_conversation=is_new_conversation, + trace_id=trace_id, + billing_context=billing_context, + initial_state=deep_research_initial_state, + deep_research_template=deep_research_template, + ) + else: + raise ValueError(f"Unknown assistant mode: {mode}") diff --git a/ee/hogai/assistant/base.py b/ee/hogai/assistant/base.py index d9f635dd2e0a0..d6613ca82fd30 100644 --- a/ee/hogai/assistant/base.py +++ b/ee/hogai/assistant/base.py @@ -101,6 +101,7 @@ def __init__( billing_context: Optional[MaxBillingContext] = None, initial_state: Optional[AssistantMaxGraphState | AssistantMaxPartialGraphState] = None, callback_handler: Optional[BaseCallbackHandler] = None, + deep_research_template: Optional[dict[str, Any]] = None, ): self._team = team self._contextual_tools = contextual_tools or {} @@ -138,6 +139,7 @@ def __init__( self._mode = mode self._initial_state = initial_state self._commentary_chunk = None + self._deep_research_template = deep_research_template @property @abstractmethod diff --git a/ee/hogai/assistant/deep_research_assistant.py b/ee/hogai/assistant/deep_research_assistant.py index fe4dac57484d4..d6daf7255b7a8 100644 --- a/ee/hogai/assistant/deep_research_assistant.py +++ b/ee/hogai/assistant/deep_research_assistant.py @@ -41,6 +41,7 @@ def __init__( trace_id: Optional[str | UUID] = None, billing_context: Optional[MaxBillingContext] = None, initial_state: Optional[DeepResearchState | PartialDeepResearchState] = None, + deep_research_template: Optional[dict[str, Any]] = None, ): super().__init__( team, @@ -57,6 +58,7 @@ def __init__( trace_id=trace_id, billing_context=billing_context, initial_state=initial_state, + deep_research_template=deep_research_template, ) @property @@ -110,15 +112,35 @@ def _should_persist_commentary_message(self, node_name: MaxNodeName) -> bool: return False def get_initial_state(self) -> DeepResearchState: - if self._latest_message: - return DeepResearchState( - messages=[self._latest_message], - start_id=self._latest_message.id, - graph_status=None, - notebook_short_id=None, - ) - else: - return DeepResearchState(messages=[]) + # Inject a default human message when a template is selected without user input, + # and stream it immediately by setting _latest_message. + message_for_state = self._latest_message + if not self._latest_message and self._deep_research_template: + from uuid import uuid4 + + title = None + if isinstance(self._deep_research_template, dict): + title = self._deep_research_template.get("notebook_title") + + content = f"Load template: {title}" if title else "Load template" + message_for_state = HumanMessage(content=content, id=str(uuid4())) + self._latest_message = message_for_state + + base_state = DeepResearchState( + messages=[message_for_state] if message_for_state else [], + start_id=message_for_state.id if message_for_state else None, + graph_status=None, + notebook_short_id=None, + ) + + if self._deep_research_template: + if isinstance(self._deep_research_template, dict): + notebook_short_id = self._deep_research_template.get("notebook_short_id") + if notebook_short_id: + base_state.template_notebook_short_id = notebook_short_id + base_state.skip_onboarding = True + + return base_state def get_resumed_state(self) -> PartialDeepResearchState: if not self._latest_message: diff --git a/ee/hogai/graph/deep_research/notebook/nodes.py b/ee/hogai/graph/deep_research/notebook/nodes.py index bfd0410a5e38b..44467a43258c5 100644 --- a/ee/hogai/graph/deep_research/notebook/nodes.py +++ b/ee/hogai/graph/deep_research/notebook/nodes.py @@ -1,7 +1,19 @@ +from uuid import uuid4 + from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnableConfig +from posthoganalytics import capture_exception + +from posthog.schema import ( + DeepResearchNotebook, + DeepResearchType, + HumanMessage, + NotebookUpdateMessage, + ProsemirrorJSONContent, +) -from posthog.schema import DeepResearchNotebook, DeepResearchType, HumanMessage +from posthog.models.notebook.notebook import Notebook +from posthog.sync import database_sync_to_async from ee.hogai.graph.deep_research.base.nodes import DeepResearchNode from ee.hogai.graph.deep_research.notebook.prompts import DEEP_RESEARCH_NOTEBOOK_PLANNING_PROMPT @@ -15,18 +27,47 @@ def node_name(self) -> MaxNodeName: return DeepResearchNodeName.NOTEBOOK_PLANNING async def arun(self, state: DeepResearchState, config: RunnableConfig) -> PartialDeepResearchState: + # Load template + template_markdown = await self._retrieve_template_markdown(state) # We use instructions with the OpenAI Responses API instructions = DEEP_RESEARCH_NOTEBOOK_PLANNING_PROMPT.format( core_memory=await self._aget_core_memory(), ) + # Get last message if available, otherwise use empty string for template-only mode + if not state.messages: + raise IndexError("No messages in state") + last_message = state.messages[-1] if not isinstance(last_message, HumanMessage): raise ValueError("Last message is not a human message.") + human_content = last_message.content if last_message else "" + # If a template was provided, emit a synthetic "loaded notebook" message once + pre_messages: list = [] + if template_markdown and not state.has_emitted_template_loaded: + serializer = self._get_notebook_serializer() + json_content = serializer.from_markdown_to_json(template_markdown) + loaded_message = NotebookUpdateMessage( + id=str(uuid4()), + notebook_id=str(state.template_notebook_short_id or ""), + content=ProsemirrorJSONContent.model_validate(json_content.model_dump(exclude_none=True)), + notebook_type="deep_research", + event="loaded", + ) + await self._write_message(loaded_message) + pre_messages.append(loaded_message) + + # If template exists, use it (with or without additional human content) + # If no template, use human content (which should exist in this case) + if template_markdown: + human_message = f"{template_markdown}\n\n{human_content}" if human_content else template_markdown + else: + human_message = human_content + prompt = ChatPromptTemplate.from_messages( [ - ("human", last_message.content), + ("human", human_message), ] ) @@ -49,8 +90,46 @@ async def arun(self, state: DeepResearchState, config: RunnableConfig) -> Partia notebook_update_message.current_run_notebooks = current_run_notebooks return PartialDeepResearchState( - messages=[notebook_update_message], + messages=[*pre_messages, notebook_update_message], previous_response_id=None, # we reset the previous response id because we're starting a new conversation after the onboarding conversation_notebooks=[notebook_info], current_run_notebooks=current_run_notebooks, + has_emitted_template_loaded=True if pre_messages else state.has_emitted_template_loaded, ) + + @database_sync_to_async + def get_notebook(self, state: DeepResearchState) -> Notebook: + return Notebook.objects.filter( + team=self._team, short_id=str(state.template_notebook_short_id), deleted=False + ).first() + + async def _retrieve_template_markdown(self, state: DeepResearchState) -> str | None: + if not ( + state.template_notebook_short_id and not state.template_markdown and not state.has_emitted_template_loaded + ): + return state.template_markdown + + try: + notebook = await self.get_notebook(state) + + if not notebook: + return state.template_markdown + + text_content = getattr(notebook, "text_content", None) + if text_content: + return text_content + + content = getattr(notebook, "content", None) + if content: + try: + nb_json = ProsemirrorJSONContent.model_validate(notebook.content) + from ee.hogai.notebook.notebook_serializer import NotebookSerializer + + return NotebookSerializer().from_json_to_markdown(nb_json) + except Exception: + return state.template_markdown + + return state.template_markdown + except Exception as e: + capture_exception(e) + return state.template_markdown diff --git a/ee/hogai/graph/deep_research/onboarding/nodes.py b/ee/hogai/graph/deep_research/onboarding/nodes.py index ec8dcf76336f3..8ad7ce6e72733 100644 --- a/ee/hogai/graph/deep_research/onboarding/nodes.py +++ b/ee/hogai/graph/deep_research/onboarding/nodes.py @@ -20,6 +20,12 @@ def node_name(self) -> MaxNodeName: return DeepResearchNodeName.NOTEBOOK_PLANNING def should_run_onboarding_at_start(self, state: DeepResearchState) -> Literal["onboarding", "planning", "continue"]: + # Skipping onboarding when provided a template + if state.skip_onboarding: + if state.current_run_notebooks: + return "continue" + return "planning" + if not state.messages: return "onboarding" diff --git a/ee/hogai/graph/deep_research/types.py b/ee/hogai/graph/deep_research/types.py index 449a32dc5f941..246f87f30114b 100644 --- a/ee/hogai/graph/deep_research/types.py +++ b/ee/hogai/graph/deep_research/types.py @@ -63,6 +63,22 @@ class _SharedDeepResearchState(BaseStateWithMessages, BaseStateWithTasks): """ Notebooks created in the current deep research run (reset on new run). """ + skip_onboarding: Annotated[Optional[bool], replace] = Field(default=None) + """ + If true, skip the onboarding node routing and go straight to planning. + """ + template_markdown: Annotated[Optional[str], replace] = Field(default=None) + """ + Template markdown content when deep research starts from a template. + """ + template_notebook_short_id: Annotated[Optional[str], replace] = Field(default=None) + """ + Template notebook ID when deep research starts from a template. + """ + has_emitted_template_loaded: Annotated[bool, replace] = Field(default=False) + """ + Whether the template loaded message has been emitted. + """ class DeepResearchState(_SharedDeepResearchState): diff --git a/posthog/api/notebook.py b/posthog/api/notebook.py index bf0dbb3475b27..9880d1b682790 100644 --- a/posthog/api/notebook.py +++ b/posthog/api/notebook.py @@ -26,6 +26,7 @@ from posthog.models.activity_logging.activity_page import activity_page_response from posthog.models.notebook.notebook import Notebook from posthog.models.utils import UUIDT +from posthog.notebooks.curated_templates import DEFAULT_CUSTOM_DEEP_RESEARCH_NOTEBOOK from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin from posthog.rbac.user_access_control import UserAccessControlSerializerMixin from posthog.utils import relative_date_parse @@ -122,6 +123,16 @@ def create(self, validated_data: dict, *args, **kwargs) -> Notebook: request = self.context["request"] team = self.context["get_team"]() + # If creating a deep research template, populate default content when FE sent only a barebone tiptap document + if "template_deep_research" in validated_data.get("tags", []): + content = validated_data.get("content") + title = validated_data.get("title") + + if not content or self._is_barebone_tiptap_doc(content, title): + validated_data["content"] = DEFAULT_CUSTOM_DEEP_RESEARCH_NOTEBOOK["content"] + if not validated_data.get("title"): + validated_data["title"] = DEFAULT_CUSTOM_DEEP_RESEARCH_NOTEBOOK["title"] + created_by = validated_data.pop("created_by", request.user) notebook = Notebook.objects.create( team=team, @@ -177,6 +188,30 @@ def update(self, instance: Notebook, validated_data: dict, **kwargs) -> Notebook return updated_notebook + def _is_barebone_tiptap_doc(self, doc: dict | None, title_text: str | None) -> bool: + """Check if TipTap doc is empty or only contains a heading matching the title.""" + if not isinstance(doc, dict): + return True + + nodes = doc.get("content", []) + if not nodes: + return True + + # Only check if it's a single heading node + if len(nodes) != 1 or nodes[0].get("type") != "heading": + return False + + heading_content = nodes[0].get("content", []) + if not heading_content: + return True + + # Single text node in heading + if len(heading_content) == 1 and heading_content[0].get("type") == "text": + text = (heading_content[0].get("text") or "").strip() + return text == "" or text == (title_text or "").strip() + + return False + @extend_schema( description="The API for interacting with Notebooks. This feature is in early access and the API can have " @@ -266,6 +301,12 @@ def _filter_list_request(self, request: Request, queryset: QuerySet, filters: di for key in filters: value = filters.get(key, None) + if key == "tags" and isinstance(value, str): + # value may be comma-separated list of tags; match notebooks containing ANY of the tags + tag_list = [v.strip() for v in value.split(",") if v.strip()] + if tag_list: + queryset = queryset.filter(tags__overlap=tag_list) + continue if key == "user": queryset = queryset.filter(created_by=request.user) elif key == "created_by": diff --git a/posthog/notebooks/curated_templates.py b/posthog/notebooks/curated_templates.py new file mode 100644 index 0000000000000..a58c95c00e410 --- /dev/null +++ b/posthog/notebooks/curated_templates.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +# Curated deep-research notebook templates stored as ProseMirror JSON + +CURATED_DEEP_RESEARCH_NOTEBOOKS: list[dict] = [ + { + "id": "dr-conversion-regression-analysis", + "title": "Conversion regression analysis", + "content": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Conversion regression analysis"}], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Analyze the drop in conversion rate observed since . Identify impacted surfaces, segments, and likely causes.", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Users: , Geography: , Timeframe: .", + } + ], + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Key flows / features: .", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Success metrics"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Primary: overall conversion rate (visit → signup, signup → activation). Secondary: step-level conversion in critical funnels.", + } + ], + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Context & hypotheses"}], + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Recent changes: . Hypotheses: .", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]}, + { + "type": "paragraph", + "content": [{"type": "text", "text": "- When did the regression start? Is it seasonal?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": "- Which segments (device, geo, source) are most impacted?"}], + }, + {"type": "paragraph", "content": [{"type": "text", "text": "- Which funnel steps are most affected?"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "- Are there concurrent experiment or feature changes correlating with the drop?", + } + ], + }, + ], + }, + }, + { + "id": "dr-funnel-drop-investigation", + "title": "Funnel drop investigation", + "content": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Funnel drop investigation"}], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Locate and quantify the largest drops in the and prioritize fixes for the highest-impact steps.", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Timeframe: . Audience: . Flow: .", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Success metrics"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Step-wise conversion rates, absolute and relative drop, impact in users and %.", + } + ], + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Context & hypotheses"}], + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Known issues or UI friction: . Hypotheses: .", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "- Which step has the largest drop?"}]}, + { + "type": "paragraph", + "content": [{"type": "text", "text": "- Are certain segments disproportionately affected?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": "- Did the drop coincide with a release/flag rollout?"}], + }, + ], + }, + }, + { + "id": "dr-feature-launch-postmortem", + "title": "Feature launch postmortem", + "content": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Feature launch postmortem"}], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Assess adoption, engagement, performance, and downstream impact of the launch.", + } + ], + }, + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]}, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Cohorts: exposed vs control (if available). Timeframe:
/. Platforms: .",
+                        }
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Success metrics"}]},
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Adoption (reach, activation), engagement (DAU/WAU, retention on feature), conversion impact.",
+                        }
+                    ],
+                },
+                {
+                    "type": "heading",
+                    "attrs": {"level": 2},
+                    "content": [{"type": "text", "text": "Context & hypotheses"}],
+                },
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {"type": "text", "text": "Rollout flags, known issues, marketing pushes, seasonality."}
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]},
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Did the feature move the primary KPI?"}]},
+                {
+                    "type": "paragraph",
+                    "content": [{"type": "text", "text": "- Are there regressions in adjacent flows?"}],
+                },
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Which segments over/under-performed?"}]},
+            ],
+        },
+    },
+    {
+        "id": "dr-retention-cohort-deep-dive",
+        "title": "Retention cohort deep dive",
+        "content": {
+            "type": "doc",
+            "content": [
+                {
+                    "type": "heading",
+                    "attrs": {"level": 1},
+                    "content": [{"type": "text", "text": "Retention cohort deep dive"}],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]},
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Understand retention by cohort, lifecycle stage, and product usage patterns to identify levers to improve week N retention.",
+                        }
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]},
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Cohorts: signup week cohorts over last  weeks. Segments: device, geo, acquisition source.",
+                        }
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Success metrics"}]},
+                {
+                    "type": "paragraph",
+                    "content": [{"type": "text", "text": "D1/D7/D28 retention, mean vs median activity, stickiness."}],
+                },
+                {
+                    "type": "heading",
+                    "attrs": {"level": 2},
+                    "content": [{"type": "text", "text": "Context & hypotheses"}],
+                },
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {"type": "text", "text": "Activation criteria, onboarding changes, pricing/events changes."}
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]},
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Which cohorts underperform baseline?"}]},
+                {
+                    "type": "paragraph",
+                    "content": [{"type": "text", "text": "- What usage patterns correlate with higher retention?"}],
+                },
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Which segments are most sensitive?"}]},
+            ],
+        },
+    },
+    {
+        "id": "dr-growth-experiment-analysis",
+        "title": "Growth experiment analysis",
+        "content": {
+            "type": "doc",
+            "content": [
+                {
+                    "type": "heading",
+                    "attrs": {"level": 1},
+                    "content": [{"type": "text", "text": "Growth experiment analysis"}],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]},
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Evaluate results for the , quantify lift, heterogeneity of effects, and guardrail impacts.",
+                        }
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]},
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Arms: control vs variant(s). Exposure: . Timeframe: .",
+                        }
+                    ],
+                },
+                {
+                    "type": "heading",
+                    "attrs": {"level": 2},
+                    "content": [{"type": "text", "text": "Primary/secondary metrics"}],
+                },
+                {
+                    "type": "paragraph",
+                    "content": [
+                        {
+                            "type": "text",
+                            "text": "Primary: . Secondary: guardrails (latency, error rate, churn, units).",
+                        }
+                    ],
+                },
+                {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]},
+                {
+                    "type": "paragraph",
+                    "content": [{"type": "text", "text": "- Did we reach power? Is lift statistically significant?"}],
+                },
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Any segment-level interactions?"}]},
+                {"type": "paragraph", "content": [{"type": "text", "text": "- Any negative guardrail movement?"}]},
+            ],
+        },
+    },
+]
+
+# Default structure for user-created custom deep research notebooks
+# This is NOT seeded into the database, but used when creating custom templates on-demand
+DEFAULT_CUSTOM_DEEP_RESEARCH_NOTEBOOK = {
+    "id": "dr-custom-research",
+    "title": "Custom Deep Research Template",
+    "content": {
+        "type": "doc",
+        "content": [
+            {
+                "type": "heading",
+                "attrs": {"level": 1},
+                "content": [{"type": "text", "text": "Custom research"}],
+            },
+            {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Objective"}]},
+            {
+                "type": "paragraph",
+                "content": [
+                    {
+                        "type": "text",
+                        "text": "What specific question are you trying to answer? What business impact does this have?",
+                    }
+                ],
+            },
+            {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Scope"}]},
+            {
+                "type": "paragraph",
+                "content": [{"type": "text", "text": "Users: "}],
+            },
+            {
+                "type": "paragraph",
+                "content": [{"type": "text", "text": "Timeframe: "}],
+            },
+            {
+                "type": "paragraph",
+                "content": [{"type": "text", "text": "Features/flows: "}],
+            },
+            {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Success metrics"}]},
+            {
+                "type": "paragraph",
+                "content": [{"type": "text", "text": "What KPIs define success? Any comparison points or benchmarks?"}],
+            },
+            {
+                "type": "heading",
+                "attrs": {"level": 2},
+                "content": [{"type": "text", "text": "Context & hypotheses"}],
+            },
+            {
+                "type": "paragraph",
+                "content": [
+                    {"type": "text", "text": "Recent changes, working hypotheses, or constraints to be aware of."}
+                ],
+            },
+            {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Questions"}]},
+            {
+                "type": "paragraph",
+                "content": [{"type": "text", "text": "- What specific questions do you need answered?"}],
+            },
+        ],
+    },
+}
diff --git a/posthog/schema.py b/posthog/schema.py
index d96a998ea8069..6c810cb311626 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -1969,6 +1969,11 @@ class NodeKind(StrEnum):
     VECTOR_SEARCH_QUERY = "VectorSearchQuery"
 
 
+class Event(StrEnum):
+    LOADED = "loaded"
+    UPDATED = "updated"
+
+
 class PageURL(BaseModel):
     model_config = ConfigDict(
         extra="forbid",
@@ -4235,6 +4240,7 @@ class NotebookUpdateMessage(BaseModel):
     content: ProsemirrorJSONContent
     conversation_notebooks: Optional[list[DeepResearchNotebook]] = None
     current_run_notebooks: Optional[list[DeepResearchNotebook]] = None
+    event: Optional[Event] = None
     id: Optional[str] = None
     notebook_id: str
     notebook_type: Literal["deep_research"] = "deep_research"
diff --git a/posthog/temporal/ai/conversation.py b/posthog/temporal/ai/conversation.py
index b4fd76c7e0ad3..5aa4d52d2ae1d 100644
--- a/posthog/temporal/ai/conversation.py
+++ b/posthog/temporal/ai/conversation.py
@@ -46,6 +46,7 @@ class AssistantConversationRunnerWorkflowInputs:
     session_id: Optional[str] = None
     mode: AssistantMode = AssistantMode.ASSISTANT
     billing_context: Optional[MaxBillingContext] = None
+    deep_research_template: Optional[dict[str, Any]] = None
 
 
 @workflow.defn(name="conversation-processing")
@@ -90,6 +91,8 @@ async def process_conversation_activity(inputs: AssistantConversationRunnerWorkf
 
     human_message = HumanMessage.model_validate(inputs.message) if inputs.message else None
 
+    deep_research_template = inputs.deep_research_template if inputs.mode == AssistantMode.DEEP_RESEARCH else None
+
     assistant = Assistant.create(
         team,
         conversation,
@@ -101,6 +104,7 @@ async def process_conversation_activity(inputs: AssistantConversationRunnerWorkf
         session_id=inputs.session_id,
         mode=inputs.mode,
         billing_context=inputs.billing_context,
+        deep_research_template=deep_research_template,
     )
 
     stream_key = get_conversation_stream_key(inputs.conversation_id)